@halftone-ds/tokens 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +187 -28
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +87 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +35 -0
package/README.md
CHANGED
|
@@ -61,7 +61,8 @@ npx halftone-tokens theme resolve [--json] # the code for tokens.json as it
|
|
|
61
61
|
npx halftone-tokens theme shadcn <theme> [--out file] [--fonts-url url] # a shadcn registry:theme item
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
`<theme>` is a Halftone code (`ht1-…`, `ht2-…`, `ht3-…`), a preset name, a recipe `.json`,
|
|
64
|
+
`<theme>` is a Halftone code (`ht1-…`, `ht2-…`, `ht3-…`), a preset name, a recipe `.json`, a system
|
|
65
|
+
`.halftone.json` (see Systems), or a
|
|
65
66
|
[shadcn/ui](https://ui.shadcn.com/create) preset code (`b5owWMfJ8l`). A shadcn code becomes the nearest Halftone
|
|
66
67
|
recipe, and what it approximates or drops (shadcn-only greys, radius steps, fonts outside the list, icon
|
|
67
68
|
libraries, chart colours, menu options, styles) is listed on stderr. The other way, `theme shadcn` writes the
|
|
@@ -71,6 +72,47 @@ as `registry:font` dependencies (hosted at halftonedesignsystem.com/r/font, or `
|
|
|
71
72
|
From code: `decodeShadcnPreset`, `recipeFromShadcn`, `shadcnTheme` and `shadcnFont` in
|
|
72
73
|
`@halftone-ds/tokens/theme` (browser-safe). The preset codec is ported from shadcn/ui (MIT).
|
|
73
74
|
|
|
75
|
+
## Systems
|
|
76
|
+
|
|
77
|
+
A **system** (`name.halftone.json`, what the Halftone Studio saves) is a recipe plus whatever was edited by hand
|
|
78
|
+
on top of it: a seed per colour role (brand, neutral, danger, success, warning, info) with a 50–950 ramp, steps
|
|
79
|
+
edited by hand, the role mapping for light and dark, text styles (h1–h4, lead, p, large, small, muted, code,
|
|
80
|
+
blockquote), any Google Fonts family, corners, shadows, borders, named extras, component edits (Button, Chip,
|
|
81
|
+
Badge, Input), and the brand (name, logos, icon background).
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx halftone-tokens theme apply ./acme.halftone.json # writes tokens.json and records the system
|
|
85
|
+
npx halftone-tokens theme show # the system, its overrides, and any contrast warnings
|
|
86
|
+
npx halftone-tokens theme encode ./acme.halftone.json # its ht- code, when it has no overrides
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `tokens.json` records the document under `$extensions`, without its brand images. A system with overrides has
|
|
90
|
+
no `ht-` code: a code only carries a recipe.
|
|
91
|
+
- Generated colours are fixed for contrast as a recipe's are. A colour chosen by hand (a mapped role, an edited
|
|
92
|
+
step) is never moved: a pairing it fails is a warning.
|
|
93
|
+
- A status ramp from a seed replaces the file's; applying a theme without one puts the curated ramp back.
|
|
94
|
+
- Edited text styles are written as `semantic.type.<style>.{size,leading,weight,tracking,font}`, corners as
|
|
95
|
+
`semantic.radius.<step>` (the build uses them as fixed px), extras under `extra.*` (exported, not drawn by
|
|
96
|
+
the kits).
|
|
97
|
+
- Every text style (`h1 h2 h3 h4 lead p large small muted code blockquote`), edited or not, is built as
|
|
98
|
+
`--type-<style>-{size,leading,weight,tracking}` (plus `-font` on Tailwind 4). nativewind3 adds the `text-`,
|
|
99
|
+
`leading-`, `tracking-` and `font-type-<style>` keys; shadcn-web adds `type-<style>` utilities; ts-theme adds
|
|
100
|
+
`TOKENS.textStyles`. With no edits they draw exactly what Tailwind's `text-*` classes did, with one exception:
|
|
101
|
+
tracking is em on every platform, so on native it scales with the style's size (NativeWind's own `tracking-*`
|
|
102
|
+
are fixed px there: `tracking-tight` is -0.25px). `themeVars` carries the styles to live previews
|
|
103
|
+
(`textStyleVars`, `themeTextStyles`).
|
|
104
|
+
- Every theme writes a component layer, `semantic.component.<button|chip|badge|input>`: a corner, heights and
|
|
105
|
+
padding, a text style and each variant's fill, label, border and pressed colours (references to semantic
|
|
106
|
+
colours). Both kits read it as `--button-*`, `--chip-*`, `--badge-*` and `--field-*` (`bg-button-primary-bg`,
|
|
107
|
+
`rounded-chip`, `h-button-md`, `text-type-button` in nativewind3; `type-button` and the same colours in
|
|
108
|
+
shadcn-web), so a Button looks the same in both. A system's `components` sets any of it (roles by name, a
|
|
109
|
+
Foundations corner or px). The build's contrast gate checks every variant's label on its fill.
|
|
110
|
+
|
|
111
|
+
From code (`@halftone-ds/tokens/system`, also in `/theme`, browser-safe): `normalizeSystem` / `validateSystem`,
|
|
112
|
+
`createSystem`, `migrate(recipe | code, brand)` and `systemToRecipe`, `generateSystem(system, tokens)` (the same
|
|
113
|
+
shape as `generateTheme`, plus `warnings`), `systemRamps`, `textStyles`, `systemRadii`, `systemCode` and
|
|
114
|
+
`applySystem`. The document's shape is described in `lib/system/schema.mjs`.
|
|
115
|
+
|
|
74
116
|
## Guarantees
|
|
75
117
|
|
|
76
118
|
- **Contrast check.** Every `X` / `X-foreground` pairing, body and muted text on every surface, each status
|
package/cli.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
22
22
|
import { dirname, join, resolve } from 'node:path';
|
|
23
23
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
24
|
|
|
25
|
-
import { checkContrast, AA, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
|
|
25
|
+
import { checkContrast, AA, componentFailures, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
|
|
26
26
|
import { scanTintText } from './lib/scan.mjs';
|
|
27
27
|
import { loadTokens } from './lib/resolve.mjs';
|
|
28
28
|
|
|
@@ -89,7 +89,10 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
// `strict: true` here is the full set of pairings (muted and accent surfaces too), not the CLI flag.
|
|
92
|
-
const
|
|
92
|
+
const base = checkContrast(ctx.colors, { strict: true });
|
|
93
|
+
// The components' own pairings (a Button variant's label on its fill), once per colour pair.
|
|
94
|
+
const seen = new Set(base.map((line) => /^\s+(\w+) .*\((#\w+) on (#\w+)\)$/.exec(line)?.slice(1).join(' ')));
|
|
95
|
+
const failures = [...base, ...componentFailures(ctx.components, ctx.colors, seen)];
|
|
93
96
|
ctx.contrastFailures = failures;
|
|
94
97
|
if (failures.length) {
|
|
95
98
|
gate(
|
package/lib/contrast.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { apcaContrast } from './apca.mjs';
|
|
7
7
|
import { blend, contrast } from './color.mjs';
|
|
8
|
+
import { componentPairs } from './theme/components.mjs';
|
|
8
9
|
|
|
9
10
|
export const AA = 4.5; // WCAG 2.1 AA, normal text. Kit labels are 16px — not "large text".
|
|
10
11
|
export const AA_NON_TEXT = 3; // WCAG 1.4.11, icons and other meaningful non-text.
|
|
@@ -125,3 +126,35 @@ export function checkContrast(colors, options) {
|
|
|
125
126
|
const of = (mode) => Object.fromEntries(colors.map((c) => [c.name, c[mode]]));
|
|
126
127
|
return [...contrastFailures(of('light'), 'light', options), ...contrastFailures(of('dark'), 'dark', options)];
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The component layer's label-on-fill pairings (lib/theme/components.mjs), in both modes, as rows like contrastReport's:
|
|
132
|
+
* { mode, label, fgName, bgName, fg, bg, threshold, ratio, component, variant }. `bgName` is the fill's role.
|
|
133
|
+
*/
|
|
134
|
+
export function componentContrast(components, background) {
|
|
135
|
+
return ['light', 'dark'].flatMap((mode) =>
|
|
136
|
+
componentPairs(components, mode, background[mode], blend).map((p) => ({
|
|
137
|
+
mode,
|
|
138
|
+
label: p.label,
|
|
139
|
+
fgName: p.fgRole,
|
|
140
|
+
bgName: p.bgRole,
|
|
141
|
+
fg: p.fg,
|
|
142
|
+
bg: p.bg,
|
|
143
|
+
threshold: AA,
|
|
144
|
+
ratio: contrast(p.fg, p.bg),
|
|
145
|
+
component: p.component,
|
|
146
|
+
variant: p.variant,
|
|
147
|
+
}))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The failing component pairings as report lines, leaving out any colour pair `seen` already reports ('mode fg bg'). */
|
|
152
|
+
export function componentFailures(components, colors, seen = new Set()) {
|
|
153
|
+
const background = Object.fromEntries(['light', 'dark'].map((mode) => [mode, colors.find((c) => c.name === 'background')?.[mode] ?? '#ffffff']));
|
|
154
|
+
const lines = new Set();
|
|
155
|
+
for (const r of componentContrast(components, background)) {
|
|
156
|
+
if (r.ratio >= r.threshold || seen.has(`${r.mode} ${r.fg} ${r.bg}`)) continue;
|
|
157
|
+
lines.add(` ${r.mode.padEnd(5)} ${r.label.padEnd(36)} ${r.ratio.toFixed(2)} / ${r.threshold} (${r.fg} on ${r.bg})`);
|
|
158
|
+
}
|
|
159
|
+
return [...lines];
|
|
160
|
+
}
|
package/lib/resolve.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import StyleDictionary from 'style-dictionary';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
-
import {
|
|
8
|
+
import { componentLayer, readComponents } from './theme/components.mjs';
|
|
9
|
+
import { extraList } from './theme/extras.mjs';
|
|
10
|
+
import { BUTTON, DEFAULT_RECIPE, DENSITY, DEPTH, FIELD, fieldWidth, resolveTextStyles, shadowCss, textEdits, TYPE, typeScale } from './theme/recipe.mjs';
|
|
9
11
|
|
|
10
12
|
export const MODES = ['light', 'dark'];
|
|
11
13
|
|
|
@@ -45,48 +47,78 @@ export function cssName(token) {
|
|
|
45
47
|
/**
|
|
46
48
|
* Tailwind radius classes as multiples of primitive.radius.lg. With the default base every class equals
|
|
47
49
|
* its primitive (rounded-xl = radius.xl); moving semantic.radius.base scales the whole set, so a base
|
|
48
|
-
* of 0 makes every rounded-* square.
|
|
50
|
+
* of 0 makes every rounded-* square. A system can set a step outright (semantic.radius.md = 6): that step is
|
|
51
|
+
* `fixed` at its px and no longer follows the base.
|
|
52
|
+
* → [{ name, scale, px, fixed }]
|
|
49
53
|
*/
|
|
50
|
-
function radiusScales(prim) {
|
|
54
|
+
function radiusScales(prim, sem, base) {
|
|
51
55
|
const steps = prim.filter((t) => t.path[1] === 'radius' && t.path.length === 3 && t.path[2] !== 'full');
|
|
52
56
|
const anchor = steps.find((t) => t.path[2] === 'lg').$value;
|
|
53
|
-
|
|
57
|
+
const fixed = Object.fromEntries(sem.filter((t) => t.path[1] === 'radius' && t.path.length === 3).map((t) => [t.path[2], t.$value]));
|
|
58
|
+
return steps.map((t) => {
|
|
59
|
+
const scale = Number((t.$value / anchor).toFixed(4));
|
|
60
|
+
const set = typeof fixed[t.path[2]] === 'number';
|
|
61
|
+
return { name: t.path[2], scale, px: set ? fixed[t.path[2]] : Math.round(base * scale * 10) / 10, fixed: set };
|
|
62
|
+
});
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
export function radiusCalc(scale) {
|
|
57
66
|
return scale === 1 ? 'var(--radius)' : `calc(var(--radius) * ${scale})`;
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
/** A radius step as CSS: a multiple of --radius, or its own px when a system fixed it. */
|
|
70
|
+
export function radiusCss(step) {
|
|
71
|
+
return step.fixed ? (step.px >= 9999 ? '9999px' : `${Number((step.px / 16).toFixed(4))}rem`) : radiusCalc(step.scale);
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
/**
|
|
61
75
|
* Everything a target needs:
|
|
62
|
-
* semLight / semDark semantic tokens per mode (colours + radius), in tokens.json order
|
|
76
|
+
* semLight / semDark semantic tokens per mode (colours + radius), in tokens.json order, but the component layer
|
|
77
|
+
* compLight / compDark the component layer's tokens per mode (semantic.component.*; read as `components` below)
|
|
63
78
|
* prim primitive tokens (mode-independent)
|
|
64
79
|
* colors [{ name, light, dark }] semantic colours as hex
|
|
65
|
-
* radius { base: px, steps: [{ name, scale }] }
|
|
80
|
+
* radius { base: px, steps: [{ name, scale, px, fixed }] } (fixed: set by a system, not scaled)
|
|
66
81
|
* shape { control: px, borderWidth: px, stroke, shadows: { light: { 1: css }, dark },
|
|
67
82
|
* density: { scale, sm, md, lg, x } }
|
|
68
83
|
* (a token file from before themes gets the kit's defaults)
|
|
69
84
|
* type { size, scale, leading, leadingFactor, flow, measure } (semantic.type; defaults
|
|
70
85
|
* for a token file from before the typeset)
|
|
86
|
+
* textStyles { h1: { font, weight, size, leading, tracking, edited }, … } the Text variants at the typeset,
|
|
87
|
+
* with a system's edited styles (semantic.type.<style>) on top (recipe.mjs resolveTextStyles)
|
|
71
88
|
* shape.button/.field the styles' numbers (semantic.button / .field), radii in px; a token file from
|
|
72
89
|
* before the styles gets solid buttons and outline fields, and colours to match
|
|
73
90
|
* styles { button, field }: the style names, from the recorded theme (or the defaults)
|
|
91
|
+
* extras a system's own tokens (tokens.json `extra.*`) as lists (lib/theme/extras.mjs extraList)
|
|
92
|
+
* components the component layer (semantic.component.*: Button, Chip, Badge, Input) resolved, as
|
|
93
|
+
* lib/theme/components.mjs componentLayer returns it; a file from before it gets the defaults
|
|
74
94
|
*/
|
|
75
95
|
export async function loadTokens(sourcePath) {
|
|
76
96
|
const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
77
97
|
const [light, dark] = await Promise.all(MODES.map((m) => resolveMode(source, m)));
|
|
78
|
-
|
|
79
|
-
const
|
|
98
|
+
// The component layer (semantic.component.*) is read on its own (ctx.components), not as plain tokens.
|
|
99
|
+
const compLight = light.filter((t) => t.path[0] === 'semantic' && t.path[1] === 'component');
|
|
100
|
+
const compDark = dark.filter((t) => t.path[0] === 'semantic' && t.path[1] === 'component');
|
|
101
|
+
const semLight = light.filter((t) => t.path[0] === 'semantic' && t.path[1] !== 'component');
|
|
102
|
+
const semDark = dark.filter((t) => t.path[0] === 'semantic' && t.path[1] !== 'component');
|
|
80
103
|
const prim = light.filter((t) => t.path[0] === 'primitive');
|
|
81
104
|
const darkHex = Object.fromEntries(semDark.filter(isColor).map((t) => [cssName(t), t.$value.toLowerCase()]));
|
|
82
105
|
const colors = semLight
|
|
83
106
|
.filter(isColor)
|
|
84
107
|
.map((t) => ({ name: cssName(t), light: t.$value.toLowerCase(), dark: darkHex[cssName(t)] }));
|
|
108
|
+
const radiusBase = semLight.find((t) => cssName(t) === 'radius').$value;
|
|
85
109
|
const radius = {
|
|
86
|
-
base:
|
|
87
|
-
steps: radiusScales(prim),
|
|
110
|
+
base: radiusBase,
|
|
111
|
+
steps: radiusScales(prim, semLight, radiusBase),
|
|
88
112
|
};
|
|
89
113
|
const find = (list, name) => list.find((t) => cssName(t) === name)?.$value;
|
|
114
|
+
// The style numbers live in the component layer (semantic.component.button / .input); older files had
|
|
115
|
+
// semantic.button / semantic.field.
|
|
116
|
+
const LEGACY = { 'button-': 'component-button-', 'field-': 'component-input-' };
|
|
117
|
+
const style = (list, name) => {
|
|
118
|
+
const prefix = Object.keys(LEGACY).find((p) => name.startsWith(p));
|
|
119
|
+
const comp = list === semDark ? compDark : compLight;
|
|
120
|
+
return find(comp, LEGACY[prefix] + name.slice(prefix.length)) ?? find(list, name);
|
|
121
|
+
};
|
|
90
122
|
// Style colours a token file from before the styles doesn't have: what the components drew before.
|
|
91
123
|
const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
|
|
92
124
|
const STYLE_FALLBACK = { button: 'primary', 'button-foreground': 'primary-foreground', 'button-edge': 'primary', field: 'input', 'field-edge': 'input' };
|
|
@@ -97,21 +129,21 @@ export async function loadTokens(sourcePath) {
|
|
|
97
129
|
const md = radius.steps.find((s) => s.name === 'md')?.scale ?? 0.8;
|
|
98
130
|
const solid = BUTTON.solid;
|
|
99
131
|
const outline = FIELD.outline;
|
|
100
|
-
const fieldRound =
|
|
101
|
-
const fieldRoundBottom =
|
|
132
|
+
const fieldRound = style(semLight, 'field-round') ?? outline.round;
|
|
133
|
+
const fieldRoundBottom = style(semLight, 'field-round-bottom') ?? outline.roundBottom;
|
|
102
134
|
const shape = {
|
|
103
135
|
control: find(semLight, 'radius-control') ?? 9999,
|
|
104
136
|
borderWidth,
|
|
105
137
|
button: {
|
|
106
|
-
alpha:
|
|
107
|
-
border:
|
|
108
|
-
edge:
|
|
138
|
+
alpha: style(semLight, 'button-alpha') ?? solid.alpha,
|
|
139
|
+
border: style(semLight, 'button-border-width') ?? solid.border,
|
|
140
|
+
edge: style(semLight, 'button-edge-width') ?? solid.edge,
|
|
109
141
|
},
|
|
110
142
|
field: {
|
|
111
|
-
alpha: { light:
|
|
112
|
-
border:
|
|
113
|
-
edge:
|
|
114
|
-
inset:
|
|
143
|
+
alpha: { light: style(semLight, 'field-alpha') ?? outline.alpha.light, dark: style(semDark, 'field-alpha') ?? outline.alpha.dark },
|
|
144
|
+
border: style(semLight, 'field-border-width') ?? fieldWidth(outline.border, borderWidth),
|
|
145
|
+
edge: style(semLight, 'field-edge-width') ?? fieldWidth(outline.edge, borderWidth),
|
|
146
|
+
inset: style(semLight, 'field-inset') ?? outline.inset,
|
|
115
147
|
radius: Number((radius.base * md * fieldRound).toFixed(4)),
|
|
116
148
|
radiusBottom: Number((radius.base * md * fieldRoundBottom).toFixed(4)),
|
|
117
149
|
},
|
|
@@ -123,9 +155,37 @@ export async function loadTokens(sourcePath) {
|
|
|
123
155
|
},
|
|
124
156
|
};
|
|
125
157
|
const type = typeScale(Object.fromEntries(Object.entries(TYPE).map(([key, value]) => [key, find(semLight, `type-${key}`) ?? value])));
|
|
158
|
+
const textStyles = resolveTextStyles(type, textEdits(source.semantic?.type));
|
|
126
159
|
const recorded = source.$extensions?.['com.halftonedesignsystem.theme']?.recipe ?? {};
|
|
127
160
|
const styles = { button: recorded.button ?? DEFAULT_RECIPE.button, field: recorded.field ?? DEFAULT_RECIPE.field };
|
|
128
|
-
|
|
161
|
+
const extras = extraList(source.extra);
|
|
162
|
+
const components = loadComponents({ source, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, recorded });
|
|
163
|
+
return { source, semLight, semDark, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, extras, components };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The component layer from the file, or (a file from before it) the defaults on the file's colours and shape. */
|
|
167
|
+
function loadComponents({ source, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, recorded }) {
|
|
168
|
+
const flat = (list) => Object.fromEntries(list.map((t) => [t.path.join('.'), t.$value]));
|
|
169
|
+
const read = readComponents(flat(compLight), flat(compDark), source.semantic?.component);
|
|
170
|
+
if (read) return read;
|
|
171
|
+
const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
|
|
172
|
+
const primMap = Object.fromEntries(prim.map((t) => [t.path.join('.'), t.$value]));
|
|
173
|
+
const outline = FIELD[styles.field] ?? FIELD.outline;
|
|
174
|
+
return componentLayer({
|
|
175
|
+
styles,
|
|
176
|
+
controls: recorded.controls ?? (shape.control >= 9999 ? 'pill' : 'match'),
|
|
177
|
+
corner: (name) => radius.steps.find((s) => s.name === name)?.px ?? radius.base,
|
|
178
|
+
density: shape.density,
|
|
179
|
+
borderWidth: shape.borderWidth,
|
|
180
|
+
button: shape.button,
|
|
181
|
+
field: { ...shape.field, round: outline.round, roundBottom: outline.roundBottom },
|
|
182
|
+
textStyles,
|
|
183
|
+
type,
|
|
184
|
+
hex: (ref, mode) => {
|
|
185
|
+
const name = /^\{semantic\.color\.(.+)\}$/.exec(ref)?.[1];
|
|
186
|
+
return String(name ? byName[name]?.[mode] : primMap[ref.slice(1, -1)]).toLowerCase();
|
|
187
|
+
},
|
|
188
|
+
}).shape;
|
|
129
189
|
}
|
|
130
190
|
|
|
131
191
|
/** Primitive tokens under a path prefix, e.g. group(prim, ['font', 'size']) → [{ key: 'xs', value: 12 }, …]. */
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateSystem — a HalftoneSystem expanded into a theme, the same shape generateTheme returns. Pure ESM.
|
|
3
|
+
*
|
|
4
|
+
* generateSystem(doc, base) base = the app's tokens.json
|
|
5
|
+
* 1. generateTheme(doc.recipe, base), with
|
|
6
|
+
* 2. a ramp for every colour role from its seed (brandRamp; a custom neutral from its seed; a status
|
|
7
|
+
* role without one keeps base's ramp), then the steps edited by hand
|
|
8
|
+
* 3. the role mapping, light and dark: the fixer moves only generated colours; a role mapped by hand, or
|
|
9
|
+
* on an edited step, keeps its colour, and a pairing that fails because of it is a warning
|
|
10
|
+
* 4. per-style text tokens (semantic.type.<style>.{size,leading,weight,tracking,font}) for edited styles
|
|
11
|
+
* 5. fonts, radius, shadow and border overrides
|
|
12
|
+
* 6. extras, as the `extra` token group (theme.extra)
|
|
13
|
+
* 7. the component layer (semantic.component.<name>), with the system's `components` edits on top
|
|
14
|
+
*
|
|
15
|
+
* A system with no overrides is exactly generateTheme(doc.recipe, base) (a test holds it to that).
|
|
16
|
+
* The theme's `code` is its recipe's: a lossy handle when the system has overrides (systemCode says so).
|
|
17
|
+
*/
|
|
18
|
+
import { encodeRecipe } from '../theme/codec.mjs';
|
|
19
|
+
import { generateTheme, withComponents } from '../theme/generate.mjs';
|
|
20
|
+
import { STEPS } from '../theme/palettes.mjs';
|
|
21
|
+
import { anchorStep, brandRamp, neutralAnchorStep, neutralRamp, neutralSeedRamp } from '../theme/ramp.mjs';
|
|
22
|
+
import { FIELD, fieldWidth, typeScale, LEADING, FLOW, resolveTextStyles } from '../theme/recipe.mjs';
|
|
23
|
+
import { resolvedMap } from '../theme/refs.mjs';
|
|
24
|
+
import { COLOR_ROLES, EXTRA_KINDS, hasOverrides, normalizeSystem, RADIUS_STEPS, STATUS_ROLES, TEXT_STYLES } from './schema.mjs';
|
|
25
|
+
|
|
26
|
+
const round = (n, places = 4) => Number(n.toFixed(places));
|
|
27
|
+
const alphaHex = (alpha) => Math.round(alpha * 255).toString(16).padStart(2, '0');
|
|
28
|
+
|
|
29
|
+
/** A layer as a DTCG shadow value. */
|
|
30
|
+
const shadowValue = (l) => ({ offsetX: l.x, offsetY: l.y, blur: l.blur, spread: l.spread, color: `${l.color}${alphaHex(l.alpha)}`, ...(l.inset ? { inset: true } : {}) });
|
|
31
|
+
const shadowToken = (s) => ({ light: s.light.map(shadowValue), dark: s.dark.map(shadowValue) });
|
|
32
|
+
|
|
33
|
+
/** The base file's primitives, resolved (its ramps may be references). */
|
|
34
|
+
const basePrimitives = (base) => resolvedMap({ primitive: base.primitive }, 'light');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every colour role's ramp: { role: { seed, anchor, source, generated, values, edited } }.
|
|
38
|
+
* source 'recipe' (brand, a preset neutral), 'seed' (a seed set in the system) or 'base' (a status
|
|
39
|
+
* ramp from tokens.json)
|
|
40
|
+
* generated the ramp before edits; values: after them; edited: the steps edited by hand
|
|
41
|
+
* The Studio draws its ramp strips from this.
|
|
42
|
+
*/
|
|
43
|
+
export function systemRamps(input, base) {
|
|
44
|
+
const doc = normalizeSystem(input);
|
|
45
|
+
const { recipe, colors } = doc;
|
|
46
|
+
const prim = basePrimitives(base);
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const role of COLOR_ROLES) {
|
|
49
|
+
const c = colors[role] ?? {};
|
|
50
|
+
let generated;
|
|
51
|
+
let seed;
|
|
52
|
+
let anchor;
|
|
53
|
+
let source;
|
|
54
|
+
if (role === 'brand') {
|
|
55
|
+
seed = recipe.brand;
|
|
56
|
+
anchor = c.anchor ?? anchorStep(seed);
|
|
57
|
+
generated = brandRamp(seed, { anchor: c.anchor });
|
|
58
|
+
source = 'recipe';
|
|
59
|
+
} else if (role === 'neutral') {
|
|
60
|
+
if (c.seed) {
|
|
61
|
+
seed = c.seed;
|
|
62
|
+
anchor = c.anchor ?? neutralAnchorStep(seed);
|
|
63
|
+
generated = neutralSeedRamp(seed, { anchor: c.anchor });
|
|
64
|
+
source = 'seed';
|
|
65
|
+
} else {
|
|
66
|
+
generated = neutralRamp(recipe.neutral, recipe.brand);
|
|
67
|
+
seed = recipe.neutral;
|
|
68
|
+
anchor = null;
|
|
69
|
+
source = 'recipe';
|
|
70
|
+
}
|
|
71
|
+
} else if (c.seed) {
|
|
72
|
+
seed = c.seed;
|
|
73
|
+
anchor = c.anchor ?? anchorStep(seed);
|
|
74
|
+
generated = brandRamp(seed, { anchor: c.anchor });
|
|
75
|
+
source = 'seed';
|
|
76
|
+
} else {
|
|
77
|
+
generated = Object.fromEntries(STEPS.filter((s) => `primitive.color.${role}.${s}` in prim).map((s) => [s, prim[`primitive.color.${role}.${s}`]]));
|
|
78
|
+
if (Object.keys(generated).length !== STEPS.length) throw new Error(`generateSystem: the base tokens have no ${role} ramp (primitive.color.${role}.50…950)`);
|
|
79
|
+
seed = generated['600'];
|
|
80
|
+
anchor = '600';
|
|
81
|
+
source = 'base';
|
|
82
|
+
}
|
|
83
|
+
const edited = Object.keys(c.steps ?? {});
|
|
84
|
+
out[role] = { seed, anchor, source, generated, values: { ...generated, ...c.steps }, edited };
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A role's value as the theme writes it: '{primitive.color.brand.600}' or a hex. */
|
|
90
|
+
const roleRef = (value) => (value.startsWith('#') ? value : `{primitive.color.${value}}`);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every text style as drawn: { h1: { font, weight, size, leading, tracking, edited: [keys] }, … }. Defaults follow
|
|
94
|
+
* the recipe's typeset (sizes × size / 16, line heights × leading / 1.75); edited keys are as set.
|
|
95
|
+
*/
|
|
96
|
+
export function textStyles(input) {
|
|
97
|
+
const doc = normalizeSystem(input);
|
|
98
|
+
const type = typeScale({ size: Number(doc.recipe.size), leading: LEADING[doc.recipe.leading], flow: FLOW[doc.recipe.flow], measure: Number(doc.recipe.measure) });
|
|
99
|
+
const generated = resolveTextStyles(type);
|
|
100
|
+
return Object.fromEntries(
|
|
101
|
+
TEXT_STYLES.map((name) => {
|
|
102
|
+
const { font, weight, size, leading, tracking } = generated[name];
|
|
103
|
+
const edits = doc.type[name] ?? {};
|
|
104
|
+
return [name, { font, weight, size: round(size, 2), leading: round(leading), tracking, ...edits, edited: Object.keys(edits) }];
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** rounded-sm…2xl in px: the base radius × each step's ratio to lg (as the build draws them), or as edited. */
|
|
110
|
+
export function systemRadii(input, base, theme = generateSystem(input, base)) {
|
|
111
|
+
const doc = normalizeSystem(input);
|
|
112
|
+
const prim = basePrimitives(base);
|
|
113
|
+
const lg = prim['primitive.radius.lg'];
|
|
114
|
+
return Object.fromEntries(RADIUS_STEPS.map((step) => [step, doc.radius[step] ?? round((theme.radius * prim[`primitive.radius.${step}`]) / lg, 2)]));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The `extra` token group (DTCG) for a system's extras, or undefined when it has none. */
|
|
118
|
+
function extraTokens(doc) {
|
|
119
|
+
const group = {};
|
|
120
|
+
const fontRef = (role) => `{primitive.font.family.${role}}`;
|
|
121
|
+
for (const kind of EXTRA_KINDS) {
|
|
122
|
+
if (!doc.extras[kind].length) continue;
|
|
123
|
+
group[kind] = {};
|
|
124
|
+
for (const { name, ...item } of doc.extras[kind]) {
|
|
125
|
+
if (kind === 'color') {
|
|
126
|
+
if (item.seed) {
|
|
127
|
+
const values = { ...brandRamp(item.seed, { anchor: item.anchor }), ...item.steps };
|
|
128
|
+
group.color[name] = Object.fromEntries(STEPS.map((s) => [s, { $type: 'color', $value: values[s] }]));
|
|
129
|
+
} else group.color[name] = { $type: 'color', $value: item.value };
|
|
130
|
+
}
|
|
131
|
+
if (kind === 'text') {
|
|
132
|
+
group.text[name] = {
|
|
133
|
+
font: { $type: 'fontFamily', $value: fontRef(item.font) },
|
|
134
|
+
weight: { $type: 'fontWeight', $value: item.weight },
|
|
135
|
+
size: { $type: 'number', $value: item.size },
|
|
136
|
+
leading: { $type: 'number', $value: item.leading },
|
|
137
|
+
tracking: { $type: 'number', $value: item.tracking },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'radius') group.radius[name] = { $type: 'number', $value: item.value };
|
|
141
|
+
if (kind === 'shadow') group.shadow[name] = { $type: 'shadow', $value: shadowToken(item) };
|
|
142
|
+
if (kind === 'border') {
|
|
143
|
+
group.border[name] = { width: { $type: 'number', $value: item.width }, ...(item.style ? { style: { $type: 'strokeStyle', $value: item.style } } : {}) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return Object.keys(group).length ? group : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The theme for a system, on top of `base` (a tokens.json object). `fix: false` skips the contrast fixer. */
|
|
151
|
+
export function generateSystem(input, base, { fix = true } = {}) {
|
|
152
|
+
const doc = normalizeSystem(input);
|
|
153
|
+
const { recipe, colors } = doc;
|
|
154
|
+
|
|
155
|
+
// 1–3. Colours: only a system that changes them goes through generateTheme's system hook.
|
|
156
|
+
const touchesColors = Object.keys(colors).length > 0 || Object.keys(doc.roles).length > 0;
|
|
157
|
+
let options = {};
|
|
158
|
+
if (touchesColors) {
|
|
159
|
+
const ramps = systemRamps(doc, base);
|
|
160
|
+
const changed = (role) => colors[role] !== undefined;
|
|
161
|
+
options = {
|
|
162
|
+
colors: {
|
|
163
|
+
ramps: Object.fromEntries(COLOR_ROLES.filter(changed).map((role) => [role, ramps[role].values])),
|
|
164
|
+
anchor: ramps.brand.anchor,
|
|
165
|
+
anchors: Object.fromEntries(STATUS_ROLES.filter((role) => colors[role]?.seed).map((role) => [role, ramps[role].anchor])),
|
|
166
|
+
roles: Object.fromEntries(Object.entries(doc.roles).map(([name, v]) => [name, Object.fromEntries(Object.entries(v).map(([mode, value]) => [mode, roleRef(value)]))])),
|
|
167
|
+
edited: Object.fromEntries(COLOR_ROLES.filter((role) => ramps[role].edited.length).map((role) => [role, ramps[role].edited])),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const theme = generateTheme(recipe, base, { fix, ...options });
|
|
172
|
+
if (!hasOverrides(doc)) return theme;
|
|
173
|
+
|
|
174
|
+
const semantic = { ...theme.semantic };
|
|
175
|
+
// 4. Text styles someone edited: every key, so the style is whole (the kits read all five).
|
|
176
|
+
const edited = Object.keys(doc.type);
|
|
177
|
+
if (edited.length) {
|
|
178
|
+
const styles = textStyles(doc);
|
|
179
|
+
semantic.type = { ...semantic.type };
|
|
180
|
+
for (const name of edited) {
|
|
181
|
+
const { font, weight, size, leading, tracking } = styles[name];
|
|
182
|
+
semantic.type[name] = { size, leading, weight, tracking, font: `{primitive.font.family.${font}}` };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 5. Fonts, radius, shadow, border.
|
|
186
|
+
const fonts = { ...theme.fonts, ...doc.fonts };
|
|
187
|
+
if (Object.keys(doc.radius).length) semantic.radius = { ...semantic.radius, ...doc.radius };
|
|
188
|
+
if (Object.keys(doc.shadow).length) {
|
|
189
|
+
semantic.shadow = { ...semantic.shadow };
|
|
190
|
+
for (const [level, s] of Object.entries(doc.shadow)) semantic.shadow[level] = shadowToken(s);
|
|
191
|
+
}
|
|
192
|
+
let { borderWidth, field } = theme;
|
|
193
|
+
if (Object.keys(doc.border).length) {
|
|
194
|
+
semantic.border = { ...semantic.border, ...doc.border };
|
|
195
|
+
if (doc.border.width !== undefined) {
|
|
196
|
+
borderWidth = doc.border.width;
|
|
197
|
+
// The field's widths follow the border's (outline fields) or a 1px floor (filled, underline).
|
|
198
|
+
const fld = FIELD[recipe.field];
|
|
199
|
+
field = { ...field, border: fieldWidth(fld.border, borderWidth), edge: fieldWidth(fld.edge, borderWidth) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// 6. Extras.
|
|
203
|
+
const extra = extraTokens(doc);
|
|
204
|
+
// 7. The component layer again, on the edited corners, border and text styles, with the system's own component edits.
|
|
205
|
+
return withComponents({ ...theme, semantic, fonts, borderWidth, field, ...(extra ? { extra } : {}) }, base, doc.components);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The ht- code for a system with no overrides; null when one would be lost (a code holds only the recipe). */
|
|
209
|
+
export function systemCode(input) {
|
|
210
|
+
const doc = normalizeSystem(input);
|
|
211
|
+
return hasOverrides(doc) ? null : encodeRecipe(doc.recipe);
|
|
212
|
+
}
|