@scattered-light/loomiere 0.0.0-stage → 0.1.1

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.
Files changed (73) hide show
  1. package/README.md +60 -2
  2. package/bin/loomiere.mjs +112 -0
  3. package/build/build-tokens.mjs +485 -0
  4. package/exporters/README.md +45 -0
  5. package/exporters/ag-grid-settings.mjs +177 -0
  6. package/exporters/ag-grid.mjs +112 -0
  7. package/exporters/chakra.mjs +630 -0
  8. package/exporters/component-styles.mjs +207 -0
  9. package/exporters/dtcg.mjs +31 -0
  10. package/exporters/figma.mjs +100 -0
  11. package/exporters/index.mjs +38 -0
  12. package/exporters/mui.mjs +666 -0
  13. package/exporters/tailwind.mjs +35 -0
  14. package/exporters/theme.mjs +93 -0
  15. package/generator/brand.mjs +328 -0
  16. package/generator/brand.schema.json +657 -0
  17. package/generator/color.mjs +130 -0
  18. package/generator/data-colors.mjs +104 -0
  19. package/generator/format-brand.mjs +60 -0
  20. package/generator/generate.mjs +43 -0
  21. package/generator/pairs.mjs +236 -0
  22. package/generator/roles.mjs +258 -0
  23. package/generator/vision.mjs +167 -0
  24. package/lib/config.mjs +71 -0
  25. package/lib/contrast.mjs +134 -0
  26. package/lib/lab.mjs +108 -0
  27. package/lib/studio.mjs +127 -0
  28. package/lib/tool-server.mjs +96 -0
  29. package/package.json +51 -4
  30. package/templates/brand.json +104 -0
  31. package/tokens/README.md +219 -0
  32. package/tokens/component/avatar.json +10 -0
  33. package/tokens/component/button.json +12 -0
  34. package/tokens/component/drawer.json +26 -0
  35. package/tokens/component/navigation-menu.json +13 -0
  36. package/tokens/component/popover.json +13 -0
  37. package/tokens/component/preview-card.json +13 -0
  38. package/tokens/component/slider.json +13 -0
  39. package/tokens/component/switch.json +41 -0
  40. package/tokens/component/toast.json +21 -0
  41. package/tokens/component/toggle.json +9 -0
  42. package/tokens/component/toolbar.json +9 -0
  43. package/tokens/component/tooltip.json +9 -0
  44. package/tokens/foundation/layering.json +57 -0
  45. package/tokens/foundation/layout.json +75 -0
  46. package/tokens/foundation/motion.json +41 -0
  47. package/tokens/foundation/type.json +92 -0
  48. package/tokens/semantic/accessibility.json +39 -0
  49. package/tokens/semantic/bar.json +45 -0
  50. package/tokens/semantic/check.json +57 -0
  51. package/tokens/semantic/color.json +270 -0
  52. package/tokens/semantic/content.json +124 -0
  53. package/tokens/semantic/disclosure.json +49 -0
  54. package/tokens/semantic/elevation.json +72 -0
  55. package/tokens/semantic/field.json +77 -0
  56. package/tokens/semantic/focus.json +36 -0
  57. package/tokens/semantic/gauge.json +45 -0
  58. package/tokens/semantic/icon-button.json +33 -0
  59. package/tokens/semantic/icon.json +30 -0
  60. package/tokens/semantic/item.json +65 -0
  61. package/tokens/semantic/modal.json +86 -0
  62. package/tokens/semantic/motion.json +20 -0
  63. package/tokens/semantic/notice.json +37 -0
  64. package/tokens/semantic/popup.json +89 -0
  65. package/tokens/semantic/scheme.json +11 -0
  66. package/tokens/semantic/shape.json +54 -0
  67. package/tokens/semantic/spacing.json +110 -0
  68. package/tokens/semantic/table.json +73 -0
  69. package/tokens/semantic/typography.json +312 -0
  70. package/tools/brand-builder/builder.js +1119 -0
  71. package/tools/brand-builder/index.html +329 -0
  72. package/tools/pair-lab/index.html +119 -0
  73. package/tools/pair-lab/lab.js +249 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Roles: which palette plays which part, and the emphasis ladder components draw from.
