DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our design tokens are a package, and the marketing site is deliberately not a consumer

Munchable has three surfaces that need to look like the app: the app itself, built with Expo; the marketing site, built with Next.js; and a set of social reels rendered with Remotion that show a phone running the app. When the reels were built, their theme file was a hand-copied mirror of the app's tokens, under a comment promising it matched.

That is the arrangement that drifts. The reels' new theme header says what happened:

They used to be hand-copied here under a comment promising they mirrored the app, which is exactly the arrangement that drifts: changing the caution colour or the "Can't assess" label in the app left the reels rendering the old one with no type error.

So the platform-free half of the tokens moved into a workspace package. The interesting decisions are what went in, what stayed out, and which surface was left alone.

What is in the package

A single light palette, because Munchable is light mode only and there is deliberately no dark one. Every colour with its contrast ratio in the comment beside it: cocoa text on cream at 13.9 to 1, the four verdict colours at 4.8 to 1 or better on white. Spacing and radius scales. And the verdict vocabulary, which is the part that most needed sharing:

export const VERDICT_LABEL = {
  good: 'Good fit',
  caution: 'Caution',
  avoid: 'Avoid',
  unknown: "Can't assess",
} as const;

/** A distinct glyph per tier, verdict is never conveyed by colour alone. */
export const VERDICT_ICON = {
  good: 'check-circle',
  caution: 'alert-triangle',
  avoid: 'slash',
  unknown: 'help-circle',
} as const;
Enter fullscreen mode Exit fullscreen mode

A reel that says "Can't assess" in the old wording is a marketing video contradicting the product. That is a worse bug than a slightly wrong shade of orange, and it is the one the copy-paste arrangement could not catch.

What is deliberately not in it

The type scale ships as sizes and line heights only, and the comment explains why the font families are missing:

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.

The app's theme file re-exports everything from the package and adds the three things that need React Native: motion curves from Reanimated, the card shadow, and the Expo font family names attached to the shared sizes. The reels' theme imports the same package and adds what only a video needs: CSS font stacks, pixel line heights, and the dark viewfinder card. Each consumer owns exactly the part that is about its platform.

// packages/reels/src/theme.ts
import { palette, radius, spacing, typeScale, VERDICT_ICON, VERDICT_LABEL, verdictColors } from '@munchable/design-tokens';

function css(variant: keyof typeof typeScale, fontFamily: string, fontWeight: number): CSSProperties {
  return { fontFamily, fontWeight, fontSize: typeScale[variant].fontSize, lineHeight: `${typeScale[variant].lineHeight}px` };
}
Enter fullscreen mode Exit fullscreen mode

The surface that was left alone

The marketing site does not import the package. Its stylesheet hand-maintains the same colours as CSS custom properties, and its radii are different on purpose: the site uses 12, 18 and 28 pixel corners where the app uses 8, 12 and 16. A landing page with hero sections and large cards wants rounder corners than a phone screen full of small components.

That is a real design difference, not drift, and it is the reason the site was not made a consumer. Pulling it in would have meant either forcing the app's radii on the site or adding a second radius scale to a package whose value is having one. The verdict labels do appear on the site, in the hero demo and the answer pages, and there they are typed by hand. That is the residual risk in this arrangement, stated rather than hidden: four short strings, on the surface a designer looks at most, and the next drift will be visible there before it is visible anywhere else.

What guards it

Nothing but the type system. There is no test comparing consumers, because the consumers are TypeScript imports of one module: rename a label and every reel and every app screen fails to compile. The only test added alongside the package guards something adjacent, the product data the reels score: every ingredient id typed into a reel's product must be one the rules engine knows, or the reel would render a verdict the app would never produce.

There is an honest loose end. An older JSON file in the docs folder still describes a palette that was abandoned before launch, under a note saying that when the JSON and the docs disagree, the JSON wins. Both are now wrong, and the package is the source of truth. Deleting a stale source of truth is on the list.

To compare the surfaces: the app is at app.munchable.app after you sign in, the site is munchable.app, and the verdict colours and wording on the ingredient answer pages are the same four the package defines. A sibling project made the same kind of move for its social cards, described in our link preview cards are drawn by code. That one shares three files; this one shares a package, because the third consumer is a video.

Top comments (0)