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 READMEpnpm 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 existsGenerated 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 fromsize, 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
#B0E5C4is 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
| Role | Light | Dark | Web token | Mobile |
|---|---|---|---|---|
| Brand teal (graphics, never behind text) | #0E8C8C | #2AB8B8 | --brand-teal | brandTeal |
| Primary action fill | #0B7575 | #2AB8B8 | --primary | primary |
| Ink on primary | white | #0A1628 | --primary-foreground | onPrimary |
| Violet — secondary action | #7A4BE0 | #9B6BFF | --violet | violet |
| Ink on violet | white | #0A1628 | --violet-foreground | onViolet |
| Violet as text | #6C43D8 | #A987FF | --violet-text | violetText |
| Mint (PR / streak / today) | #2E7A4D text | #B0E5C4 | --brand-mint, success | success |
| Ground | #F6F8FA | #0A1628 (web) · #07202B (mobile, admin, bands) | --background | background |
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 isviolet-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/violetEdgetokens. 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
| Surface | Config |
|---|---|
| Web favicons, PWA icons, OG | apps/web/src/app/[lang]/layout.tsx (generateMetadata), public/manifest.webmanifest, browserconfig.xml |
| Web auth pages | components/brand/auth-frame.tsx; Clerk themed via appearance={{ baseTheme: shadcn }} on ClerkProvider |
| Marketing | components/Head.astro, Navbar.astro, Footer.astro (inline TaikanMark.astro) |
| Admin | index.html, src/theme.ts, Refine ThemedTitle icon= |
| Docs | apps/docs/app/layout.jsx |
| Minisites | /favicon.ico fallback only — sites are white-labelled per org, never a Taikan favicon.svg |
apps/api/src/notifications/templates/theme.ts; letterhead mark is the flat 64px raster | |
| Mobile | app.config.ts (icon, ios.icon.{light,dark,tinted}, android.adaptiveIcon + monochromeImage, expo-notifications.icon, expo-splash-screen) |
| Store listings | store-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):
| Channel | pending when |
|---|---|
| Brand | a draft exists and its palette identity (colours case-insensitive, name, logos) differs from the published one, or nothing is published |
| Website | adopted (schema 2) and never published, or publishedContentRevision !== draftRevision |
| Member app | a 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_publishedwithappPropagatedbranding.brand_restoredbranding.website_publishedandbranding.website_unpublishedbranding.app_publishedandbranding.app_unpublishedbranding.asset_normalizedafter a stored upload; asset intake is the one place a refusal is also captured, asbranding.asset_rejected(see asset intake below)- Refused writes already surface as
api.request_failedwith the APIcode.
- Web:
branding.tab_selected- the publish funnel:
branding.publish_opened, with the entry point and every channel’s statebranding.publish_channel_toggledbranding.publish_confirmedorbranding.publish_cancelled- one
branding.publish_channel_resultper channel:verified,unverified,failedwithcode, orskipped, plusdurationMs branding.publish_completed
branding.control_clickedfor every button, link, tab, checkbox and file picker inside[data-branding-scope], keyed bydata-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):
- Sniffs the leading bytes — only PNG, JPEG and WebP ever reach a decoder
(
brand_asset_source_format; SVG, GIF, HEIC and animated files included). - 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. - A PNG that needs nothing — no crop, fill or removal, upright, already
within the slot’s bounds — is stored byte-for-byte (
passthrough). - Otherwise auto-orients (crop coordinates are in the EXIF-oriented pixels
the browser displayed) and intersects the crop with the image
(
brand_asset_cropwhen nothing is left or the transform is malformed). - 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 ontobackgroundonly 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 aBRAND_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. - 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 asbrand_asset_output_too_large. - 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_rejectedon every refusal from every path, plus a structuredBrandingServicewarn log ({ orgId, asset, source, code, format, width, height, cause }). Properties:asset(slot),source(upload,library, orstoredfor a saved draft asset re-checked on save/publish),code, and the source’sformat/width/height(nullwhen unreadable).branding.asset_normalizedonce 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, anddurationMs(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, andbrand_asset.bytes/width/heighton success orbrand_asset.codeon an owner refusal (span status stays OK; only real faults mark it errored).
- validate, with
- The crop dialog carries
data-branding-scope, so its controls emitbranding.control_clickedlike 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.