Two independent systems color the app.
  • The theme decides neutral colors: backgrounds, text, hairlines, status colors. It has a dark and a light palette.
  • The brand decides the accent color and identity: the studio’s primary color, logo and name. It comes from the server per studio.
A screen reads both: useColors() for neutrals and useBrandPalette() for the accent.

Tokens

src/theme/index.ts re-exports everything.

Spacing, radius, sizes

From src/theme/spacing.ts: spacing.screen is the horizontal page gutter. sizes.maxContentWidth caps the content column, which keeps the layout sane in the web preview and on wide devices.

Typography

src/theme/typography.ts defines twelve variants. AppText takes one through its variant prop. variantColorKey maps each variant to ink, inkSecondary or inkTertiary. AppText resolves that key against the active palette, so text recolors with the theme. The colors baked into the typography objects are a static fallback only.

Fonts

fontFamily is resolved once at module load from the stored language: The root layout also loads Heebo and Assistant for the story share cards, Rubik for the glass home skin, Karantina and Assistant for the poster skin, and Varela Round for the bento skin. Because the family is picked at module load, a language change needs the reload that setLanguage performs.

Palettes

src/theme/colors.ts exports darkColors and lightColors with identical keys, typed as ThemePalette. The dark background is #0B0B14, which matches the native splash and backgroundColor in app.json. The light background is #F4F6F9. colors is a static export of the dark palette for the few non-reactive uses. Components should use the hook.
colors.brand in the palette is the Perform default red. For anything the studio should recolor, read useBrandPalette().brand instead.

Theme store

useThemeStore in src/theme/themeStore.ts holds scheme, the matching colors object and hasChoice.

Who wins

  1. The trainee’s choice. Set by the Dark mode switch on the profile tab, which calls toggle().
  2. The studio default. authStore.applyStudioScheme calls applyDefault when studio.branding.appTheme is 'dark' or 'light'.
  3. Dark.
The two sources are stored under separate keys so one cannot overwrite the other: The app does not follow the operating system setting. userInterfaceStyle in app.json is dark. The root layout hydrates the theme before the auth store, and sets the status bar and Android navigation bar style from the scheme.

Themed styles

Static StyleSheet.create cannot react to the scheme. Use useThemedStyles with a factory declared outside the component:
The hook memoizes on the palette and the factory identity, so the factory must be stable. The auth screens are the exception. They always use darkColors directly and do not follow the theme.

Studio brand

src/features/branding/brandStore.ts holds a Brand and a derived BrandPalette.

Where it comes from

authStore.brandFromStudio(studio) maps studio.branding from /v1/trainee/me and from the sign in response, then calls useBrandStore.apply(). This runs on sign in, on studio switch and on every refreshSession(). A coach who changes branding in the dashboard sees it in the app the next time the trainee opens it. Mapping rules worth knowing:
  • logoUrl falls back to iconUrl.
  • storyBackgroundUrl falls back to the older selfieExampleUrl field.
  • primaryColor must be a six digit hex. Anything else falls back to the default red.
  • homeTheme must be one of HOME_THEMES. Anything else becomes classic.
apply() persists the normalized brand under perform_brand. hydrate() restores it at launch so the login screen is already in the studio’s colors. reset() runs on sign out.

Derived palette

paletteFor(primaryColor) builds:

Color helpers

src/theme/brandColor.ts: useAuthAccent() uses ensureVibrant so a very dark studio color is replaced by #FF5F4D on the always-dark login screens.

Home skins

HOME_THEMES is ['classic', 'glass', 'poster', 'bento']. The coach picks one for the studio in the dashboard branding settings, and HomeScreen switches on useHomeTheme(). The skin also dresses every other screen. Screen renders SkinBackground behind its content whenever the skin is not classic: Base colors per skin and scheme are in SKIN_BASE_COLOR. The comment in SkinBackground.tsx notes that these recipes mirror the skin home screens and must change together.

Brand in the header and widgets

BrandTopBar shows the page title and the studio logo. If the logo fails to load it falls back to the studio name in the brand color. widgetBrandLogo.ts downloads the studio logo into the shared widgets directory as brand-logo.png so the iOS widgets and Live Activity can show it. With no studio logo it installs the bundled Perform mark instead.

Rules for new UI

  • Neutrals from useColors(). Accent from useBrandPalette(). No hard-coded hex for either.
  • Spacing and radius from the tokens.
  • Text through AppText with a variant.
  • Styles that depend on the palette through useThemedStyles.
  • The project rule is no shadows. ProgressBar has an optional glow, which is the one deliberate exception in the kit.