Munchable has three surfaces that all have to look like one product: the Expo app, the Next.js marketing and answers site, and a Remotion package that renders marketing overlays with the app's own screens inside them.

For a while the tokens lived in the app, as apps/mobile/src/theme/tokens.ts, which was correct when there was one consumer. Then the video package needed the same palette, and it got one the way everybody gets one: a hand-copied mirror.

The argument for a package is not DRY, it is that the failure is silent

A copied palette does not break. It drifts. Changing a colour in the app left the videos rendering the old one, and there was no type error anywhere to say so, because both files type-checked perfectly against themselves. The only detector was a person noticing that a video looked slightly wrong against a screenshot, weeks later, in a file nobody had touched.

So @munchable/design-tokens exists, and the test of what goes in it is a single question: can this value be read by something that is not React Native and not a browser?

/** * The type scale, as sizes only: which font FILE carries each face is a * platform question (Expo loads `Outfit_700Bold`, the browser loads a woff2), * so the family names stay with each consumer. */ export const typeScale = { hero: { fontSize: 44, lineHeight: 50 }, // ... } as const; 

Sizes are in the package. Font families are not. Spacing and radii are in, as plain numbers, because React Native wants numbers and CSS wants px, and the unit is the consumer's business. The motion curve is out, because it is a Reanimated Easing. The shadows are out, because shadowOffset and box-shadow are not the same idea. The package is 151 lines and imports nothing but two types.

The facade that kept the refactor from touching every file

The app did not change a single import. apps/mobile/src/theme/tokens.ts stayed exactly where it was, re-exporting the shared half and adding the React Native half:

export { palette, radius, spacing, typeScale, verdictColors, VERDICT_ICON, VERDICT_LABEL, TIER_ORDER, CONFIDENCE_LABEL, type Palette, type TypeVariant, } from '@munchable/design-tokens'; export const motion = { /* Reanimated easing, durations */ }; export const shadow = { /* RN shadow props */ }; export const fonts = { /* the families Expo loads */ }; 

Eighteen files across the app keep importing from that one local path, and the theme provider they feed is what everything else reads. The move was invisible to all of them, which is the only reason it was a small change rather than a diff nobody wants to review. Exactly two files in the whole repo name @munchable/design-tokens directly: the app's theme file and the video package's theme file.

Keying maps by a union from the shared engine

The colour and label maps are not objects with string keys. They are Records keyed by a union that comes out of the shared package the app and the site both use:

export const VERDICT_LABEL: Record<VerdictValue, string> = { /* one per member */ }; export const VERDICT_ICON: Record<VerdictValue, string> = { /* one per member */ }; 

This is the cheapest guarantee in the repo. Adding a member to that union does not produce a blank chip in the app and an undefined label in the video renderer. It produces two compile errors in two packages, naming both files, before anything renders. Same idea as the array length in a heading rather than a typed number: make the thing that must stay in step be the same object.

Two small decisions in that file I would keep in any design system:

  • The brand primary is deliberately not green, so a button can never be mistaken for a judgement about the food in your hand.
  • Every status has an icon as well as a colour, because the status is never conveyed by colour alone. That is a token, not a component choice, so it lives next to the palette where it cannot be forgotten.

And then there is the website

The site does not import the package. It has 3,144 lines of CSS and 28 custom properties in :root, 18 of which are hex values. So I measured the overlap:

python3 - <<'PY' import re css = open('apps/web/app/globals.css').read() tok = open('packages/design-tokens/src/index.ts').read() cssvars = re.findall(r'--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})', css) tokhex = {h.lower() for h in re.findall(r'#[0-9a-fA-F]{3,8}', tok)} shared = [n for n, v in cssvars if v.lower() in tokhex] print(len(cssvars), 'hex custom properties') print(len(shared), 'identical to a token value:', shared) print('site only:', [n for n, v in cssvars if v.lower() not in tokhex]) print('token only:', sorted(tokhex - {v.lower() for _, v in cssvars})) PY 

The answer: 15 of the 18 are byte-identical to a value in the token package, which nothing enforces. Three exist only in the stylesheet (a deeper shade of the primary, a second ink, a softer line), and two values in the package appear nowhere on the site, one of them being white.

I can defend the split. The site's variables are not a subset of the app's tokens: they include an rgba focus ring, two composite shadows, a max content width, and three font stacks whose first entry is a variable Next's font loader writes. Half of a stylesheet's tokens are things React Native has no concept of.

What I cannot defend is that 15 duplicated literals have no guard at all. The two surfaces that could never see each other, the app and the video renderer, are the two that share a package. The surface a person can compare against a screenshot in a browser is the one still holding its own copy. The fix is not a third copy or a CSS-in-JS rewrite: it is generating those custom properties from the package at build time, so the stylesheet keeps its own composite values and stops owning the palette.

Until that lands, the script above is in this post for a reason. A measurement you can rerun is worth more than an intention, and it took about four minutes to write.

Compare them yourself

The site and the app are supposed to be the same product. The homepage is the stylesheet in question, and a recipe page or a condition guide shows the same palette carrying ordinary long-form content. Then open the app from the homepage and look at the two together: cream, cocoa, one pink pop, and no dark mode anywhere, because there is deliberately no dark palette to go out of step.

If you are about to copy a palette into a second surface: do not. Put it in a package the day the second consumer appears, and the measurement of whether you did it in time is how many of the old literals are still lying around afterwards.