3
+ *
4
+ * roles.chrome the palette for surfaces, text and borders (a neutral-table palette)
5
+ * roles.accent the palette for links, focus and the loudest actions (any spot palette)
6
+ * roles.emphasis four levels, each a style and a color:
7
+ * { bold: { style: "solid", color: "accent" }, standard: …, subtle: …, quiet: … }
8
+ * roles.concepts optional picks that replace a role's default step for one concept:
9
+ * { "chrome.fg": { light: "primary.1000" } }. A pick is "palette.step",
10
+ * "white" (paper), "black" (ink) or a hex. Modes left out keep the default.
11
+ *
12
+ * Every palette also gets a scope: the same slots (solid, soft, border, text…) at fixed
13
+ * steps, so any palette can stand in for another. That works because spot palettes share a
14
+ * contrast table, so step 600 has the same contrast in every hue. A page re-colors a part
15
+ * with data-color="secondary"; data-emphasis="subtle" applies a level.
16
+ */
17
+
18
+ /**
19
+ * Slot → [light step, dark step, other modes?], per contrast table. This table is the only
20
+ * place that decides which step a concept uses in light, dark and high contrast; semantic
21
+ * tokens point at slots and never at steps. `on.N` is step N's on-color; `white` and
22
+ * `black` are the brand's paper and ink.
23
+ */
24
+ export const SLOTS = {
25
+ spot: {
26
+ solid: [600, 400],
27
+ 'solid-hover': [700, 300],
28
+ 'solid-active': [800, 200],
29
+ 'on-solid': ['on.600', 'on.400'],
30
+ soft: [50, 1000],
31
+ 'soft-hover': [100, 900],
32
+ 'soft-active': [200, 800],
33
+ 'soft-strong': [300, 700],
34
+ 'on-soft': [800, 100],
35
+ ink: [900, 75],
36
+ border: [600, 400],
37
+ 'border-subtle': [200, 900],
38
+ fg: [700, 200],
39
+ signal: [500, 400],
40
+ focus: [500, 400],
41
+ },
42
+ // Chrome palettes run lighter to darker over more steps; their solid is the inverse surface.
43
+ // They also carry the page concepts: canvas, raised, overlay, modal and tray surfaces, text levels, dividers, tracks.
44
+ neutral: {
45
+ solid: [1100, 100],
46
+ 'solid-hover': [1000, 200],
47
+ 'solid-active': [900, 300],
48
+ 'on-solid': ['on.1100', 'on.100'],
49
+ soft: [200, 900],
50
+ 'soft-hover': [300, 800],
51
+ 'soft-active': [400, 700],
52
+ 'soft-strong': [500, 600],
53
+ 'on-soft': [1200, 50],
54
+ border: [800, 600],
55
+ 'border-hover': [900, 400],
56
+ 'border-subtle': [200, 900],
57
+ 'border-strong': [800, 700, { 'contrast-more': 900, 'dark-contrast-more': 200 }],
58
+ divider: [300, 800, { 'contrast-more': 700, 'dark-contrast-more': 400 }],
59
+ fg: [1200, 'white'],
60
+ 'fg-secondary': [1000, 300, { 'contrast-more': 1100, 'dark-contrast-more': 100 }],
61
+ 'fg-tertiary': [900, 400, { 'contrast-more': 1000 }],
62
+ focus: [800, 400],
63
+ canvas: ['white', 1200],
64
+ raised: [50, 1100],
65
+ overlay: [100, 1000],
66
+ modal: ['white', 1100],
67
+ tray: [50, 1000],
68
+ inverse: [1100, 900],
69
+ 'on-inverse': ['on.1100', 'on.900'],
70
+ // The empty part of a meter or progress bar. Dark mode goes near the page so any palette's fill reads 3:1 on it.
71
+ track: [200, 1100],
72
+ },
73
+ };
74
+
75
+ /** The token a slot step stands for in one palette. */
76
+ export const stepRef = (palette, step) => (step === 'white' || step === 'black' ? `{color.${step}}` : `{color.${palette}.${step}}`);
77
+
78
+ /**
79
+ * The concepts the builder shows as rows, by role. `against` is what the color is read on
80
+ * (text) or sits beside (lines and fills), for the contrast column; `min` is the ratio it
81
+ * should reach there.
82
+ */
83
+ export const CONCEPTS = [
84
+ { role: 'chrome', group: 'Surfaces', slot: 'canvas', label: 'Page', against: 'fg', min: 4.5 },
85
+ { role: 'chrome', group: 'Surfaces', slot: 'raised', label: 'Cards and panels', against: 'fg', min: 4.5 },
86
+ { role: 'chrome', group: 'Surfaces', slot: 'overlay', label: 'Menus, popovers, inline code', against: 'fg', min: 4.5 },
87
+ { role: 'chrome', group: 'Surfaces', slot: 'modal', label: 'Dialogs and drawers', against: 'fg', min: 4.5 },
88
+ { role: 'chrome', group: 'Surfaces', slot: 'tray', label: 'Toolbars and toggle groups', against: 'fg', min: 4.5 },
89
+ { role: 'chrome', group: 'Surfaces', slot: 'inverse', label: 'Inverse surface (tooltips)', against: 'on-inverse', min: 4.5 },
90
+ { role: 'chrome', group: 'Surfaces', slot: 'on-inverse', label: 'Text on the inverse surface', against: 'inverse', min: 4.5 },
91
+ { role: 'chrome', group: 'Text', slot: 'fg', label: 'Body text and prose', against: 'canvas', min: 4.5 },
92
+ { role: 'chrome', group: 'Text', slot: 'fg-secondary', label: 'Secondary text', against: 'canvas', min: 4.5 },
93
+ { role: 'chrome', group: 'Text', slot: 'fg-tertiary', label: 'Tertiary text', against: 'canvas', min: 4.5 },
94
+ { role: 'chrome', group: 'Text', slot: 'soft-strong', label: 'Disabled text', against: 'canvas' },
95
+ { role: 'chrome', group: 'Lines', slot: 'border', label: 'Control borders', against: 'canvas', min: 3 },
96
+ { role: 'chrome', group: 'Lines', slot: 'border-hover', label: 'Control border on hover', against: 'canvas', min: 3 },
97
+ { role: 'chrome', group: 'Lines', slot: 'border-strong', label: 'Strong borders', against: 'canvas', min: 3 },
98
+ { role: 'chrome', group: 'Lines', slot: 'divider', label: 'Dividers', against: 'canvas' },
99
+ { role: 'chrome', group: 'Lines', slot: 'border-subtle', label: 'Hairlines', against: 'canvas' },
100
+ { role: 'chrome', group: 'Fills', slot: 'soft', label: 'Subtle fill', against: 'canvas' },
101
+ { role: 'chrome', group: 'Fills', slot: 'soft-hover', label: 'Default fill', against: 'canvas' },
102
+ { role: 'chrome', group: 'Fills', slot: 'soft-active', label: 'Strong fill, disabled controls', against: 'canvas' },
103
+ { role: 'chrome', group: 'Fills', slot: 'track', label: 'Meter and progress tracks', against: 'canvas' },
104
+ { role: 'chrome', group: 'Fills', slot: 'solid', label: 'Solid chip (keycaps)', against: 'on-solid', min: 4.5 },
105
+ { role: 'chrome', group: 'Fills', slot: 'on-solid', label: 'Text on the solid chip', against: 'solid', min: 4.5 },
106
+ { role: 'accent', group: 'Accent', slot: 'solid', label: 'Buttons, links, focus ring', against: 'canvas', min: 3 },
107
+ { role: 'accent', group: 'Accent', slot: 'solid-hover', label: 'Hover', against: 'canvas', min: 3 },
108
+ { role: 'accent', group: 'Accent', slot: 'solid-active', label: 'Pressed', against: 'canvas', min: 3 },
109
+ { role: 'accent', group: 'Accent', slot: 'on-solid', label: 'Text on accent', against: 'solid', min: 4.5 },
110
+ { role: 'accent', group: 'Accent', slot: 'fg', label: 'Accent text, active tab', against: 'canvas', min: 4.5 },
111
+ { role: 'accent', group: 'Accent', slot: 'focus', label: 'Focus outline', against: 'canvas', min: 3 },
112
+ { role: 'accent', group: 'Tints', slot: 'soft', label: 'Tint', against: 'canvas' },
113
+ { role: 'accent', group: 'Tints', slot: 'soft-hover', label: 'Tint hover, selection, mark', against: 'canvas' },
114
+ { role: 'accent', group: 'Tints', slot: 'soft-active', label: 'Tint pressed', against: 'canvas' },
115
+ { role: 'accent', group: 'Tints', slot: 'soft-strong', label: 'Strong tint', against: 'canvas' },
116
+ { role: 'accent', group: 'Tints', slot: 'on-soft', label: 'Text on a tint, inline code', against: 'soft', min: 4.5 },
117
+ { role: 'accent', group: 'Tints', slot: 'ink', label: 'Text on a strong tint', against: 'soft-strong', min: 4.5 },
118
+ ];
119
+
120
+ /** How each emphasis style reads the scope's slots, part by part. */
121
+ export const STYLES = {
122
+ solid: { bg: 'solid', 'bg-hover': 'solid-hover', 'bg-active': 'solid-active', fg: 'on-solid', border: 'solid', 'border-hover': 'solid-hover' },
123
+ soft: { bg: 'soft', 'bg-hover': 'soft-hover', 'bg-active': 'soft-active', fg: 'on-soft', border: 'soft', 'border-hover': 'soft-hover' },
124
+ outline: { bg: 'transparent', 'bg-hover': 'soft', 'bg-active': 'soft-hover', fg: 'fg', border: 'border', 'border-hover': 'solid-hover' },
125
+ ghost: { bg: 'transparent', 'bg-hover': 'soft', 'bg-active': 'soft-hover', fg: 'fg', border: 'transparent', 'border-hover': 'transparent' },
126
+ };
127
+ export const PARTS = Object.keys(STYLES.solid);
128
+ export const LEVELS = ['bold', 'standard', 'subtle', 'quiet'];
129
+
130
+ export const DEFAULT_ROLES = {
131
+ chrome: 'neutral',
132
+ accent: 'primary',
133
+ emphasis: {
134
+ bold: { style: 'solid', color: 'accent' },
135
+ standard: { style: 'outline', color: 'accent' },
136
+ subtle: { style: 'soft', color: 'chrome' },
137
+ quiet: { style: 'outline', color: 'chrome' },
138
+ },
139
+ };
140
+
141
+ const tableOf = (palette) => (palette.contrast === 'neutral' ? 'neutral' : 'spot');
142
+
143
+ /** The palette name a color choice ("accent", "chrome" or a palette role) stands for. */
144
+ export const paletteFor = (roles, color) => (color === 'accent' ? roles.accent : color === 'chrome' ? roles.chrome : color);
145
+
146
+ /** A brand's roles with every missing setting taken from the defaults. */
147
+ export const withDefaults = (roles = {}) => ({
148
+ ...DEFAULT_ROLES,
149
+ ...roles,
150
+ emphasis: Object.fromEntries(LEVELS.map((level) => [level, { ...DEFAULT_ROLES.emphasis[level], ...roles.emphasis?.[level] }])),
151
+ });
152
+
153
+ /**
154
+ * A concept pick as a token value: "palette.step" and "white"/"black" become references,
155
+ * a hex stays a hex. Returns undefined for anything else.
156
+ */
157
+ export function pickRef(pick, palettes) {
158
+ if (pick === 'white' || pick === 'black') return `{color.${pick}}`;
159
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(pick)) return pick.toLowerCase();
160
+ const m = /^([\w-]+)\.((?:on\.)?\d+)$/.exec(String(pick));
161
+ return m && palettes[m[1]] ? `{color.${m[1]}.${m[2]}}` : undefined;
162
+ }
163
+
164
+ /** A slot's default as a pick, in the same form a brand file writes ("neutral.1200", "white"). */
165
+ export const defaultPick = (palette, step) => (step === 'white' || step === 'black' ? step : `${palette}.${step}`);
166
+
167
+ /**
168
+ * One role concept's values in every mode, as token values, with the brand's picks applied.
169
+ * A pick for light replaces the high-contrast light step too, and dark likewise, since
170
+ * those were chosen to go with the default.
171
+ */
172
+ export function conceptModes(brand, roles, role, slot) {
173
+ const palettes = brand.color.palettes;
174
+ const name = roles[role];
175
+ const [light, dark, more = {}] = SLOTS[tableOf(palettes[name])][slot];
176
+ const pick = roles.concepts?.[`${role}.${slot}`] ?? {};
177
+ const out = {
178
+ light: pick.light !== undefined ? pickRef(pick.light, palettes) : stepRef(name, light),
179
+ dark: pick.dark !== undefined ? pickRef(pick.dark, palettes) : stepRef(name, dark),
180
+ };
181
+ if (more['contrast-more'] !== undefined && pick.light === undefined) out['contrast-more'] = stepRef(name, more['contrast-more']);
182
+ if (more['dark-contrast-more'] !== undefined && pick.dark === undefined) out['dark-contrast-more'] = stepRef(name, more['dark-contrast-more']);
183
+ return out;
184
+ }
185
+
186
+ /** Roles with defaults filled in, checked against the brand's palettes. */
187
+ export function resolveRoles(brand) {
188
+ const roles = withDefaults(brand.roles);
189
+ const palettes = brand.color.palettes;
190
+ const problems = [];
191
+ if (!palettes[roles.chrome]) problems.push(`roles.chrome is "${roles.chrome}", which is not a palette`);
192
+ if (!palettes[roles.accent]) problems.push(`roles.accent is "${roles.accent}", which is not a palette`);
193
+ for (const [level, { style, color }] of Object.entries(roles.emphasis)) {
194
+ if (!STYLES[style]) problems.push(`roles.emphasis.${level}.style "${style}" is not one of ${Object.keys(STYLES).join(', ')}`);
195
+ if (!palettes[paletteFor(roles, color)]) problems.push(`roles.emphasis.${level}.color "${color}" is not accent, chrome or a palette`);
196
+ }
197
+ for (const [key, pick] of Object.entries(roles.concepts ?? {})) {
198
+ const [role, slot] = key.split('.');
199
+ const palette = palettes[roles[role]];
200
+ if (!['chrome', 'accent'].includes(role) || !palette || !SLOTS[tableOf(palette)][slot]) {
201
+ problems.push(`roles.concepts "${key}" is not a chrome or accent concept`);
202
+ continue;
203
+ }
204
+ for (const [mode, value] of Object.entries(pick)) {
205
+ if (!['light', 'dark'].includes(mode)) problems.push(`roles.concepts.${key}.${mode}: only light and dark can be picked`);
206
+ else if (pickRef(value, palettes) === undefined) problems.push(`roles.concepts.${key}.${mode} "${value}" is not palette.step, white, black or a hex`);
207
+ }
208
+ }
209
+ if (problems.length) throw new Error(`Roles: ${problems.join('; ')}`);
210
+ return roles;
211
+ }
212
+
213
+ const token = (type, value, description, modes) => ({
214
+ $type: type,
215
+ $value: value,
216
+ ...(description && { $description: description }),
217
+ ...(modes && { $extensions: { 'sl.modes': modes } }),
218
+ });
219
+
220
+ /**
221
+ * tokens/semantic/scope.json and emphasis.json:
222
+ * scope.<palette>.<slot> every palette's slots, plus scope.accent and scope.chrome
223
+ * emphasis.<level>.<part> each level's colors at its default palette
224
+ */
225
+ export function generateRoleTokens(brand) {
226
+ const roles = resolveRoles(brand);
227
+ const scope = {};
228
+ for (const [name, palette] of Object.entries(brand.color.palettes)) {
229
+ scope[name] = {};
230
+ for (const [slot, [light, dark, more = {}]] of Object.entries(SLOTS[tableOf(palette)])) {
231
+ const modes = { dark: stepRef(name, dark), ...Object.fromEntries(Object.entries(more).map(([mode, step]) => [mode, stepRef(name, step)])) };
232
+ scope[name][slot] = token('color', stepRef(name, light), `${name} ${slot}: ${light} in light, ${dark} in dark`, modes);
233
+ }
234
+ }
235
+ for (const role of ['accent', 'chrome']) {
236
+ const name = roles[role];
237
+ scope[role] = Object.fromEntries(
238
+ Object.keys(scope[name]).map((slot) => {
239
+ if (!roles.concepts?.[`${role}.${slot}`]) return [slot, token('color', `{scope.${name}.${slot}}`, `The ${role} role is ${name}.`)];
240
+ // A picked concept no longer follows the palette's slot, so it carries its own modes.
241
+ const { light, ...modes } = conceptModes(brand, roles, role, slot);
242
+ return [slot, token('color', light, `${role} ${slot}, picked in the brand file (roles.concepts).`, modes)];
243
+ }),
244
+ );
245
+ }
246
+
247
+ const emphasis = {};
248
+ for (const level of LEVELS) {
249
+ const { style, color } = roles.emphasis[level];
250
+ const palette = paletteFor(roles, color);
251
+ emphasis[level] = {};
252
+ for (const [part, slot] of Object.entries(STYLES[style])) {
253
+ const value = slot === 'transparent' ? 'transparent' : `{scope.${color}.${slot}}`;
254
+ emphasis[level][part] = token('color', value, `${level}: ${style} in ${color === palette ? palette : `${color} (${palette})`}`);
255
+ }
256
+ }
257
+ return { scope: { scope }, emphasis: { emphasis }, roles };
258
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Color vision themes: versions of the alert and data colors designed for each kind of
3
+ * color blindness, generated from the brand's own colors (so every brand gets its own).
4
+ *
5
+ * Every spot palette (brand colors and alerts; the neutral stays) moves until the colors
6
+ * that stand for them are as far apart as possible as seen with that kind of vision,
7
+ * without wandering further from the brand than they need to; brand colors cost more to
8
+ * move than alerts. For the three color blindnesses they turn hue and keep lightness and
9
+ * chroma; with no color vision at all hue means nothing, so they move lightness instead. Either way each
10
+ * ramp is rebuilt on the same contrast table, so every step keeps its contrast. Data
11
+ * colors are nudged, in series order, until each is distinct from the two before and
12
+ * after it as seen with that vision, and never lose contrast on paper.
13
+ *
14
+ * A brand can pin any generated color: color.vision.deuteranopia.keys.primary = "#…" or
15
+ * color.vision.deuteranopia.data.coral = "#…"; the others move to make room.
16
+ */
17
+ import { differenceCiede2000, formatHex, oklch, wcagContrast } from 'culori';
18
+
19
+ import { dataColorPasses, nudgeDataColor, VISIONS } from './data-colors.mjs';
20
+
21
+ const turnHue = {
22
+ cost: 0.05,
23
+ amounts: range(-60, 60, 5),
24
+ // An alert turned further than this stops reading as its kind (a green success turned yellow).
25
+ alertLimit: 40,
26
+ move: (c, dh) => ({ ...c, h: ((c.h ?? 0) + dh + 360) % 360 }),
27
+ between: (from, to) => ((((to.h ?? 0) - (from.h ?? 0)) % 360) + 540) % 360 - 180,
28
+ };
29
+ // Lightness only darkens, so no color loses contrast on paper.
30
+ const shiftLightness = {
31
+ cost: 5,
32
+ amounts: range(-0.4, 0, 0.02),
33
+ move: (c, dl) => ({ ...c, l: Math.min(0.97, Math.max(0.2, c.l + dl)) }),
34
+ between: (from, to) => to.l - from.l,
35
+ };
36
+
37
+ /** Most common first. `sees` is the simulation the theme is designed against. */
38
+ export const VISION_THEMES = {
39
+ deuteranopia: { name: 'Deuteranopia', label: 'green-blind, the most common', sees: 'deutan', moves: turnHue },
40
+ protanopia: { name: 'Protanopia', label: 'red-blind', sees: 'protan', moves: turnHue },
41
+ tritanopia: { name: 'Tritanopia', label: 'blue-blind, rare', sees: 'tritan', moves: turnHue },
42
+ achromatopsia: { name: 'Achromatopsia', label: 'no color vision, or a grayscale screen', sees: 'achromat', moves: shiftLightness },
43
+ };
44
+ export const ALERT_ROLES = ['success', 'warning', 'error', 'info'];
45
+ /** Brand colors carry identity, so they cost four times as much to move as alerts. */
46
+ const BRAND_WEIGHT = 4;
47
+
48
+ /** ΔE neighbors need in a vision theme: more than the everyday check, since it is the point. */
49
+ const THEME_DISTINCT = 15;
50
+ /** ΔE at which two colors are plainly different; further apart gains nothing. */
51
+ const ENOUGH = 25;
52
+ /** ΔE of lost difference past which a pair counts as blurred together. */
53
+ const BLURRED = 8;
54
+ function range(from, to, step) {
55
+ const out = [];
56
+ for (let x = from; x <= to + 1e-9; x += step) out.push(Math.round(x * 1000) / 1000);
57
+ return out;
58
+ }
59
+
60
+ const ΔE = differenceCiede2000();
61
+ const moveHex = (moves, hex, amount) => formatHex(moves.move(oklch(hex), amount));
62
+
63
+ /**
64
+ * What a vision loses: for each pair of colors, how much of the difference typical vision
65
+ * sees (up to ENOUGH) is gone when seen with `sees`. Pairs that look alike to everyone
66
+ * (an ember brand color and an error red) lose nothing, so nothing pushes them apart.
67
+ */
68
+ function losses(hexes, sees) {
69
+ const seen = hexes.map((h) => VISIONS[sees].see(h));
70
+ const out = [];
71
+ for (let a = 0; a < hexes.length; a++) for (let b = a + 1; b < hexes.length; b++) {
72
+ const wanted = Math.min(ΔE(hexes[a], hexes[b]), ENOUGH);
73
+ out.push(Math.max(0, wanted - ΔE(seen[a], seen[b])));
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** Pairs that blur together: they lose more than BLURRED of their difference. */
79
+ const blurred = (hexes, sees) => losses(hexes, sees).filter((l) => l > BLURRED).length;
80
+
81
+ /**
82
+ * How far to move each palette (degrees of hue, or lightness). Coordinate search: move one
83
+ * role at a time to the amount that most reduces what the vision loses (squared, so the
84
+ * worst pair counts most), plus the cost of moving, until nothing moves. Pinned roles sit
85
+ * where they were pinned.
86
+ */
87
+ function paletteMoves(keys, sees, moves, pinned) {
88
+ const roles = Object.keys(keys);
89
+ const amount = Object.fromEntries(roles.map((r) => [r, 0]));
90
+ const weight = (r) => (ALERT_ROLES.includes(r) ? 1 : BRAND_WEIGHT);
91
+ const hexes = () => roles.map((r) => pinned[r] ?? moveHex(moves, keys[r], amount[r]));
92
+ const score = () =>
93
+ -losses(hexes(), sees).reduce((sum, l) => sum + l * l, 0) / ENOUGH -
94
+ moves.cost * roles.reduce((total, r) => total + weight(r) * Math.abs(amount[r]), 0);
95
+ for (let round = 0; round < 6; round++) {
96
+ let moved = false;
97
+ for (const r of roles) {
98
+ if (pinned[r]) continue;
99
+ let best = amount[r], bestScore = score();
100
+ for (const a of moves.amounts) {
101
+ if (moves.alertLimit && ALERT_ROLES.includes(r) && Math.abs(a) > moves.alertLimit) continue;
102
+ amount[r] = a;
103
+ const s = score();
104
+ if (s > bestScore + 1e-6) [best, bestScore] = [a, s];
105
+ }
106
+ if (best !== amount[r]) moved = true;
107
+ amount[r] = best;
108
+ }
109
+ if (!moved) break;
110
+ }
111
+ return amount;
112
+ }
113
+
114
+ /** The key that stands for a palette: its most colorful one. */
115
+ const representative = (keys) => keys.reduce((a, b) => ((oklch(b).c ?? 0) > (oklch(a).c ?? 0) ? b : a));
116
+
117
+ /**
118
+ * Per theme: every spot palette with moved keys (the neutral stays), the data colors, the
119
+ * color that stands for each palette, and how many pairs of those blur together for that
120
+ * vision before and after. Pins come from color.vision.<theme>: keys.<role> (that palette's
121
+ * representative color) and data.<name>.
122
+ */
123
+ let last = { key: '', value: null };
124
+
125
+ export function visionColors(color) {
126
+ // The builder recomputes on every keystroke; only the palettes, data and paper matter here.
127
+ const key = JSON.stringify([color.paper, color.data, color.vision, color.palettes]);
128
+ if (key === last.key) return last.value;
129
+ const out = {};
130
+ const roles = Object.keys(color.palettes).filter((r) => color.palettes[r].keys?.length && color.palettes[r].contrast !== 'neutral');
131
+ const reps = Object.fromEntries(roles.map((r) => [r, representative(color.palettes[r].keys)]));
132
+ for (const [theme, { sees, moves }] of Object.entries(VISION_THEMES)) {
133
+ const pinned = color.vision?.[theme]?.keys ?? {};
134
+ const amounts = paletteMoves(reps, sees, moves, pinned);
135
+ const palettes = {};
136
+ const keys = {};
137
+ for (const r of roles) {
138
+ const p = color.palettes[r];
139
+ // A pin moves the whole palette by as much as its representative moved.
140
+ const amount = pinned[r] ? moves.between(oklch(reps[r]), oklch(pinned[r])) : amounts[r];
141
+ const move = (k) => (k === reps[r] && pinned[r] ? pinned[r] : moveHex(moves, k, amount));
142
+ palettes[r] = { ...p, keys: p.keys.map(move), ...(p.darkest && { darkest: moveHex(moves, p.darkest, amount) }) };
143
+ keys[r] = move(reps[r]);
144
+ }
145
+
146
+ const dataPins = color.vision?.[theme]?.data ?? {};
147
+ const data = color.data.map(([name, hex]) => [name, dataPins[name] ?? hex]);
148
+ const rules = { visions: [sees], distinct: THEME_DISTINCT, reach: 2 };
149
+ for (let i = 0; i < data.length; i++) {
150
+ if (dataPins[data[i][0]]) continue;
151
+ // Never lose contrast on paper: a light color may darken, never the other way.
152
+ const goal = wcagContrast(data[i][1], color.paper);
153
+ if (dataColorPasses(data, i, color.paper, goal, rules)) continue;
154
+ data[i][1] = nudgeDataColor(data, i, color.paper, goal, rules) ?? data[i][1];
155
+ }
156
+
157
+ out[theme] = {
158
+ palettes,
159
+ keys,
160
+ brandKeys: reps,
161
+ data,
162
+ blurred: { before: blurred(Object.values(reps), sees), after: blurred(Object.values(keys), sees) },
163
+ };
164
+ }
165
+ last = { key, value: out };
166
+ return out;
167
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a project's brand lives and where its theme goes. Read from loomiere.config.json in
3
+ * the project folder, with command-line flags on top:
4
+ *
5
+ * {
6
+ * "brand": "brand/portfolio.json", the brand file (default: the only one in brand/)
7
+ * "out": "src/theme", where the theme is written (default: theme)
8
+ * "prefix": "sl", CSS variable prefix; loomiere-base-ui reads --sl-
9
+ * "exports": ["ag-grid", "tailwind"] which framework files to write (default: all)
10
+ * }
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ import { overridesFileFor } from '../generator/generate.mjs';
17
+
18
+ export const CONFIG_FILE = 'loomiere.config.json';
19
+
20
+ const ownSchema = fileURLToPath(new URL('../generator/brand.schema.json', import.meta.url));
21
+
22
+ /**
23
+ * The $schema path a brand file in `brandDir` should use: through the project's
24
+ * node_modules when the engine is installed there, so the path survives reinstalls.
25
+ */
26
+ export function schemaPathFor(brandDir, cwd) {
27
+ const installed = path.join(cwd, 'node_modules', '@scattered-light', 'loomiere', 'generator', 'brand.schema.json');
28
+ const target = fs.existsSync(installed) ? installed : ownSchema;
29
+ return path.relative(brandDir, target).split(path.sep).join('/');
30
+ }
31
+
32
+ /** The project's brand files (not their .overrides.json), relative to cwd. */
33
+ export function brandFiles(dir) {
34
+ if (!fs.existsSync(dir)) return [];
35
+ return fs
36
+ .readdirSync(dir)
37
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.overrides.json'))
38
+ .map((f) => path.join(dir, f));
39
+ }
40
+
41
+ export function resolveConfig(flags = {}, cwd = process.cwd()) {
42
+ const file = path.join(cwd, CONFIG_FILE);
43
+ const saved = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
44
+ const pick = (key, fallback) => flags[key] ?? saved[key] ?? fallback;
45
+
46
+ let brand = pick('brand');
47
+ if (!brand) {
48
+ const found = brandFiles(path.join(cwd, 'brand'));
49
+ if (found.length !== 1) {
50
+ throw new Error(
51
+ found.length
52
+ ? `Found ${found.length} brand files in brand/. Say which one with --brand or "brand" in ${CONFIG_FILE}.`
53
+ : `No brand file yet. Run loomiere init <name> to make one, or set "brand" in ${CONFIG_FILE}.`,
54
+ );
55
+ }
56
+ brand = found[0];
57
+ }
58
+ const brandFile = path.resolve(cwd, brand);
59
+ if (!fs.existsSync(brandFile)) throw new Error(`No brand file at ${path.relative(cwd, brandFile)}.`);
60
+ const outDir = path.resolve(cwd, pick('out', 'theme'));
61
+ const exports = pick('exports', 'all');
62
+ return {
63
+ cwd,
64
+ brandFile,
65
+ overridesFile: overridesFileFor(brandFile),
66
+ outDir,
67
+ generatedDir: path.join(outDir, 'generated'),
68
+ prefix: pick('prefix', 'sl'),
69
+ exports: exports === 'all' ? 'all' : [].concat(exports),
70
+ };
71
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Checks the semantic color pairs in every mode against WCAG.
3
+ *
4
+ * Palettes are the same in every mode, but semantic tokens point at different steps in
5
+ * dark and high-contrast mode, so each pair is resolved per mode from the theme's tokens.json.
6
+ * Brand pairs (color.pairs in the brand file) are checked too, in light and dark: the
7
+ * text, the accent, the button label on hover and press, and text on the tinted parts.
8
+ * loomiere build runs it after every build and fails if anything falls short; loomiere check
9
+ * runs it on its own.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { wcagContrast } from 'culori';
14
+
15
+ import { resolvePair } from '../generator/pairs.mjs';
16
+
17
+ export const MODES = ['light', 'dark', 'contrast-more', 'dark-contrast-more'];
18
+ const fallback = { dark: 'light', 'contrast-more': 'light', 'dark-contrast-more': 'dark' };
19
+
20
+ /**
21
+ * A token's value in a mode. Dark + contrast falls back to dark, then light. A plain alias
22
+ * ({other.token}) follows the other token into the same mode, the way var() does in CSS.
23
+ */
24
+ function valueIn(byPath, tokenPath, mode) {
25
+ const t = byPath.get(tokenPath);
26
+ if (!t) throw new Error(`Unknown token ${tokenPath}`);
27
+ for (let m = mode; m && m !== 'light'; m = fallback[m]) {
28
+ if (t.modes?.[m] === undefined) continue;
29
+ const modeAlias = typeof t.modeReferences?.[m] === 'string' && t.modeReferences[m].match(/^\{([^}]+)\}$/);
30
+ return modeAlias ? valueIn(byPath, modeAlias[1], mode) : t.modes[m];
31
+ }
32
+ const alias = typeof t.references === 'string' && t.references.match(/^\{([^}]+)\}$/);
33
+ return alias ? valueIn(byPath, alias[1], mode) : t.value;
34
+ }
35
+
36
+ const SURFACES = ['surface.canvas', 'surface.raised', 'surface.overlay', 'surface.modal', 'surface.tray'];
37
+ const TEXT = 4.5;
38
+ const UI = 3;
39
+
40
+ /** [foreground, background, minimum ratio, what it is] */
41
+ const PAIRS = [
42
+ ...['text.primary', 'text.secondary', 'text.tertiary', 'text.accent'].flatMap((fg) =>
43
+ SURFACES.map((bg) => [fg, bg, TEXT, 'text']),
44
+ ),
45
+ ...['interactive.default', 'interactive.hover', 'interactive.active'].map((bg) => ['text.on-primary', bg, TEXT, 'text on a button']),
46
+ ['content.link-color', 'surface.canvas', TEXT, 'link text'],
47
+ ['content.link-hover-color', 'surface.canvas', TEXT, 'link text'],
48
+ ['content.code-text', 'content.code-background', TEXT, 'code text'],
49
+ ['content.kbd-text', 'content.kbd-background', TEXT, 'keycap text'],
50
+ ['content.selection-text', 'content.selection-background', TEXT, 'selected text'],
51
+ ['content.prose-color', 'content.page-background', TEXT, 'prose'],
52
+ ['content.prose-color', 'content.mark-background', TEXT, 'marked text'],
53
+ ['text.on-inverse-surface', 'surface.inverse', TEXT, 'tooltip text'],
54
+ ['text.on-inverse', 'fill.inverse', TEXT, 'keycap text'],
55
+ ['text.on-tint', 'interactive.tint', TEXT, 'text on a tint'],
56
+ ['text.on-tint-strong', 'interactive.tint-strong', TEXT, 'text on a strong tint'],
57
+ ...['error', 'warning', 'success', 'info'].map((s) => [`status.${s}-dark`, `status.${s}-light`, TEXT, 'alert text']),
58
+ ...['error', 'warning', 'success', 'info'].map((s) => [`scope.${s}.on-soft`, `scope.${s}.soft`, TEXT, 'tinted toast text']),
59
+ ...['accent', 'primary', 'secondary', 'tertiary', 'quaternary', 'error', 'warning', 'success', 'info'].map((s) => [`scope.${s}.solid`, 'gauge.track-color', UI, 'meter fill on its track']),
60
+ ['table.color', 'table.background', TEXT, 'grid cell text'],
61
+ ['table.subtle-color', 'table.background', TEXT, 'grid secondary text'],
62
+ ['table.header-color', 'table.header-background', TEXT, 'grid header text'],
63
+ ['table.subtle-color', 'table.header-background', TEXT, 'grid side bar text'],
64
+ ['table.color', 'table.row-hover', TEXT, 'grid text on a hovered row'],
65
+ ['table.color', 'table.row-selected', TEXT, 'grid text on a selected row'],
66
+ ['table.color', 'table.range-background', TEXT, 'grid text in a range selection'],
67
+ ['table.range-border', 'table.background', UI, 'focused cell edge'],
68
+ ['table.range-border', 'table.row-selected', UI, 'focused cell edge on a selected row'],
69
+ ['interactive.default', 'surface.canvas', UI, 'control fill'],
70
+ ['border.strong', 'surface.canvas', UI, 'control border'],
71
+ ['border.control', 'surface.canvas', UI, 'control border'],
72
+ ['border.control-hover', 'surface.canvas', UI, 'control border'],
73
+ ['focus.ring', 'surface.canvas', UI, 'focus ring'],
74
+ ['focus.ring', 'surface.raised', UI, 'focus ring'],
75
+ ['scope.chrome.focus', 'surface.canvas', UI, 'neutral focus ring'],
76
+ ['scope.accent.focus', 'surface.canvas', UI, 'accent focus ring'],
77
+ ];
78
+
79
+ /** Checks a built theme: { rows, failures }, one row per pair and mode. */
80
+ export function checkContrast(outDir, brandFile) {
81
+ const { tokens } = JSON.parse(fs.readFileSync(path.join(outDir, 'tokens.json'), 'utf8'));
82
+ const byPath = new Map(tokens.map((t) => [t.path, t]));
83
+ const failures = [];
84
+ const rows = [];
85
+ for (const mode of MODES) {
86
+ for (const [fg, bg, min, kind] of PAIRS) {
87
+ const a = valueIn(byPath, fg, mode);
88
+ const b = valueIn(byPath, bg, mode);
89
+ const ratio = wcagContrast(a, b);
90
+ const row = { mode, fg, bg, kind, min, ratio: Number(ratio.toFixed(2)), a, b };
91
+ rows.push(row);
92
+ if (ratio < min) failures.push(row);
93
+ }
94
+ }
95
+
96
+ // Palettes are the same in every mode, so a pair resolves from the light palette values.
97
+ const brandPairs = JSON.parse(fs.readFileSync(brandFile, 'utf8')).color.pairs ?? {};
98
+ const hexOf = (tokenPath) => byPath.get(tokenPath)?.value;
99
+ for (const [name, { dark, ...spec }] of Object.entries(brandPairs)) {
100
+ for (const [mode, modeSpec] of [['light', spec], ...(dark ? [['dark', { ...spec, ...dark }]] : [])]) {
101
+ const { parts } = resolvePair(modeSpec, hexOf);
102
+ const checks = [
103
+ ['fg', 'bg', 'text'],
104
+ ['accent', 'bg', 'accent text'],
105
+ ['bg', 'hover', 'button label on hover'],
106
+ ['bg', 'active', 'button label pressed'],
107
+ ['fg', 'subtle', 'text on a highlight'],
108
+ ['fg', 'emphasis', 'text on a selection'],
109
+ ['fg', 'muted', 'code text'],
110
+ ];
111
+ for (const [fg, bg, kind] of checks) {
112
+ const ratio = wcagContrast(parts[fg], parts[bg]);
113
+ const row = { mode, fg: `pair.${name}.${fg}`, bg: `pair.${name}.${bg}`, kind: `pair ${kind}`, min: TEXT, ratio: Number(ratio.toFixed(2)), a: parts[fg], b: parts[bg] };
114
+ rows.push(row);
115
+ if (ratio < TEXT) failures.push(row);
116
+ }
117
+ }
118
+ }
119
+
120
+ return { rows, failures };
121
+ }
122
+
123
+ /** Prints a check's result; returns false when something falls short. */
124
+ export function reportContrast({ rows, failures }) {
125
+ if (failures.length) {
126
+ console.error(`Contrast check: ${failures.length} of ${rows.length} pairs fall short.`);
127
+ for (const f of failures) {
128
+ console.error(` ${f.mode.padEnd(18)} ${f.fg} on ${f.bg}: ${f.ratio}:1, needs ${f.min}:1 (${f.kind})`);
129
+ }
130
+ return false;
131
+ }
132
+ console.log(`Contrast check passed: ${rows.length} pairs across ${MODES.length} modes, brand pairs included.`);
133
+ return true;
134
+ }