The migration won't tell you which values it dropped

Press · No. 085

Shipped

press 0.2.0 added what natejswenson.io needed to adopt the shared brand without changing by a single pixel. Surveying the site first turned up four values the token set could not express: three alpha tints it had written as literal rgba() strings, and a monospace stack one fallback deeper than the canonical one. Adopting press at that moment would have meant either losing them or hand-writing them locally, which is the duplication the whole thing exists to end. All 19 of the site’s generated custom properties came out byte-identical to what it already shipped.

The survey is the transferable part. Any time you move a product onto shared configuration, whether that’s design tokens, a lint config, or a base Docker image, there’s a set of values the product currently has and a set the shared thing can produce, and the interesting work is in the gap between them. This guide builds a tool that finds that gap before you migrate rather than after.

Read the consumer before you touch it

The step people skip is the first one. You cannot know what a migration would drop without reading what’s there, so start by extracting the consumer’s real declarations rather than trusting your memory of them.

Everything below is Node’s standard library, so an empty directory and Node 18 or newer is the whole setup. Start with the token set as it existed before the survey:

{
  "colors": {
    "paper": "#F5F0E6",
    "ink": "#181510",
    "accent": "#E8501F"
  },
  "derived": {},
  "fonts": {
    "mono_stack": "ui-monospace, 'SF Mono', Menlo, monospace"
  }
}

Then declare what the consumer is expected to receive. This mapping is also where a consumer’s own naming lives, since a product will already have its own names for these values and renaming everything on adoption day is how a migration stalls.

{
  "id": "site",
  "path": "site/theme.css",
  "vars": [
    { "token": "paper", "name": "bg" },
    { "token": "ink", "name": "fg" },
    { "token": "accent", "name": "accent" },
    { "token": "border", "name": "border" },
    { "token": "border_hover", "name": "border-hover" },
    { "token": "accent_dim", "name": "accent-dim" },
    { "token": "mono_stack", "name": "font-mono" }
  ]
}

Note that four of those tokens do not exist in the file above. That is deliberate: the target declares what the consumer needs, and the survey’s job is to tell you the system can’t supply it yet.

Derive the tints, don’t copy them

Three of the site’s values were alpha tints of colors the token set already had. A tint written out as a literal is a second place the brand’s ink color lives, and nothing tells you when the two stop agreeing. So declare it as a computation instead. Save this as resolve.mjs:

