Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureBrand system

Brand system

How the Taikan identity (brand kit v3.1) is encoded in code, and the rules that keep every surface on it. The design source is the Claude Design project “Taikan Brand Kit v3”; everything in the repos is derived from it.

One source, everything generated

scripts/brand/mark.mjs geometry, palette, ramp, SVG builders ← the source of truth scripts/brand/extract-text.mjs outlines "Taikan" (Rubik 600) + tagline (Assistant 700) scripts/generate-brand-assets.mjs composes + rasterises every output assets/brand/ generated outlines + the asset README
pnpm brand:assets # web, marketing, admin, docs, minisites (+ the React/Astro marks) pnpm brand:assets:all # + the mobile repo (TAIKAN_MOBILE_ROOT, default ../FitKit-mobile) pnpm brand:check # assert every expected output exists

Generated and never hand-edited: apps/*/public/{favicon*,favicons,icons,logo,og-social,ms-tiles}, apps/web/src/components/brand/taikan-mark.tsx, apps/marketing/src/components/ui/TaikanMark.astro, apps/admin/src/components/taikan-mark.tsx, apps/docs/app/taikan-mark.tsx, the mobile assets/images/* and store-assets/{play,appstore} icons and feature graphic.

CI (ci-smoke-tests.yml → “Brand assets in sync”) regenerates and diffs the text outputs, so a palette or geometry edit that was not re-run fails the build.

The mark

A barbell glyph on a 24×24 grid, one closed outline, filled with a vertical teal → violet ramp (#3FE0DC → #2AB8B8 → #3E8FD0 → #8A5CF0 → #6C43D8). Two rules the generator and the components enforce mechanically:

  • Below 32px, ship the flat file. The fillets and shading bands sit near 1u — sub-pixel at 20px. <TaikanMark size={n}> (web, admin, docs), <TaikanMark size={n}> (marketing) and <FKBrandMark size={n}> (mobile) all pick the variant from size, so a call site cannot forget.
  • Alpha-only outputs always use the flat file. Android discards a gradient on the notification icon; MS tiles composite against TileColor; the email letterhead is 18px on a light ground.

And the rules a human still has to enforce:

  • Ramps run vertically only. A diagonal ramp breaks the mark’s left–right mirror and would force a second file for the RTL lockup.
  • The wordmark never takes the ramp. “Taikan” is Rubik SemiBold, tracking −18/1000, solid.
  • Mint #B0E5C4 is not in the ramp. Reserved for PR / streak / today, so success states can’t read as brand chrome. Four colours in the brand, three in the mark.
  • The favicon is the app icon. Same tile, same art fraction, downscaled. A tab glyph that differs from the home-screen icon reads as a different product.

Palette → tokens

RoleLightDarkWeb tokenMobile
Brand teal (graphics, never behind text)#0E8C8C#2AB8B8--brand-tealbrandTeal
Primary action fill#0B7575#2AB8B8--primaryprimary
Ink on primarywhite#0A1628--primary-foregroundonPrimary
Violet — secondary action#7A4BE0#9B6BFF--violetviolet
Ink on violetwhite#0A1628--violet-foregroundonViolet
Violet as text#6C43D8#A987FF--violet-textvioletText
Mint (PR / streak / today)#2E7A4D text#B0E5C4--brand-mint, successsuccess
Ground#F6F8FA#0A1628 (web) · #07202B (mobile, admin, bands)--backgroundbackground

Marketing carries the same values as --color-* in apps/marketing/src/styles/global.css, with on-band remaps inside <Band>.

Two contrast rules that came out of the rollout

The raw brand teal can’t carry white text. White on #0E8C8C is 4.08:1 — below AA. Filled controls use #0B7575 (5.50:1); the raw teal is kept for graphics.

Bright fills on dark grounds take dark ink. White on dark-mode teal #2AB8B8 is 2.43:1; dark ink is 7.46:1. The same holds for violet (#9B6BFF + white 3.53:1, + #0A1628 5.13:1). This is why --primary-foreground flips to navy in .dark — any surface that pairs it with a hardcoded dark background must use text-white explicitly (see the workout drawer header).

Where violet goes

Violet is the secondary action and the colour of the AI surface. Teal stays the primary path.

  • Web: Button variant="violet" | "violet-outline", Badge variant="violet"; “Ask Spotter” in the action center is violet-outline.
  • Marketing: Button variant="violet", Badge variant="violet"; the Spotter section and the AI pillar card are violet, Autopilot event chips are violet.
  • Mobile: violet / violetText / onViolet / violetSoft / violetEdge tokens. No call site yet — there is no Spotter surface in the app.

Typography

Only the wordmark changed: Rubik 600 at −18/1000 (--font-wordmark, TaikanLockup). The product UI keeps Manrope + Clash Grotesk (web), Rubik display + Assistant body (mobile), Heebo for Hebrew. The kit’s tagline “Train. Track. Thrive.” (he: “אימון. מעקב. שגשוג.”, ru: “Тренируйся. Отслеживай. Процветай.”) is used on the OG cards, the Play feature graphic and the mobile splash, one ramp colour per word.

Surfaces and where they are configured

SurfaceConfig
Web favicons, PWA icons, OGapps/web/src/app/[lang]/layout.tsx (generateMetadata), public/manifest.webmanifest, browserconfig.xml
Web auth pagescomponents/brand/auth-frame.tsx; Clerk themed via appearance={{ baseTheme: shadcn }} on ClerkProvider
Marketingcomponents/Head.astro, Navbar.astro, Footer.astro (inline TaikanMark.astro)
Adminindex.html, src/theme.ts, Refine ThemedTitle icon=
Docsapps/docs/app/layout.jsx
Minisites/favicon.ico fallback only — sites are white-labelled per org, never a Taikan favicon.svg
Emailapps/api/src/notifications/templates/theme.ts; letterhead mark is the flat 64px raster
Mobileapp.config.ts (icon, ios.icon.{light,dark,tinted}, android.adaptiveIcon + monochromeImage, expo-notifications.icon, expo-splash-screen)
Store listingsstore-assets/play/ (feature graphic, 512 icon), store-assets/appstore/icon-1024.png; screenshots are captured, not generated

Off-repo surfaces (manual checklist)

These hold a logo or icon that nothing in the repos controls. After a brand change, update by hand: Clerk dashboard (hosted accounts.taikan.fit pages, email logo), App Store Connect + Play Console listings, GitHub org/repo social preview, Sentry / PostHog / Railway / Vercel project avatars, Cloudflare email-routing sender, and the WhatsApp Business profile.

Organization mobile branding (TKN-337)

Disabled and unpublished responses use the existing Taikan visual identity. Elite organizations can manage a draft and publish a versioned snapshot from Grow → Mobile Branding; dedicated native apps also require Elite. Only identity tokens change. Semantic colors and neutral surfaces remain FK tokens, and deriveMobilePalette derives readable foregrounds for bright fills. Uploads are organization-owned PNG objects; drafts do not become live until published. Publishing resets screenshot/internal-smoke attestations.

Native app locking is independent of visual branding. Only fit.taikan.app can be a multi-organization container. Every other native application ID, including an unknown ID or Expo Go, blocks until the public immutable application-ID lookup succeeds. A network failure, disabled mapping, or missing mapping never unlocks the container. There is no persisted identity fallback. The mobile native version is 1.0.6 so pre-lock 1.0.5 OTA updates cannot roll back into these binaries. Public visual snapshots can be cached for thirty minutes; authoritative negatives clear/bypass them without changing the dedicated organization lock.

Rich clients negotiate the payload version per route, and the parameter name is not the same on both: public branding and application lookups read schema, while GET /users/me reads brandingSchema; both parse it with parseBrandingSchemaVersion. Schema 4 carries the current palette vocabulary (primaryColor, secondaryColor, optional buttonColor, cardColor, backgroundColor, darkCardColor, darkBackgroundColor; published brand schemaVersion: 2). Every colour the owner sets is honoured; every absent one is derived by deriveBrandSurfaceColors, and every foreground is always derived. There is no backgroundStyle any more.

Schemas 1–3 exist only for app versions already installed on phones, and are produced by projectLegacyPublishedBrand, which emits the frozen pre-schema-4 shape (accentColor, backgroundStyle, surfaceColor, brand schemaVersion: 1). Schema 3 receives the DERIVED page and card colours as backgroundStyle: 'custom', so an old app paints exactly what a new one paints; schema 2 receives tinted (no owner surfaces) or default (owner surfaces it cannot carry); schema 1 receives no surface fields. None can carry buttonColor. Requests without the parameter keep the schema-1 shape. Delete the projection when no installed version requests schema ≤ 3.

The only other reader of the legacy shape is storedReleaseSnapshotSchema: release snapshots are hash-bound (snapshot_sha256, bound by approvals and operations) and are never rewritten, so releases created before schema 4 still dispatch. New snapshots are built with releaseSnapshotSchema, which only admits brand schemaVersion: 2. Delete the union when no unresolved release snapshot carries brand.schemaVersion 1. Migration 0160_palette_schema_4 rewrote every other stored palette.

Installed /b/<slug> resolution does not depend on the tier. Disabled apps and inactive/deleted organizations still deny access.

Dedicated native IDs and link slugs are immutable database mappings, independent of a gym’s renameable slug. Apple IDs and Android IDs use different validators; generated Android package segments replace slug hyphens with underscores. Dedicated binaries register only taikan-<slug>://. Their web associations claim exactly /b/<slug> and /b/<slug>/…, never a bare prefix such as /b/<slug>*. The browser fallback resolves that immutable slug and preserves the organization. Push registration requires membership in the mapped org; fanout and delivery revalidate dedicated org scope, membership, app enablement and token revocation.

Publishing from the workspace

The workspace (apps/web/.../dashboard/branding/) has exactly one publish action: the Publish button in the bar pinned under the dashboard header (branding-workspace.tsx). The tabs only edit. Each tab carries its channel’s state as the setup rail’s marker (StepMarker): settled = live, current = changes waiting, hollow = not live yet. The state comes from the read model, GET /organizations/:orgId/brand-workspace, whose channels carry server-computed pending flags (OrganizationAppearanceService.getWorkspace):

Channelpending when
Branda draft exists and its palette identity (colours case-insensitive, name, logos) differs from the published one, or nothing is published
Websiteadopted (schema 2) and never published, or publishedContentRevision !== draftRevision
Member appa draft row exists and nothing is published, or the row was written more than 1 s after publishedAt

BrandingService.publish writes publishedAt and updatedAt from one instant so a fresh publish never reads as pending.

publish-center.tsx owns the dialog. Editors register a handle with usePublishChannel(channel, { dirty, prepare, settle }). prepare stores what is on screen and returns the exact revision to publish. settle adopts the stored state once the channel is verified. The dialog preselects every pending channel and a brand or member app that was never published. It never preselects a website that was never published: going public is the owner’s call. It keeps the brand ticked while the app is ticked and the brand has never been published (the app takes its colours from the published brand). It also says so, with a one-click fix, when the brand is ticked and the never-published app is not. That trap had an owner told “published” while phones kept the Taikan look.

Channels publish in order (brand → website → app), and the run stops at the first failure. Every write is followed by a fresh read of the read model. The row turns live only when that read shows the change: the brand’s published revision advanced with nothing pending, the site’s publishedContentRevision equals the published draft revision, and the app’s version advanced. For the app, the dialog also reads GET /public/organizations/:id/branding?schema=4, the endpoint phones read, and requires status: 'published' at the new version. A write the read-back does not show is reported as not live, never as success; disabled there is reported as an app that isn’t served. The website publish sends the brand revision read after the brand step, because the site pins the palette revision it goes out with.

Analytics (BrandingEvents in libs/shared/src/lib/analytics/branding-events.ts):

  • Server (after commit only, never on a refused write):
    • branding.brand_published with appPropagated
    • branding.brand_restored
    • branding.website_published and branding.website_unpublished
    • branding.app_published and branding.app_unpublished
    • branding.asset_normalized after a stored upload; asset intake is the one place a refusal is also captured, as branding.asset_rejected (see asset intake below)
    • Refused writes already surface as api.request_failed with the API code.
  • Web:
    • branding.tab_selected
    • the publish funnel:
      • branding.publish_opened, with the entry point and every channel’s state
      • branding.publish_channel_toggled
      • branding.publish_confirmed or branding.publish_cancelled
      • one branding.publish_channel_result per channel: verified, unverified, failed with code, or skipped, plus durationMs
      • branding.publish_completed
    • branding.control_clicked for every button, link, tab, checkbox and file picker inside [data-branding-scope], keyed by data-testid, so a dead click is as visible as a working one.

Asset intake and rejection codes

Each member-app slot (icon, logo, wordmark, splashLogo) is uploaded on its own; nothing is derived from another slot at intake. POST /organizations/:orgId/branding/assets/:name takes one multipart file part (PNG, JPEG or WebP, MAX_BRAND_ASSET_BYTES) and an optional transform text part — JSON matching brandAssetTransformSchema: the owner’s crop rectangle, for the icon only a background fill colour, and for every other slot removeBackground. It answers 400 {code} on every refusal, including an oversized body (brand_asset_size, via BrandAssetUploadInterceptor, which wraps multer) and a malformed multipart body (brand_asset_multipart). Both intake routes carry @Throttle({ default: { ttl: 60_000, limit: 20 } }). Bounds, the transform schema and the format sniff (sniffBrandAssetSource) live in libs/shared/src/lib/branding.ts so the API and the web pre-flight cannot answer differently for the same file. The codes map to mobileSubmission.* copy in all three locales.

The browser only chooses; the server produces the stored PNG. normalizeBrandAsset (apps/api/src/organizations/branding-normalize.ts) runs inside the request, at most two per process at once (concurrency-limit.ts; further uploads wait, they are not refused):

  1. Sniffs the leading bytes — only PNG, JPEG and WebP ever reach a decoder (brand_asset_source_format; SVG, GIF, HEIC and animated files included).
  2. Reads the header under a 100-megapixel probe ceiling and refuses anything over MAX_BRAND_ASSET_SOURCE_PIXELS (40 MP) before decoding (brand_asset_source_too_large). That ceiling and the two-at-once limit bound what intake can allocate; a 36 MP source normalizes in roughly 50–150 ms.
  3. A PNG that needs nothing — no crop, fill or removal, upright, already within the slot’s bounds — is stored byte-for-byte (passthrough).
  4. Otherwise auto-orients (crop coordinates are in the EXIF-oriented pixels the browser displayed) and intersects the crop with the image (brand_asset_crop when nothing is left or the transform is malformed).
  5. Icon: the area must be square within 2 px (brand_icon_dimensions) and at least 1024 px (brand_icon_source_small, never upscaled), then resizes to 1024² and flattens onto background only if the owner picked one. Other slots: downscaled to fit 4096, never enlarged; then, only if the owner ticked Remove background, the flat corner colour is cleared (libs/shared/src/lib/brand-background.ts). The background is the colour the four corners share, decided on a BRAND_BACKGROUND_SAMPLE_MAX_SIDE (384 px) sample — the same sample size the crop dialog previews — and cleared on the full-resolution output in 1 M-pixel slices that yield the event loop between them. Every pixel near that colour becomes transparent wherever it sits (letter holes clear, and so does same-coloured detail inside the mark); edge pixels are un-mixed so no halo remains. Corners that are not one opaque colour are refused (brand_asset_background_uneven); corners already transparent leave the image unchanged. Canvas and libvips resample slightly differently, so a borderline background can be offered in the dialog and then refused at upload; the server’s answer stands. A framed logo (a thin border to the edge) averages the border into the corner colour and clears only partially — the preview shows it.
  6. The PNG is encoded; if it exceeds MAX_BRAND_ASSET_BYTES (photographic content at 4096² easily does) it is re-encoded at compression level 9, then downscaled up to three times (never below 1024 px on the longest side) before being refused as brand_asset_output_too_large.
  7. The PNG then goes through the unchanged validateBrandAsset (apps/api/src/organizations/branding-assets.ts), which stays the sole judge of a valid slot: brand_icon_opaque, brand_logo_transparent, brand_asset_empty, brand_asset_dimensions (under 64 px), brand_asset_size.

A file that sniffs as an accepted format but fails to decode is brand_asset_source_format with the decoder error kept as the rejection’s cause. Errors that are not about the file — TypeError, RangeError, libvips out-of-memory — are rethrown and answer 500, so they reach Sentry.

A brand-library reuse (POST /organizations/:orgId/brand-assets/:assetId/native/:slot) goes through the same BrandingService.storeAsset with no transform, so both paths apply one rule. Each stored slot is a new immutable key; a failed or repeated upload leaves at most an unreferenced object, never a changed draft. Draft previews (assetUrls, upload previewUrl) are presigned URLs of the owner’s own draft images, signed fresh (1 h) on every read rather than taken from the presign cache; if a thumbnail still fails to load, the editor re-reads the draft once for that slot and replaces only the preview. On save and publish every stored asset is re-checked: a key outside the org’s prefix is brand_asset_not_owned, a missing or wrong-typed object brand_asset_stored_invalid.

The editor pre-checks size, signature, decoded size and the icon’s 1024 minimum before opening the crop dialog (brand-asset-crop-dialog.tsx), so those answers arrive without a round trip. The dialog shows the icon crop with rounded corners and disables confirm below 1024 px; the background preview is recomputed once the crop has been still for 150 ms. The upload is aborted by Cancel or Escape (no error, a late response is ignored) and times out after 60 s (uploadTimedOut).

Observability, never including file content or filenames:

  • branding.asset_rejected on every refusal from every path, plus a structured BrandingService warn log ({ orgId, asset, source, code, format, width, height, cause }). Properties: asset (slot), source (upload, library, or stored for a saved draft asset re-checked on save/publish), code, and the source’s format/width/height (null when unreadable).
  • branding.asset_normalized once the PNG is stored in R2: asset, source, sourceFormat/sourceWidth/sourceHeight, outputWidth/outputHeight, bytes, cropped, filled, removeBackgroundRequested, backgroundAnalysis (flat/uneven/transparent/null), backgroundRemoved, passthrough, recompressed, downscaled, and durationMs (normalize + validate, excluding the wait for a slot).
  • Sentry span brand_asset.normalize (op: image.process) around normalize
    • validate, with brand_asset.slot/brand_asset.source, and brand_asset.bytes/width/height on success or brand_asset.code on an owner refusal (span status stays OK; only real faults mark it errored).
  • The crop dialog carries data-branding-scope, so its controls emit branding.control_clicked like the rest of the workspace.

Generation and release operations

Owner-safe branding contracts live in libs/shared/src/lib/branding.ts; release requests, approvals, snapshots, evidence, and executor events live in libs/shared/src/lib/branded-app-releases.ts. Localized owner copy lives in the shared mobileBranding, mobileRelease and mobileSubmission dictionaries. Until the shared exports are published, mobile consumes generated, verbatim source contracts:

TAIKAN_MOBILE_ROOT=/absolute/path/to/mobile pnpm brand:sync TAIKAN_MOBILE_ROOT=/absolute/path/to/mobile pnpm brand:sync --check TAIKAN_MOBILE_ROOT=/absolute/path/to/mobile \ TAIKAN_API_URL=https://api.example \ TAIKAN_API_TOKEN="$AUTHORIZED_TOKEN" pnpm brand:org <organization-id-or-slug> # Same variables; validates version and asset digests without regenerating: pnpm brand:org:check <organization-id-or-slug>

The generator is an administrator-only surface. It requires a published opaque 1024×1024 store icon and transparent logo and fetches published branding and current approved submission build input from an authenticated admin route. Owner requests never contain or return bundle IDs, package IDs, Apple/Google account identifiers, certificate fingerprints, EAS IDs, CI IDs, raw errors, or the runtime enabled control. Add the correct brand Firebase google-services.json; do not commit signing keys or store service-account credentials. Deploy association files before testing links on physical devices. Never use generated registries as runtime identity.

Build input returns { app, brand, submission, assets, assetDownloads }: brand.assets contains durable object keys, assets[name] contains { key, sha256 }, and the separate assetDownloads[name] is a short-lived signed HTTPS URL. The generator verifies source digests before deriving native images and stores durable source keys/digests, never download URLs. Executor input likewise leaves snapshot.assets unchanged and returns its signed URL map in assetDownloads outside the snapshot.

Every candidate is an immutable snapshot bound to a source commit, config revision, branding version, native version, EAS project, per-platform profile, asset digests, Firebase digest and approved submission intake/revision. A new publish/config/intake revision supersedes the old candidate. Per-platform state and append-only evidence are separate; owner approvals are purpose-specific and revision-bound. Internal-upload approval expires after 24 hours and is rechecked immediately before submit.

The protected mobile workflow accepts only operation_id and dispatch_nonce. GitHub OIDC proves the trusted repository, workflow, ref, event, audience, repository ID, and branded-app-internal environment before the API returns short-lived executor access and immutable inputs. Dispatch is a durable operation with lease/heartbeat, ordered idempotent callbacks, and manual reconciliation for ambiguous external effects. Build provenance binds the exact EAS build, candidate commit, application ID, and artifact digest before eas submit --id. Smoke and production-review jobs consume only the redacted receipt and have no Expo or store credentials.

For the ordinary dispatched workflow, the exact trusted workflow/ref is bound by GitHub’s workflow_ref claim, not reusable-workflow-only job_workflow_ref.

The owner tracker is business-language only: setup, per-platform progress, blockers, responsible party, and the next action. It does not expose technical configuration or raw provider errors. Store-review and public-release approvals never authorize an API call; an administrator performs those actions manually in the store console and records the observed result. The four owner business checks are scoped to each release target and artifact generation, with missing/passing/failing states and observation timestamps. Approval readiness uses the same prerequisite evaluation as the approval command. Technical checks remain operator-only; owners see which party must act without receiving credentials or technical configuration.

Publishing similar dedicated apps through Taikan’s existing Apple/Play accounts is a business and policy risk, including rejection and account-level enforcement. A tracker is not a compliance guarantee. Record client-owned accounts when that is the agreed publisher. Account enrollment, brand permission, app records, EAS signing/FCM credentials, store metadata/screenshots, real demo accounts, privacy declarations, and physical-device smoke testing remain operational prerequisites.

TKN-352 adds owner submission intake, decoded source-image validation, immutable published asset copies, hash-bound generated native assets/store listing/Play graphic, and real uploaded-build screenshot evidence. Screenshots are not generated from logos. Current approved per-platform intake, artifact-bound media and operator store disclosure evidence are mandatory for store-review/public-release readiness. Owner release/submission APIs expose only reviewed content and authenticated image previews, never provider configuration, raw errors or release artifact links. (Draft brand-asset previews in the editor are presigned URLs of the owner’s own uploads; see asset intake above.) Follow the branded app submission runbook for publisher account choices, real captures, demo access and manual submission.

The migration journal is authoritative. Existing main migrations through 0145_lumpy_xorn and the consolidated branding/release baseline 0146_wooden_jasper_sitwell remain unchanged. Additive migration 0147_branded_app_submission adds nullable owner submission JSON and a zero revision default to release requests. It does not rewrite existing release or brand snapshots/hashes. Legacy candidates without approved intake remain visible but cannot receive new submission/public-release authorization. No database application is implicit in these source changes.

Before deployment, an authorized operator must apply the complete reconciled journal. Keep member-app-release-tracker, member-app-internal-upload-assistance, and member-app-hermes-assistance disabled by default and enable them independently for selected organizations. organization_branded_apps.enabled remains an internal runtime access control; it is not release status and is never owner-editable. Existing native identity locking and app mappings remain authoritative.