Munchable has to look like itself in four places: the phone app, the same app served on the web, the vertical videos we cut for social, and the marketing site. Three of those now import one package. The fourth keeps its own copy of the colours, and deciding that was the actual work.
You can compare two of them in about ten seconds. munchable.app is the marketing site, and app.munchable.app is the product. Same cream, same cocoa, same three verdict colours.
The bug that created the package
The app's tokens lived in apps/mobile/src/theme/tokens.ts, which was correct and single sourced for the app. Then we started rendering social videos in React, and every one of them shows a phone running Munchable. Those compositions needed the palette, so they got a hand-copied mirror of the same file.
A copy drifts silently. That is the whole story. Changing the caution colour, or the wording of the "Can't assess" verdict, updated the app and left the videos showing the old one. No type error, no failing test, nothing red anywhere. A video is an advertisement, so a stale verdict label in one is a claim about what the product says, made in public, by us.
So the platform-free half moved into @munchable/design-tokens, a workspace package with one file in it. The app re-exports it, so app code still imports everything from one place and nothing had to be rewritten. The video package imports the same module. The reason the package exists is written at the top of it, because the next person to look at a 150 line file of constants will reasonably ask why it is not just in the app.
What is platform-free, and what is not
The package holds the palette, the spacing and radius scales, the type scale, and the verdict vocabulary. That last one is the interesting inclusion.
export const VERDICT_LABEL: Record<VerdictValue, string> = {
good: 'Good fit',
caution: 'Caution',
avoid: 'Avoid',
unknown: "Can't assess",
};
/** A distinct glyph per tier, verdict is never conveyed by colour alone. */
export const VERDICT_ICON: Record<VerdictValue, string> = {
good: 'check-circle',
caution: 'alert-triangle',
avoid: 'slash',
unknown: 'help-circle',
};
Those are design tokens in the sense that matters: they are the decisions a surface must not re-make locally. The icon map is there because a verdict is never allowed to be carried by colour alone, which is an accessibility requirement and not a styling preference. The label map is there because "Can't assess" is a phrase the product uses, and a second copy of it in a video template is exactly the drift we had.
The type scale is in the package as sizes only:
export const typeScale = {
hero: { fontSize: 44, lineHeight: 50 },
h1: { fontSize: 27, lineHeight: 33 },
body: { fontSize: 16, lineHeight: 23 },
label: { fontSize: 13, lineHeight: 17 },
// ...
} as const;
No font families. Which file carries a face is a platform question: Expo loads a bundled font by name, the browser loads a woff2, the video renderer loads whatever Remotion was given. So the families stay with each consumer, and the numbers, which are design decisions, are shared.
Motion curves, shadow definitions and anything typed against React Native stayed in the app file. A package that pulls react-native-reanimated into a browser bundle to hand over an easing curve has not reduced coupling, it has moved it.
Contrast ratios live next to the hex
Every colour in the palette carries its measured ratio in a comment:
text: '#3B2314', // cocoa, 13.9:1 on cream
textMuted: '#7F5F47', // warm brown, 5.5:1 on cream
link: '#9A5F1C', // deep caramel, deepened enough to pass AA on cream
accent: '#FF8FAB', // decoration only: fails contrast as text
The link colour is the one that earns its comment. It started as the brand caramel, which looks right and fails AA as body text on a cream background. The darker value is the brand colour moved until it passed, and without the comment the next person to "fix the inconsistency" would move it straight back.
The accent carries a warning rather than a ratio, because a token that is unusable for text should say so where somebody is choosing a colour, not in a design document they will not open.
Two decisions that look like constraints
Light mode only, on purpose. There is no dark palette and no plan for one. Munchable is a traffic light for food: three semantic colours that have to stay legible, distinguishable and consistent. A second palette doubles every contrast pairing and gives the same verdict two appearances, and the app is read in a shop aisle in daylight far more often than in bed.
The brand primary is deliberately not green. Buttons are cocoa brown. If the primary action colour were green, every button in the app would read faintly as "this is good for you", in an app whose entire job is to answer exactly that question with evidence. Reserving green for a verdict is cheaper than explaining later why a button is not an opinion.
The copy we kept
The marketing site does not import the package. It declares the same values as CSS custom properties in its own stylesheet:
:root {
--bg: #fff8ec;
--ink: #3b2314;
--primary: #5c3417;
--good: #15803d;
--caution: #b45309;
--avoid: #dc2626;
}
CSS cannot import TypeScript, so closing that gap means a generator step, a generated file in the repo and a build that fails in an unfamiliar way when the generator did not run. We priced that against what the copy can actually cost us, and the two risks are not the same kind of thing.
If the site's cream drifts a shade from the app's, a visitor sees a slightly different beige on a page they will not have open next to the app. If a video's caution colour or verdict wording drifts, an advert says something the product does not. One is cosmetic; the other is a false claim. So the shared package covers the surfaces that quote the product, and the site keeps six hex values and a comment explaining that they match the app on purpose.
The general version of this, which I keep relearning: deduplicate by blast radius, not by string equality. Two identical literals in two files are only a problem when disagreeing would mean something.
Related
Our videos are rendered by the product engine, which is the other half of why a copied palette was not acceptable, and the landing page is phones built in DOM rather than screenshots, which is why the site needs the colours at all.
Top comments (0)