export function hexToRgb(hex) {
  const m = /^#([0-9a-f]{6})$/i.exec(hex);
  if (!m) throw new Error(`not a 6-digit hex color: ${hex}`);
  const n = parseInt(m[1], 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

// A derived token is declared as {from, alpha} and COMPUTED, never written out.
// This is the difference between one source of truth and two: an rgba() typed
// by hand into a consumer is a second place the brand's ink color lives, and
// nothing will tell you when the two stop agreeing.
export function resolveTokens(raw) {
  const derived = {};
  for (const [name, spec] of Object.entries(raw.derived ?? {})) {
    const base = raw.colors[spec.from];
    if (!base) {
      throw new Error(`derived token "${name}" references unknown color "${spec.from}"`);
    }
    const [r, g, b] = hexToRgb(base);
    derived[name] = `rgba(${r}, ${g}, ${b}, ${spec.alpha})`;
  }
  return { ...raw.colors, ...derived, ...raw.fonts };
}

This is the aliasing idea every token toolchain lands on. Style Dictionary spells references with curly-brace token paths so a value is written once and referenced everywhere else, and the same instinct now exists in CSS itself: Color Module Level 5’s relative color syntax lets you write rgb(from var(--ink) r g b / 0.16) and skip the precompute entirely. Precomputing still buys you one thing relative color doesn’t, which is that the emitted value is a plain literal every consumer can read, including the ones that aren’t a browser.

Naming a color that doesn’t exist fails immediately rather than emitting something plausible:

derived token "border" references unknown color "inkk"

Build the survey

Now the tool. For each declared variable it answers one of five things, and three of those five block the migration. Save it as survey.mjs:

#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolveTokens } from './resolve.mjs';

// Pull every custom property the consumer already declares. This is the half
// people skip: you cannot know what a migration would drop without reading
// what is there first.
export function readCustomProps(css) {
  const out = new Map();
  for (const m of css.matchAll(/^\s*--([\w-]+)\s*:\s*([^;]+);/gm)) {
    out.set(m[1], m[2].trim().replace(/\s+/g, ' '));
  }
  return out;
}

const faces = (stack) => stack.split(',').map((f) => f.trim());

// Is `inner` an in-order subsequence of `outer`? A font stack is a prioritized
// list, so a shorter list that appears in order inside a longer one is the
// same intent with fewer escape hatches, not a different one.
function isSubsequence(inner, outer) {
  let i = 0;
  for (const f of outer) if (i < inner.length && inner[i] === f) i += 1;
  return i === inner.length;
}

export function surveyTarget(target, tokens, css) {
  const existing = readCustomProps(css);
  const rows = [];

  for (const v of target.vars) {
    const have = existing.get(v.name);
    const want = tokens[v.token];

    if (want === undefined) {
      // The consumer ships a value the token set has no name for. Migrating
      // now means hand-writing it locally, which is the duplication the token
      // set exists to prevent.
      rows.push({ name: v.name, verdict: 'unexpressible', have });
      continue;
    }
    if (have === undefined) { rows.push({ name: v.name, verdict: 'new', want }); continue; }
    if (have === want) { rows.push({ name: v.name, verdict: 'match' }); continue; }

    const [a, b] = [faces(have), faces(want)];
    if (a.length > b.length && isSubsequence(b, a)) {
      rows.push({
        name: v.name, verdict: 'would-drop-fallbacks',
        lost: a.filter((f) => !b.includes(f)),
      });
      continue;
    }
    rows.push({ name: v.name, verdict: 'differs', have, want });
  }
  return rows;
}

const LABEL = {
  match: 'ok      ',
  new: 'NEW     ',
  differs: 'DIFFERS ',
  'would-drop-fallbacks': 'DROPS   ',
  unexpressible: 'NO TOKEN',
};

if (import.meta.url === `file://${process.argv[1]}`) {
  const tokens = resolveTokens(JSON.parse(readFileSync('tokens.json', 'utf8')));
  const target = JSON.parse(readFileSync('targets.json', 'utf8'));
  const rows = surveyTarget(target, tokens, readFileSync(target.path, 'utf8'));

  for (const r of rows) {
    let detail = '';
    if (r.verdict === 'unexpressible') detail = `consumer has ${r.have}`;
    if (r.verdict === 'would-drop-fallbacks') detail = `would lose ${r.lost.join(', ')}`;
    if (r.verdict === 'differs') detail = `${r.have}  ->  ${r.want}`;
    console.log(`${LABEL[r.verdict]}  --${r.name}${detail ? '  ' + detail : ''}`);
  }
  const blockers = rows.filter((r) => r.verdict !== 'match' && r.verdict !== 'new');
  console.log(`\n${rows.length} declared, ${blockers.length} blocking adoption`);
  process.exit(blockers.length ? 1 : 0);
}

The font-stack branch is the one worth reading twice. A font-family value is a prioritized list, and per CSS Fonts Module Level 4, “A user agent iterates through the list of family names until it matches an available font that contains a glyph for the character to be rendered.” So a shorter list that appears in order inside a longer one isn’t a different intent, it’s the same intent with fewer escape hatches. Reporting that as a plain difference invites you to overwrite it, which is exactly the wrong move.

Run it

Give the consumer the values it actually ships today, then survey it:

mkdir -p site
cat > site/theme.css <<'EOF'
:root {
  --bg: #F5F0E6;
  --fg: #181510;
  --accent: #E8501F;
  --border: rgba(24, 21, 16, 0.16);
  --border-hover: rgba(24, 21, 16, 0.38);
  --accent-dim: rgba(232, 80, 31, 0.12);
  --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
}
EOF

node survey.mjs; echo "exit: $?"
ok        --bg
ok        --fg
ok        --accent
NO TOKEN  --border  consumer has rgba(24, 21, 16, 0.16)
NO TOKEN  --border-hover  consumer has rgba(24, 21, 16, 0.38)
NO TOKEN  --accent-dim  consumer has rgba(232, 80, 31, 0.12)
DROPS     --font-mono  would lose 'JetBrains Mono'

7 declared, 4 blocking adoption
exit: 1

Four blockers, which is the same count press hit surveying the real site. Three of them are values with nowhere to live, and one is a face that would have quietly vanished from a fallback chain. None of these would have shown up in review: the alpha tints render identically until someone changes ink, and the missing mono fallback only matters on a machine where the faces ahead of it are absent.

Now close the gaps. Declare the tints as computations, and widen the stack to the union rather than overwriting it:

{
  "colors": {
    "paper": "#F5F0E6",
    "ink": "#181510",
    "accent": "#E8501F"
  },
  "derived": {
    "border": { "from": "ink", "alpha": 0.16 },
    "border_hover": { "from": "ink", "alpha": 0.38 },
    "accent_dim": { "from": "accent", "alpha": 0.12 }
  },
  "fonts": {
    "mono_stack": "ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace"
  }
}
node survey.mjs; echo "exit: $?"
ok        --bg
ok        --fg
ok        --accent
ok        --border
ok        --border-hover
ok        --accent-dim
ok        --font-mono

7 declared, 0 blocking adoption
exit: 0

Zero blockers, and the three tints the system now computes came out as the same strings the consumer had typed by hand. That equality is the actual result: it means adoption is a no-op, and you know it before you’ve changed the consumer at all rather than by staring at a rendered page afterward.

Gotchas

A deeper fallback chain is not a difference to fix. When the consumer’s stack and yours disagree, the reflex is to emit yours and move on. Symptom: a face disappears from the chain, and nothing looks wrong to you, because the faces ahead of it resolve on your machine and you never reach the part you deleted. It surfaces later on somebody else’s box, or in a rendering engine that walks the chain for real. The escape is the subsequence check above: if your list appears in order inside theirs, take the union, because fallbacks are additive and the deepest chain is the safe one. press made this call twice, first when the résumé’s print-tuned stacks became canonical in 0.1.0, then again here for 'JetBrains Mono'.

Two values that are “obviously the same” can be two shipped products. The survey will hand you near-duplicates and invite you to reconcile them. press found hair at alpha 0.18 and border at 0.16, both meaning “a faint ink line”, differing only because one was tuned on a résumé and the other on a website. Symptom: you merge them, and one of two shipped products changes appearance as a side effect of a refactor that was supposed to change nothing, which is also the hardest kind of regression to attribute later. The escape is to keep both, named separately, with a comment recording that the difference is real and known. Reconciling them is a design decision worth making on purpose, and separately.

An unexpressible value becomes a local hand-write, and that is worse than before. The tempting compromise when the token set can’t say something is to adopt it anyway and leave that one line local “for now”. Symptom: you end up with two sources of truth where you had one, and the local copy now looks sanctioned because it sits inside a file that otherwise came from the shared system, so the next person to read it has no way to tell which lines are authoritative. The escape is the exit code: make the survey fail until the token set can express everything the consumer ships, so “we’ll fix it after the migration” isn’t reachable.

Sources

Changelog

  • feat(press): alpha tints + mono union so the site can adopt at 1:1 (0.2.0) (#117) (6e22f2c)