@halftone-ds/tokens 1.1.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 +84 -4
- package/cli.mjs +33 -14
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +56 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +100 -12
- 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 +191 -16
- package/lib/theme/codec.mjs +38 -23
- 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 +16 -1
- package/lib/theme/generate.mjs +114 -13
- package/lib/theme/index.d.mts +166 -2
- package/lib/theme/index.mjs +33 -3
- package/lib/theme/presets.mjs +19 -7
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +107 -0
- package/lib/theme/semantic.mjs +43 -6
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +81 -3
- package/lib/theme-cli.mjs +179 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +64 -17
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +9 -4
- package/targets/nativewind3.mjs +77 -12
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +106 -6
- package/targets/ts-theme.mjs +42 -0
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,50 +47,106 @@ 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)
|
|
88
|
+
* shape.button/.field the styles' numbers (semantic.button / .field), radii in px; a token file from
|
|
89
|
+
* before the styles gets solid buttons and outline fields, and colours to match
|
|
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
|
|
71
94
|
*/
|
|
72
95
|
export async function loadTokens(sourcePath) {
|
|
73
96
|
const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
74
97
|
const [light, dark] = await Promise.all(MODES.map((m) => resolveMode(source, m)));
|
|
75
|
-
|
|
76
|
-
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');
|
|
77
103
|
const prim = light.filter((t) => t.path[0] === 'primitive');
|
|
78
104
|
const darkHex = Object.fromEntries(semDark.filter(isColor).map((t) => [cssName(t), t.$value.toLowerCase()]));
|
|
79
105
|
const colors = semLight
|
|
80
106
|
.filter(isColor)
|
|
81
107
|
.map((t) => ({ name: cssName(t), light: t.$value.toLowerCase(), dark: darkHex[cssName(t)] }));
|
|
108
|
+
const radiusBase = semLight.find((t) => cssName(t) === 'radius').$value;
|
|
82
109
|
const radius = {
|
|
83
|
-
base:
|
|
84
|
-
steps: radiusScales(prim),
|
|
110
|
+
base: radiusBase,
|
|
111
|
+
steps: radiusScales(prim, semLight, radiusBase),
|
|
85
112
|
};
|
|
86
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
|
+
};
|
|
122
|
+
// Style colours a token file from before the styles doesn't have: what the components drew before.
|
|
123
|
+
const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
|
|
124
|
+
const STYLE_FALLBACK = { button: 'primary', 'button-foreground': 'primary-foreground', 'button-edge': 'primary', field: 'input', 'field-edge': 'input' };
|
|
125
|
+
for (const [name, from] of Object.entries(STYLE_FALLBACK)) if (!byName[name] && byName[from]) colors.push({ ...byName[from], name });
|
|
87
126
|
const shadows = (list, mode) =>
|
|
88
127
|
Object.fromEntries(['1', '2', '3'].map((level) => [level, shadowCss(find(list, `shadow-${level}`) ?? DEPTH.soft[level][mode])]));
|
|
128
|
+
const borderWidth = find(semLight, 'border-width') ?? 1;
|
|
129
|
+
const md = radius.steps.find((s) => s.name === 'md')?.scale ?? 0.8;
|
|
130
|
+
const solid = BUTTON.solid;
|
|
131
|
+
const outline = FIELD.outline;
|
|
132
|
+
const fieldRound = style(semLight, 'field-round') ?? outline.round;
|
|
133
|
+
const fieldRoundBottom = style(semLight, 'field-round-bottom') ?? outline.roundBottom;
|
|
89
134
|
const shape = {
|
|
90
135
|
control: find(semLight, 'radius-control') ?? 9999,
|
|
91
|
-
borderWidth
|
|
136
|
+
borderWidth,
|
|
137
|
+
button: {
|
|
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,
|
|
141
|
+
},
|
|
142
|
+
field: {
|
|
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,
|
|
147
|
+
radius: Number((radius.base * md * fieldRound).toFixed(4)),
|
|
148
|
+
radiusBottom: Number((radius.base * md * fieldRoundBottom).toFixed(4)),
|
|
149
|
+
},
|
|
92
150
|
stroke: find(semLight, 'icon-stroke') ?? 2,
|
|
93
151
|
shadows: { light: shadows(semLight, 'light'), dark: shadows(semDark, 'dark') },
|
|
94
152
|
density: {
|
|
@@ -97,7 +155,37 @@ export async function loadTokens(sourcePath) {
|
|
|
97
155
|
},
|
|
98
156
|
};
|
|
99
157
|
const type = typeScale(Object.fromEntries(Object.entries(TYPE).map(([key, value]) => [key, find(semLight, `type-${key}`) ?? value])));
|
|
100
|
-
|
|
158
|
+
const textStyles = resolveTextStyles(type, textEdits(source.semantic?.type));
|
|
159
|
+
const recorded = source.$extensions?.['com.halftonedesignsystem.theme']?.recipe ?? {};
|
|
160
|
+
const styles = { button: recorded.button ?? DEFAULT_RECIPE.button, field: recorded.field ?? DEFAULT_RECIPE.field };
|
|
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;
|
|
101
189
|
}
|
|
102
190
|
|
|
103
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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Types for @halftone-ds/tokens/system (index.mjs): the HalftoneSystem v1 document. */
|
|
2
|
+
import type { Mode, Neutral, Recipe, RecipeInput, Theme, TokensJson } from '../theme/index.mjs';
|
|
3
|
+
|
|
4
|
+
export type ColorRole = 'brand' | 'neutral' | 'danger' | 'success' | 'warning' | 'info';
|
|
5
|
+
export type StatusRole = 'danger' | 'success' | 'warning' | 'info';
|
|
6
|
+
export type Step = '50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | '950';
|
|
7
|
+
/** The neutral also has '0' (white) and '1000' (black). */
|
|
8
|
+
export type NeutralStep = Step | '0' | '1000';
|
|
9
|
+
export type TextStyleName = 'h1' | 'h2' | 'h3' | 'h4' | 'lead' | 'p' | 'large' | 'small' | 'muted' | 'code' | 'blockquote';
|
|
10
|
+
export type FontRole = 'heading' | 'body' | 'mono';
|
|
11
|
+
export type RadiusStep = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
12
|
+
export type ShadowLevel = '1' | '2' | '3';
|
|
13
|
+
export type BorderStyle = 'solid' | 'dashed' | 'dotted';
|
|
14
|
+
export type ExtraKind = 'color' | 'text' | 'radius' | 'shadow' | 'border';
|
|
15
|
+
/** A 6-digit lowercase hex colour, '#2563eb'. */
|
|
16
|
+
export type Hex = `#${string}`;
|
|
17
|
+
/** A ramp step ('brand.600', 'neutral.0') or a hex. */
|
|
18
|
+
export type RoleValue = `${ColorRole}.${NeutralStep}` | Hex;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One colour role. brand: its seed is recipe.brand (a seed here folds into it). neutral: a hex seed makes a custom
|
|
22
|
+
* neutral; a preset name folds into recipe.neutral. A status role without a seed keeps the base file's ramp.
|
|
23
|
+
*/
|
|
24
|
+
export type ColorOverride = {
|
|
25
|
+
seed?: Hex;
|
|
26
|
+
/** The step the seed is pinned to, when not its nearest one. */
|
|
27
|
+
anchor?: Step;
|
|
28
|
+
/** Steps edited by hand. */
|
|
29
|
+
steps?: Partial<Record<NeutralStep, Hex>>;
|
|
30
|
+
};
|
|
31
|
+
export type RoleOverride = { light?: RoleValue; dark?: RoleValue };
|
|
32
|
+
/** size px, leading × size, tracking em. */
|
|
33
|
+
export type TextStyle = { font: FontRole; weight: number; size: number; leading: number; tracking: number };
|
|
34
|
+
export type ShadowLayer = { x: number; y: number; blur: number; spread: number; color: Hex; alpha: number; inset?: true };
|
|
35
|
+
export type Shadow = Record<Mode, ShadowLayer[]>;
|
|
36
|
+
export type BorderOverride = { width?: number; style?: BorderStyle };
|
|
37
|
+
|
|
38
|
+
export type ExtraColor = { name: string } & ({ value: Hex | Record<Mode, Hex> } | { seed: Hex; anchor?: Step; steps?: Partial<Record<Step, Hex>> });
|
|
39
|
+
export type ExtraText = { name: string } & TextStyle;
|
|
40
|
+
export type ExtraRadius = { name: string; value: number };
|
|
41
|
+
export type ExtraShadow = { name: string } & Shadow;
|
|
42
|
+
export type ExtraBorder = { name: string; width: number; style?: BorderStyle };
|
|
43
|
+
export type Extras = { color: ExtraColor[]; text: ExtraText[]; radius: ExtraRadius[]; shadow: ExtraShadow[]; border: ExtraBorder[] };
|
|
44
|
+
|
|
45
|
+
/** As the docs site's brand store keeps it (apps/docs lib/brand/svg.ts). */
|
|
46
|
+
export type BrandImage = { svg?: string; png?: string; mono?: boolean; aspect: number };
|
|
47
|
+
export type IconBackground = 'brand' | 'white' | 'black' | Hex;
|
|
48
|
+
export type SystemBrand = {
|
|
49
|
+
name: string;
|
|
50
|
+
iconBackground: IconBackground;
|
|
51
|
+
logo?: BrandImage;
|
|
52
|
+
logoDark?: BrandImage;
|
|
53
|
+
mark?: BrandImage;
|
|
54
|
+
markDark?: BrandImage;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A normalised system: every section present, overrides only. */
|
|
58
|
+
export type HalftoneSystem = {
|
|
59
|
+
kind: 'halftone.system';
|
|
60
|
+
v: 1;
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
preset?: string;
|
|
64
|
+
updatedAt?: string;
|
|
65
|
+
recipe: Recipe;
|
|
66
|
+
colors: Partial<Record<ColorRole, ColorOverride>>;
|
|
67
|
+
roles: Partial<Record<string, RoleOverride>>;
|
|
68
|
+
type: Partial<Record<TextStyleName, Partial<TextStyle>>>;
|
|
69
|
+
/** Any Google Fonts family. */
|
|
70
|
+
fonts: Partial<Record<FontRole, string>>;
|
|
71
|
+
radius: Partial<Record<RadiusStep, number>>;
|
|
72
|
+
shadow: Partial<Record<ShadowLevel, Shadow>>;
|
|
73
|
+
border: BorderOverride;
|
|
74
|
+
extras: Extras;
|
|
75
|
+
brand: SystemBrand;
|
|
76
|
+
/** Component-level edits (Button, Chip, Badge, Input), overrides only. Button and Input styles fold into the recipe. */
|
|
77
|
+
components: SystemComponents;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A component colour: a role (any semantic colour), or 'none' for a fill or border. */
|
|
81
|
+
export type ComponentEdit = {
|
|
82
|
+
style?: string;
|
|
83
|
+
/** A Foundations corner, or px. */
|
|
84
|
+
radius?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | number;
|
|
85
|
+
size?: Partial<Record<'sm' | 'md' | 'lg' | 'x', number>>;
|
|
86
|
+
/** A text style (h1…blockquote). */
|
|
87
|
+
text?: string;
|
|
88
|
+
variants?: Record<string, Partial<Record<'fill' | 'label' | 'border' | 'pressed', string>>>;
|
|
89
|
+
};
|
|
90
|
+
export type SystemComponents = Partial<Record<'button' | 'chip' | 'badge' | 'input', ComponentEdit>>;
|
|
91
|
+
|
|
92
|
+
/** What normalizeSystem accepts: any section may be missing, a shadow may be a plain layer list, a layer partial. */
|
|
93
|
+
export type HalftoneSystemInput = Partial<Omit<HalftoneSystem, 'recipe' | 'colors' | 'shadow' | 'extras' | 'brand' | 'components' | 'kind' | 'v'>> & {
|
|
94
|
+
kind?: 'halftone.system';
|
|
95
|
+
v?: number;
|
|
96
|
+
recipe?: RecipeInput;
|
|
97
|
+
colors?: Partial<Record<Exclude<ColorRole, 'neutral'>, ColorOverride>> & { neutral?: Omit<ColorOverride, 'seed'> & { seed?: Hex | Neutral } };
|
|
98
|
+
shadow?: Partial<Record<ShadowLevel, Partial<ShadowLayer>[] | { light?: Partial<ShadowLayer>[]; dark?: Partial<ShadowLayer>[] }>>;
|
|
99
|
+
extras?: Partial<Record<ExtraKind, Array<Record<string, unknown> & { name: string }>>>;
|
|
100
|
+
brand?: Partial<SystemBrand>;
|
|
101
|
+
components?: SystemComponents;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** A contrast pairing the fixer left failing because a colour in it was chosen by hand. */
|
|
105
|
+
export type ContrastWarning = { mode: Mode; label: string; fg: string; bg: string; ratio: number; threshold: number; locked: string[] };
|
|
106
|
+
|
|
107
|
+
/** A colour role's ramp as the Studio draws it. */
|
|
108
|
+
export type SystemRamp = {
|
|
109
|
+
/** The seed (a neutral preset's name for a preset neutral). */
|
|
110
|
+
seed: string;
|
|
111
|
+
/** The step the seed sits on (null for a preset neutral). */
|
|
112
|
+
anchor: Step | null;
|
|
113
|
+
source: 'recipe' | 'seed' | 'base';
|
|
114
|
+
/** Before edits. */
|
|
115
|
+
generated: Record<string, string>;
|
|
116
|
+
/** After edits. */
|
|
117
|
+
values: Record<string, string>;
|
|
118
|
+
edited: string[];
|
|
119
|
+
};
|
|
120
|
+
export type ResolvedTextStyle = TextStyle & { edited: (keyof TextStyle)[] };
|
|
121
|
+
|
|
122
|
+
export const SYSTEM_KIND: 'halftone.system';
|
|
123
|
+
export const SYSTEM_VERSION: 1;
|
|
124
|
+
export const COLOR_ROLES: ColorRole[];
|
|
125
|
+
export const STATUS_ROLES: StatusRole[];
|
|
126
|
+
/** Every semantic colour a role mapping can set. */
|
|
127
|
+
export const ROLE_NAMES: string[];
|
|
128
|
+
export const TEXT_STYLES: TextStyleName[];
|
|
129
|
+
export const TEXT_KEYS: (keyof TextStyle)[];
|
|
130
|
+
export const TEXT_DEFAULTS: Record<TextStyleName, Omit<TextStyle, 'leading'> & { leading: number | 'prose' }>;
|
|
131
|
+
export const FONT_ROLES: FontRole[];
|
|
132
|
+
export const RADIUS_STEPS: RadiusStep[];
|
|
133
|
+
export const SHADOW_LEVELS: ShadowLevel[];
|
|
134
|
+
export const BORDER_STYLES: BorderStyle[];
|
|
135
|
+
export const EXTRA_KINDS: ExtraKind[];
|
|
136
|
+
|
|
137
|
+
export class SystemError extends Error {
|
|
138
|
+
/** Every problem, as 'path: message'. */
|
|
139
|
+
errors: string[];
|
|
140
|
+
}
|
|
141
|
+
export function isSystem(value: unknown): boolean;
|
|
142
|
+
/** Why an extra can't take this name (a built-in slot, a Tailwind class it would shadow, a theme variable, `taken`), or null. */
|
|
143
|
+
export function extraNameProblem(kind: ExtraKind, name: string, taken?: string[]): string | null;
|
|
144
|
+
export function newSystemId(): string;
|
|
145
|
+
export function normalizeSystem(input: HalftoneSystemInput | HalftoneSystem): HalftoneSystem;
|
|
146
|
+
export function validateSystem(input: unknown): { ok: boolean; errors: string[]; system: HalftoneSystem | null };
|
|
147
|
+
/** A new system with a fresh id and the time. */
|
|
148
|
+
export function createSystem(input?: HalftoneSystemInput): HalftoneSystem;
|
|
149
|
+
/** Every override as a path ('colors.danger.seed', 'roles.primary.dark', 'extras.color.sunset'). */
|
|
150
|
+
export function systemOverrides(system: HalftoneSystemInput | HalftoneSystem): string[];
|
|
151
|
+
export function hasOverrides(system: HalftoneSystemInput | HalftoneSystem): boolean;
|
|
152
|
+
export function withoutBrandImages(system: HalftoneSystem): HalftoneSystem;
|
|
153
|
+
|
|
154
|
+
/** The theme for a system: generateTheme's shape; `extra` holds the extras as a DTCG group. */
|
|
155
|
+
export function generateSystem(system: HalftoneSystemInput | HalftoneSystem, base: TokensJson, options?: { fix?: boolean }): Theme & { extra?: Record<string, unknown> };
|
|
156
|
+
/** The ht- code, or null when the system has overrides a code can't carry. */
|
|
157
|
+
export function systemCode(system: HalftoneSystemInput | HalftoneSystem): string | null;
|
|
158
|
+
export function systemRamps(system: HalftoneSystemInput | HalftoneSystem, base: TokensJson): Record<ColorRole, SystemRamp>;
|
|
159
|
+
export function textStyles(system: HalftoneSystemInput | HalftoneSystem): Record<TextStyleName, ResolvedTextStyle>;
|
|
160
|
+
/** rounded-sm…2xl in px. */
|
|
161
|
+
export function systemRadii(system: HalftoneSystemInput | HalftoneSystem, base: TokensJson, theme?: Theme): Record<RadiusStep, number>;
|
|
162
|
+
|
|
163
|
+
/** A system from a recipe (or an ht- code) and the Studio's brand; a document is normalised. */
|
|
164
|
+
export function migrate(input: RecipeInput | string | HalftoneSystemInput, brand?: Partial<SystemBrand>, options?: { id?: string; name?: string; updatedAt?: string }): HalftoneSystem;
|
|
165
|
+
/** The recipe under a system, its code (null with overrides) and the overrides a recipe loses. */
|
|
166
|
+
export function systemToRecipe(system: HalftoneSystemInput | HalftoneSystem): { recipe: Recipe; code: string | null; lost: string[] };
|
|
167
|
+
/** Commit a system into tokens.json; the record keeps the document without brand images. */
|
|
168
|
+
export function applySystem(tokens: TokensJson, system: HalftoneSystemInput | HalftoneSystem): { tokens: TokensJson; theme: Theme; system: HalftoneSystem };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @halftone-ds/tokens/system — the HalftoneSystem document: one design system (a recipe plus everything edited
|
|
3
|
+
* by hand on top of it), as the Studio keeps it and `halftone-tokens theme apply ./name.halftone.json` commits it.
|
|
4
|
+
* Pure ESM with no Node APIs. See schema.mjs for the shape.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
BORDER_STYLES,
|
|
8
|
+
COLOR_ROLES,
|
|
9
|
+
createSystem,
|
|
10
|
+
EXTRA_KINDS,
|
|
11
|
+
extraNameProblem,
|
|
12
|
+
FONT_ROLES,
|
|
13
|
+
hasOverrides,
|
|
14
|
+
isSystem,
|
|
15
|
+
newSystemId,
|
|
16
|
+
normalizeSystem,
|
|
17
|
+
RADIUS_STEPS,
|
|
18
|
+
ROLE_NAMES,
|
|
19
|
+
SHADOW_LEVELS,
|
|
20
|
+
STATUS_ROLES,
|
|
21
|
+
SYSTEM_KIND,
|
|
22
|
+
SYSTEM_VERSION,
|
|
23
|
+
SystemError,
|
|
24
|
+
systemOverrides,
|
|
25
|
+
TEXT_DEFAULTS,
|
|
26
|
+
TEXT_KEYS,
|
|
27
|
+
TEXT_STYLES,
|
|
28
|
+
validateSystem,
|
|
29
|
+
withoutBrandImages,
|
|
30
|
+
} from './schema.mjs';
|
|
31
|
+
export { generateSystem, systemCode, systemRadii, systemRamps, textStyles } from './generate.mjs';
|
|
32
|
+
export { migrate, systemToRecipe } from './migrate.mjs';
|
|
33
|
+
export { applySystem } from '../theme/apply.mjs';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate — a HalftoneSystem from what came before it: a theme recipe (or its ht- code) and the Studio's brand.
|
|
3
|
+
* A document already is cleaned up and brought to the current version. Pure ESM.
|
|
4
|
+
*
|
|
5
|
+
* migrate(recipe | code | system, brand?, { id?, name?, updatedAt? }) → HalftoneSystem
|
|
6
|
+
* systemToRecipe(system) → { recipe, code, lost }: the other way; `lost` lists the overrides a recipe can't
|
|
7
|
+
* hold (code is null when there are any)
|
|
8
|
+
*
|
|
9
|
+
* A recipe migrates to a system with no overrides, so it round-trips: systemToRecipe(migrate(r)).recipe is r, and
|
|
10
|
+
* generateSystem(migrate(r), base) is generateTheme(r, base).
|
|
11
|
+
*/
|
|
12
|
+
import { decodeRecipe, encodeRecipe, isThemeCode } from '../theme/codec.mjs';
|
|
13
|
+
import { presetById } from '../theme/presets.mjs';
|
|
14
|
+
import { normalizeRecipe } from '../theme/recipe.mjs';
|
|
15
|
+
import { isSystem, newSystemId, normalizeSystem, systemOverrides } from './schema.mjs';
|
|
16
|
+
|
|
17
|
+
export function migrate(input, brand, { id, name, updatedAt } = {}) {
|
|
18
|
+
if (isSystem(input)) {
|
|
19
|
+
const doc = normalizeSystem(input);
|
|
20
|
+
return { ...doc, ...(id ? { id } : {}), ...(name ? { name } : {}), ...(updatedAt ? { updatedAt } : {}), ...(brand ? { brand: normalizeSystem({ brand }).brand } : {}) };
|
|
21
|
+
}
|
|
22
|
+
const recipe = typeof input === 'string' && isThemeCode(input) ? decodeRecipe(input) : normalizeRecipe(input ?? {});
|
|
23
|
+
const preset = recipe.preset ? presetById(recipe.preset) : undefined;
|
|
24
|
+
return normalizeSystem({
|
|
25
|
+
id: id ?? newSystemId(),
|
|
26
|
+
name: name ?? (brand?.name?.trim() || preset?.name || 'My system'),
|
|
27
|
+
...(recipe.preset ? { preset: recipe.preset } : {}),
|
|
28
|
+
updatedAt: updatedAt ?? new Date().toISOString(),
|
|
29
|
+
recipe,
|
|
30
|
+
...(brand ? { brand } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function systemToRecipe(input) {
|
|
35
|
+
const doc = normalizeSystem(input);
|
|
36
|
+
const lost = systemOverrides(doc);
|
|
37
|
+
return { recipe: doc.recipe, code: lost.length ? null : encodeRecipe(doc.recipe), lost };
|
|
38
|
+
}
|