Two apps that had drifted apart
Hike Messenger ships on iOS and Android, in light and dark, with a web build alongside. Over time each platform had collected its own colours, type styles and radii. Designers fixed issues in Figma, engineers fixed them again in code, and the two copies kept drifting.
Two symptoms made it urgent: every screen went through too many iteration loops between design and engineering, and the same component kept coming out differently on iOS and Android. Both had the same root cause — no shared source of truth.
My role
I owned the system end to end: the audit, the token architecture and naming rules, the light and dark mapping, platform divergence decisions, the Figma tooling, code outputs and documentation. I partnered with the iOS and Android leads so every decision held up against how each platform actually builds UI.
What the audit found
I audited shipped screens across chat list, search, calls and settings, on both platforms and in both themes — 28 frames across 16 sections. The audit flagged 12 inconsistencies. A few examples:
neutral.grayLight.300held two values, #E6E6E6 and #E9E9E9 — a near-duplicate grey feeding both dividers and the search bar.- Android call-list rows rendered in SF Pro, an iOS font — most likely a component reused across platforms by mistake.
- The settings footer card used a 22px radius, drifting from the 20px
radius.xlon every other settings card. color.text.disabledmatched tertiary text in light and secondary text in dark, so disabled states couldn’t be told apart.- Disclosure chevrons kept #9C9C9C in both themes instead of adapting.
- The Android search bar changed its horizontal padding with the theme: 12dp in light, 16dp in dark.
- A swipe-to-delete label was set at 11.586pt, an off-scale value.
Three tiers, one rule
Every token sits in one of three tiers, and each tier only references the one below it. Changing theme swaps semantic values. Changing platform swaps a small, documented set of component values. Nothing else moves.
- PrimitiveRaw values that never change with theme, e.g.
vividCerulean.500 = #00A8F3. - SemanticNamed by purpose and switched by theme, e.g.
color.text.primary. - ComponentBound to semantic tokens and scoped to one component, e.g.
component.chatListItem.paddingV.
grayLight.900color.text.primarycomponent.chatListItem.nameChanging theme swaps the semantic value. The component never changes — it only points at the semantic token.
Semantic colour, light and dark
| Token | Light | Dark | Used for |
|---|---|---|---|
color.background.primary | #F6F6F6 | #0A0A0A | Screen root background |
color.background.secondary | #FFFFFF | #1A1A1C | Cards and sheets |
color.surface.search | #E9E9E9 | #1A1A1C | Search bar fill |
color.border.default | #E6E6E6 | #2B2B2B | Row dividers, list hairlines |
color.text.primary | #111111 | #F2F2F2 | Names, headings |
color.brand.primary | #00A8F3 | #00A8F3 | FAB, toggle on |
color.brand.accent | #5BCBFC | #5BCBFC | Unread badge background |
color.feedback.error | #F04438 | #F04438 | Notification badge, errors |
Dark mode uses grayDark.800 as the root and lifts cards with grayDark.700, instead of drawing shadows on dark surfaces.
Same system, two native platforms
A shared system doesn’t mean identical screens. iOS should feel like iOS and Android like Material. I documented every place the platforms intentionally differ, so a difference is a decision rather than drift. Colour roles, spacing, avatar and badge sizes and row structure stay shared.
| Decision | iOS | Android |
|---|---|---|
| Font family | SF Pro (system) | Roboto (system) |
| Bottom nav | Floating frosted-glass pill, radius 48 | Full-width flat bar, Material surface |
| Active tab | White pill at 8% opacity | Cyan tint pill, rgba(0,168,243,0.16) |
| Toggle | UISwitch 64×28 (HIG) | Material Switch 49×30dp (M3) |
| Backdrop blur | Yes, on the nav pill | No, flat surface colour |
Spacing sits on a 4px base. Two values, 14 and 30, sit off-grid on purpose — the chat row padding and settings group gap needed them — so they’re documented exceptions, not drift.
From Figma to code
- Audit at scaleRead the live Figma file through Figma MCP to pull variables and design context across every section.
- Name and structureWrote the three tiers and naming rules: one dot-separated name on every platform, values resolved at build time.
- One source fileTokens in W3C DTCG format; Style Dictionary generates CSS variables, Kotlin for Compose, Swift for SwiftUI and Markdown docs.
- Back into FigmaA plugin reads the token file and creates Figma Variables across 10 collections, from Primitives to Components.
- Migration mapEvery current token mapped to its replacement — matched, renamed, new or inconsistent — so teams migrate screen by screen.
- GuidelinesPublished Design Token Guidelines in Figma: naming, colour, type, spacing, radius, elevation and platform divergence.
Decisions and trade-offs
One name everywhere, values per platform
Separate names would have made migration easier. A shared name costs more up front, but removes a translation step from every handoff for good.
Divergence is documented, not tolerated
The iOS pill nav and the Material bar are both correct. Writing every difference down is what makes everything else non-negotiable.
Native type, shared roles
SF Pro and Roboto map to the same roles. Forcing one font would look consistent in Figma and feel foreign on the phone.
Tinting, not shadows, in dark mode
Shadows disappear on near-black, so elevation comes from a lighter surface step.
Where AI helped
I used Figma MCP with Claude to read the live file across all 28 frames, extract variables, and draft the code outputs and the plugin. The architecture, naming rules, divergence calls and every token decision were mine, and I reviewed each output against platform conventions. When the tooling failed silently on generating the mapping as a Figma page, I switched to a spreadsheet — part of the craft is knowing when the tool is wrong for the job.
What I learned
A token system is a naming agreement first and a colour palette second. The hardest conversations were about what a name promises, which differences are allowed, and who gets to change them.