Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/token-export-round-trip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'onsystem': patch
---

`onsystem export --format design-md` writes Tailwind's spacing unit as the steps of the scale (`"1": 0.25rem`, `"2": 0.5rem` up to `"96": 24rem`) instead of `base: 0.25rem`. DESIGN.md has no multiplier, so a reader, onsystem included, took 4px as the only step: read back in a project with no stylesheet, `padding: "16px"` got the advice "Use `p-[4px]`", and `gap-[8px]` the nearest step `gap-[4px]`. Now 8px and 16px are on the scale, and `p-[13px]` is nearest `p-[12px]`. Export again to rewrite a DESIGN.md written by an earlier version.

A typography alias in the DESIGN.md export, such as shadcn/ui's `--font-heading: var(--font-sans)`, is written as the level it names (`fontFamily: ...`) instead of `"{typography.font-sans}"`. A reference in the front matter names one value, not a level: onsystem warned "unresolved reference {typography.font-sans}" when it read the file, and DESIGN.md's linter reported one unknown typography property per character of the reference.

A DTCG file exported into the project with `onsystem export --format dtcg --out design.tokens.json`, which zero config reads back next to the stylesheet, no longer adds tokens. Its `@theme inline` tokens (`rounded-sm`, `font-sans`) have no custom property to merge by, so they now merge with the stylesheet's by name and Tailwind class. In a stock shadcn/ui app, `inspect` listed 50 tokens instead of 42, and a later DESIGN.md export listed the radii twice and warned that they were left out of the front matter.
8 changes: 5 additions & 3 deletions docs/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ DTCG files are read as the [Format Module 2025.10](https://www.designtokens.org/
- `$root` tokens: the token a group has for itself. `color.accent.$root` is referenced as `{color.accent.$root}` and its custom property is `--color-accent`,
- groups that extend another: `"$extends": "{button}"` (or JSON Schema's `"$ref": "#/button"`) gives the group every token and property of `button`, and its own tokens replace the ones at the same path. Circular extension, a reference that resolves to nothing and `$extends` that names a token are reported by `inspect` and left out.

A token is named by its path (`color.brand.500`), unless onsystem's extension keeps another name for it, and its custom property is the path with dashes (`--color-brand-500`, or `--acme-color-brand-500` with `"prefix": "acme"`). Values for other themes can sit under `$extensions.modes` (`{ "dark": "#000" }`). A DTCG file and the CSS generated from it are merged by custom property.
A token is named by its path (`color.brand.500`), unless onsystem's extension keeps another name for it, and its custom property is the path with dashes (`--color-brand-500`, or `--acme-color-brand-500` with `"prefix": "acme"`). Values for other themes can sit under `$extensions.modes` (`{ "dark": "#000" }`). A DTCG file and the CSS generated from it are merged by custom property, and a token that has none (a Tailwind `@theme inline` value such as `--radius-sm`) by its name and Tailwind class.

### onsystem's extension

Expand Down Expand Up @@ -85,11 +85,13 @@ One group per category (`color`, `spacing`, `radius`, `typography`, `shadow`, `o
- An alias stays a reference: `--card: var(--background)` is `"{color.background}"`.
- What DTCG has no type for (`color-mix()`, a `var()` no token defines, lengths in `em` or `%`) is written as the CSS string, without `$type`.

Read back as a token source, the file gives the same tokens with the same names: the shadcn demo's `check` reports the same findings from its exported file as from `globals.css`, and names `destructive`, not `color.destructive`. The test suite runs this round trip.
Read back as a token source, the file gives the same tokens with the same names: the shadcn demo's `check` reports the same findings from its exported file as from `globals.css`, and names `destructive`, not `color.destructive`. Zero config reads a `*.tokens.json` anywhere in the project, so a file exported into it is read next to the stylesheet it came from: the two merge, and `inspect` lists as many tokens as before. The test suite runs both.

### DESIGN.md

Front matter in the DESIGN.md schema (`version`, `name`, `colors`, `typography`, `rounded`, `spacing`), then a section per category in the format's order (Colors, Typography, Layout, Elevation & Depth, Shapes, and Other tokens), each with one sentence and a table of the tokens: their value, a column per mode, the class and custom property code uses, and the description. Values the schema cannot hold, such as a `var()` font family, are left out of the front matter and kept in the table. Aliases stay references (`"{colors.background}"`).
Front matter in the DESIGN.md schema (`version`, `name`, `colors`, `typography`, `rounded`, `spacing`), then a section per category in the format's order (Colors, Typography, Layout, Elevation & Depth, Shapes, and Other tokens), each with one sentence and a table of the tokens: their value, a column per mode, the class and custom property code uses, and the description. Values the schema cannot hold, such as a `var()` font family, are left out of the front matter and kept in the table. Aliases stay references (`"{colors.background}"`), except in typography: a reference names one value, and a typography level holds several, so an alias is written as the level it names.

Tailwind's spacing unit is written as the steps of Tailwind v3's default scale, which v4 still makes: from `"0.5": 0.125rem` to `"96": 24rem`. DESIGN.md has no multiplier: a reader takes each spacing entry as a step, so the unit alone (`base: 0.25rem`) would make 4px the only one. Read back as a token source, the file gives the same colors and radii, and those spacing steps: v4's other steps, such as `p-4.5` or `p-13`, are off the scale for a project that reads only the DESIGN.md. A DESIGN.md token names no class, so `check` leaves a length on the scale alone and snaps one off it to the nearest step: `p-[13px]` becomes `p-[12px]`. The test suite runs this round trip.

## Status colors

Expand Down
53 changes: 48 additions & 5 deletions src/tokens/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,22 +502,31 @@ export function toDesignMd(tokens: readonly Token[], options: { name: string }):
const lookup = varLookup(tokens);
const keys = designKeys(tokens);
const front: Record<string, unknown> = { version: 'alpha', name: options.name };
const sections: Record<string, Record<string, unknown>> = {};
const sections: Record<string, Map<string, unknown>> = {};
const body: string[] = [`# ${options.name}`];
const byName = new Map(tokens.map((t) => [t.name, t]));

for (const section of SECTIONS) {
const list = tokens.filter((t) => t.category === section.category);
if (!list.length) continue;
if (section.key) {
const values: Record<string, unknown> = {};
// A Map keeps the keys in order: an object puts `"1"` before `"0.5"`.
const values = new Map<string, unknown>();
let steps: Map<string, string> | undefined;
for (const token of list) {
if (isSpacingUnit(token)) {
steps = spacingSteps(token, lookup);
continue;
}
const key = keys.get(token);
if (key === undefined) continue;
// An alias stays a reference to a token of the same section that the front matter has.
// A typography level is no single value, which a reference must name: it is written out.
const target = token.aliasOf ? byName.get(token.aliasOf) : undefined;
const targetKey =
section.key !== 'typography' &&
target?.category === token.category &&
!isSpacingUnit(target) &&
frontMatterValue(target, section.key, lookup) !== undefined
? keys.get(target)
: undefined;
Expand All @@ -526,13 +535,20 @@ export function toDesignMd(tokens: readonly Token[], options: { name: string }):
? frontMatterValue(token, section.key, lookup)
: `{${section.key}.${targetKey}}`;
if (value === undefined) continue;
if (key in values) {
if (values.has(key)) {
warnings.push(`${token.name} is left out of the front matter: another token has its key`);
continue;
}
values[key] = value;
values.set(key, value);
}
if (Object.keys(values).length) sections[section.key] = values;
// A step the project sets itself (`--spacing-4`) keeps its own value.
for (const [key, value] of steps ?? []) if (!values.has(key)) values.set(key, value);
// In scale order, with the steps the project sets among the others.
const step = (key: string) => (Number.isFinite(Number(key)) ? Number(key) : Infinity);
const ordered = steps
? new Map([...values].sort(([a], [b]) => step(a) - step(b) || 0))
: values;
if (ordered.size) sections[section.key] = ordered;
}
body.push('', `## ${section.heading}`, '', section.intro, '', ...table(list, keys));
}
Expand All @@ -551,6 +567,33 @@ function designKeys(tokens: readonly Token[]): Map<Token, string> {
return out;
}

/** Tailwind's spacing unit, `--spacing`, which `p-4` takes 4 times. */
function isSpacingUnit(token: Token): boolean {
return token.category === 'spacing' && token.tailwind === '';
}

/** The steps of Tailwind's default spacing scale, `p-0.5` to `p-96`, in spacing units. */
const SPACING_STEPS = [
0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48,
52, 56, 60, 64, 72, 80, 96,
];

/**
* Tailwind's spacing unit as the steps it makes: `"4": 1rem` for `p-4`. A DESIGN.md has no
* multiplier: a reader takes each spacing entry as a step, so the unit alone (`base: 0.25rem`)
* would make 4px the only one.
*/
function spacingSteps(token: Token, lookup: Lookup): Map<string, string> {
const plain = PLAIN_LENGTH.exec(token.value.trim());
const [amount, unit] = plain
? [Number(plain[1]), plain[2] ?? 'px']
: [evaluateLength(token.value, lookup), 'px'];
const steps = new Map<string, string>();
if (amount === undefined || !Number.isFinite(amount) || amount <= 0) return steps;
for (const n of SPACING_STEPS) steps.set(String(n), `${round(n * amount)}${unit}`);
return steps;
}

function frontMatterValue(
token: Token,
key: (typeof FRONT_MATTER_ORDER)[number],
Expand Down
18 changes: 14 additions & 4 deletions src/tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,27 +144,37 @@ function beforeV4(range: unknown): boolean {
/**
* DTCG files and the CSS generated from them often describe the same tokens.
* Merge entries that share a custom property: keep the DTCG name and
* description, and add the Tailwind mapping from CSS.
* description, and add the Tailwind mapping from CSS. A token without one (a
* `@theme inline` value, `rounded-sm`) merges with the token of its name and
* Tailwind key, such as the one a DTCG file exported from the stylesheet has.
*/
export function mergeTokens(tokens: Token[]): Token[] {
// Tailwind's built-in defaults (`--spacing`) only stand in until the project
// sets its own value, in whichever file that is.
const overridden = new Set(
tokens.filter((t) => t.origin !== 'tailwind-default').map((t) => themeKey(t)),
);
const byVar = new Map<string, Token>();
const byKey = new Map<string, Token>();
const out: Token[] = [];
for (const token of tokens) {
if (token.origin === 'tailwind-default' && overridden.has(themeKey(token))) continue;
const existing = token.cssVar ? byVar.get(token.cssVar) : undefined;
// Not by category: an exported `-0.025em` has no DTCG type, and reads back as `other`.
const key =
token.cssVar ??
(token.tailwind === undefined ? undefined : `${token.name}:${token.tailwind}`);
const existing = key === undefined ? undefined : byKey.get(key);
if (!existing) {
if (token.cssVar) byVar.set(token.cssVar, token);
if (key !== undefined) byKey.set(key, token);
out.push(token);
continue;
}
if (existing.tailwind === undefined && token.tailwind !== undefined) {
existing.tailwind = token.tailwind;
}
// A DTCG file listed before the stylesheet: the stylesheet knows `-0.025em` is letter spacing.
if (existing.category === 'other' && token.category !== 'other') {
existing.category = token.category;
}
existing.usage = [...new Set([...token.usage, ...existing.usage])];
existing.description ??= token.description;
if (token.modes) existing.modes = { ...token.modes, ...existing.modes };
Expand Down
124 changes: 123 additions & 1 deletion test/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,10 @@ describe('export --format design-md', () => {
'version: alpha\nname: shadcn-demo\ncolors:\n background: oklch(1 0 0)\n',
);
expect(yaml).toContain('rounded:\n sm: 6px\n md: 8px\n lg: 0.625rem\n');
expect(yaml).toContain('spacing:\n base: 0.25rem\n');
// DESIGN.md has no multiplier: Tailwind's spacing unit is written as the steps it makes.
expect(yaml).toContain('spacing:\n "0.5": 0.125rem\n "1": 0.25rem\n "1.5": 0.375rem\n');
expect(yaml).toContain(' "4": 1rem\n');
expect(yaml).not.toContain('base:');
// `var(--font-geist-sans)` is no font name: the table has it, the front matter does not.
expect(yaml).not.toContain('typography');
expect(body.match(/^## .*/gm)).toEqual([
Expand Down Expand Up @@ -344,6 +347,125 @@ describe('export --format design-md', () => {
});
});

describe('export, then read back', () => {
const stock = fs.readFileSync(
path.join(import.meta.dirname, 'fixtures/shadcn-neutral/globals.css'),
'utf8',
);
const page = `export default function Page() {
return (
<div className="gap-[8px] p-[13px]" style={{ padding: "16px" }}>
Hello
</div>
);
}
`;
const findings = async (cwd: string) => {
const { stdout } = await run(['check', 'app', '--format', 'json', '--no-cache'], cwd);
return (JSON.parse(stdout) as CheckResult[]).flatMap((r) => r.diagnostics);
};

it('keeps the spacing scale in a DESIGN.md, which another project then reads', async () => {
const app = fixture({
'package.json': '{"name":"app"}',
'app/globals.css': stock,
'app/page.tsx': page,
});
const file = path.join(app, 'DESIGN.md');
expect(
await run(['export', '--format', 'design-md', '--out', file, '--no-cache'], app),
).toMatchObject({ code: 0, stderr: '' });
const md = fs.readFileSync(file, 'utf8');
expect(md).toContain('spacing:\n "0.5": 0.125rem\n "1": 0.25rem\n');
expect(md).toContain(' "96": 24rem\n---\n');

// A project with only the DESIGN.md to read tokens from.
const other = fixture({
'package.json': '{"name":"other"}',
'DESIGN.md': md,
'app/page.tsx': page,
});
expect((await findings(app)).map((d) => d.suggestion)).toEqual(['gap-2', 'p-3', 'p-4']);
// 8px and 16px are steps of the scale, which a DESIGN.md token names no class for, and 13px
// is nearest 12px. Read as the only step, `base: 0.25rem` made all three `p-[4px]`.
expect((await findings(other)).map((d) => d.message)).toEqual([
'Hardcoded spacing `p-[13px]` (13px) is off the scale. Nearest: `p-[12px]` (12px).',
]);
});

it('writes the spacing steps in scale order, with those the project sets among them', () => {
const { tokens } = parseCssTokens(
`@theme {
--spacing: 0.25rem;
--spacing-18: 4.5rem;
--spacing-4: 1.1rem;
}`,
'app/globals.css',
);
const { text } = toDesignMd(tokens, { name: 'Steps' });
const spacing = /\nspacing:\n((?: {2}.*\n)+)/.exec(text)?.[1] ?? '';
const keys = [...spacing.matchAll(/^ {2}"?([^":]+)"?:/gm)].map((m) => Number(m[1]));
expect(keys).toEqual([...keys].sort((a, b) => a - b));
expect(spacing).toContain(' "4": 1.1rem\n');
expect(spacing).toContain(' "16": 4rem\n "18": 4.5rem\n "20": 5rem\n');
});

it('writes a typography alias as the level it names, not a reference to a whole level', () => {
const { tokens } = parseCssTokens(
`@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
}`,
'app/globals.css',
);
expect(tokens.find((t) => t.name === 'font-heading')?.aliasOf).toBe('font-sans');
const { text, warnings } = toDesignMd(tokens, { name: 'Fonts' });
expect(warnings).toEqual([]);
// In front matter, a reference names one value; `{typography.font-sans}` is a whole level,
// which DESIGN.md's linter read as a string of 22 unknown properties.
expect(text).toContain(
`typography:\n font-heading:\n fontFamily: "'Inter Variable', sans-serif"\n font-sans:\n fontFamily: "'Inter Variable', sans-serif"\n`,
);
const back = parseDesignMd(text, 'DESIGN.md');
expect(back.warnings).toEqual([]);
expect(back.tokens.find((t) => t.name === 'typography.font-heading')?.value).toBe(
"fontFamily: 'Inter Variable', sans-serif",
);
});

it('adds no tokens when the stylesheet and a DTCG file exported from it are both read', async () => {
const app = fixture({
'package.json': '{"name":"app"}',
'app/globals.css': stock,
'app/page.tsx': page,
});
const designMd = async () => {
const out = path.join(app, 'DESIGN.md');
const result = await run(
['export', '--format', 'design-md', '--out', out, '--no-cache'],
app,
);
return { ...result, text: fs.readFileSync(out, 'utf8') };
};
const before = await designMd();
expect(before).toMatchObject({ code: 0, stderr: '' });
expect(before.stdout).toMatch(/^Wrote 42 tokens to /);

// Zero config reads `**/*.tokens.json`, so the export is read back next to the stylesheet.
// Its `@theme inline` tokens (`rounded-sm`, `font-sans`) have no custom property: they merge
// with the stylesheet's by name and Tailwind key.
const dtcg = path.join(app, 'design.tokens.json');
expect(
await run(['export', '--format', 'dtcg', '--out', dtcg, '--no-cache'], app),
).toMatchObject({ code: 0, stderr: '' });
const after = await designMd();
expect(after).toMatchObject({ code: 0, stderr: '' });
expect(after.stdout).toMatch(/^Wrote 42 tokens to /);
expect(after.text).toBe(before.text);
expect(await findings(app)).toHaveLength(3);
});
});

const tok = (name: string, category: Token['category'], value: string): Token => ({
name,
category,
Expand Down
30 changes: 30 additions & 0 deletions test/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
mergeTokens,
parseCssTokens,
parseDtcgTokens,
toDtcg,
TokenIndex,
} from '../src/tokens/index.js';
import { evaluateLength, lengthToPx } from '../src/tokens/units.js';
Expand Down Expand Up @@ -398,6 +399,35 @@ describe('CSS custom-property tokens', () => {
]);
});

it('merges a @theme inline token, which has no custom property, by name and Tailwind key', () => {
const css = parseCssTokens(
`@theme inline {
--radius-sm: 6px;
--leading-tight: 1.25;
--tracking-tight: -0.025em;
}`,
't.css',
).tokens;
expect(css.map((t) => t.cssVar)).toEqual([undefined, undefined, undefined]);
// The DTCG file exported from them keeps their names and Tailwind keys, and no custom property.
const json = parseDtcgTokens(toDtcg(css).text, 't.json').tokens;
expect(json).toHaveLength(3);
expect(mergeTokens([...css, ...json])).toEqual(css);
// Listed the other way round, a merged token takes the stylesheet's category: the DTCG file
// has no type for `-0.025em`, so it reads it as `other`.
expect(json[2]?.category).toBe('other');
expect(mergeTokens([...json, ...css]).map((t) => t.category)).toEqual(
css.map((t) => t.category),
);
// Two tokens of the same key (`tight`) that make other classes stay apart.
const leading = json.filter((t) => t.name === 'leading-tight');
expect(mergeTokens([...css.filter((t) => t.name !== 'leading-tight'), ...leading])).toEqual([
css[0],
css[2],
leading[0],
]);
});

it('suggests the bare utility for a Tailwind v3 DEFAULT radius', () => {
const token: Token = {
name: 'radius-DEFAULT',
Expand Down
Loading