@threadlabs/looma-docs 0.0.0-stage → 0.52.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -2
  3. package/bin/looma-docs.mjs +3 -0
  4. package/docs/README.md +89 -0
  5. package/docs/card.html +79 -0
  6. package/docs/card.js +18 -0
  7. package/docs/catalog-view.js +24 -0
  8. package/docs/catalog-view.mjs +13 -0
  9. package/docs/catalog.mjs +135 -0
  10. package/docs/cli.mjs +26 -0
  11. package/docs/commands.mjs +94 -0
  12. package/docs/customizer-render.mjs +157 -0
  13. package/docs/customizer.js +494 -0
  14. package/docs/documentation.css +42 -0
  15. package/docs/example-invocations.mjs +63 -0
  16. package/docs/fonts.mjs +73 -0
  17. package/docs/framework-examples.d.mts +9 -0
  18. package/docs/framework-examples.mjs +225 -0
  19. package/docs/guides/01.theme-your-system.md +64 -0
  20. package/docs/guides/02.add-your-components.md +96 -0
  21. package/docs/guides/03.publish-your-system.md +71 -0
  22. package/docs/guides/04.theme-decisions.md +141 -0
  23. package/docs/guides/05.(Theme reference)/01.theme-colors.md +95 -0
  24. package/docs/guides/05.(Theme reference)/02.theme-typography.md +73 -0
  25. package/docs/guides/05.(Theme reference)/03.theme-spacing.md +103 -0
  26. package/docs/guides/05.(Theme reference)/04.theme-material.md +90 -0
  27. package/docs/guides/05.(Theme reference)/05.theme-motion.md +57 -0
  28. package/docs/guides/assets/fellowship-dark.png +0 -0
  29. package/docs/guides/assets/fellowship-light.png +0 -0
  30. package/docs/index.d.ts +59 -0
  31. package/docs/index.mjs +4 -0
  32. package/docs/local-server.mjs +65 -0
  33. package/docs/navigation.mjs +40 -0
  34. package/docs/palette.mjs +68 -0
  35. package/docs/pastoral.svg +54 -0
  36. package/docs/portrait.svg +15 -0
  37. package/docs/reference.html +1 -0
  38. package/docs/reference.js +36 -0
  39. package/docs/render.mjs +97 -0
  40. package/docs/site.mjs +244 -0
  41. package/docs/store.mjs +92 -0
  42. package/docs/theme-session.mjs +72 -0
  43. package/docs/theme.mjs +669 -0
  44. package/docs/value-editors.mjs +37 -0
  45. package/docs/workspace.mjs +32 -0
  46. package/package.json +53 -3
package/docs/theme.mjs ADDED
@@ -0,0 +1,669 @@
1
+ import { googleFonts, googleFontStack, googleFontURL } from './fonts.mjs';
2
+ import { mixColour as mix, paletteSeeds, companionSurface, toLch, fromLch } from './palette.mjs';
3
+
4
+ const bounds = {
5
+ 'text-size': [14, 24], 'control-roominess': [2, 4], 'corner-roundness': [0, 32],
6
+ 'line-weight': [.5, 3], rhythm: [.75, 1.5], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
7
+ 'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
8
+ 'response-time': [0, 600], 'neutral-border-contrast': [1, 4.5],
9
+ };
10
+
11
+ const brandChoices = { primary: 'Primary accent', secondary: 'Secondary accent', tertiary: 'Tertiary accent' };
12
+ const statusChoices = { derived: 'Semantic hue', related: 'Harmonize with primary', ...brandChoices };
13
+ const neutralChoices = { text: 'Derive from text', ...brandChoices, gray: 'Untinted gray' };
14
+ const relationshipChoices = { preserve: 'Preserve seed hues', neighboring: 'Neighboring hues', complementary: 'Complementary hues' };
15
+ const choicesFor = id => id === 'neutral-source' ? neutralChoices : id.endsWith('-source') ? statusChoices : id.endsWith('-accent') ? brandChoices : relationshipChoices;
16
+ const statusSeeds = { danger: '#c92d1e', success: '#007a33', warning: '#b27522', info: '#0066cc' };
17
+
18
+ /** The shared authoring schema; ids remain stable in saved recipes. */
19
+ export const themeFields = [
20
+ ['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary fill; readable labels and contextual action text are derived separately.'],
21
+ ['Colour', 'secondary-action', 'Secondary accent', null, 'optional-colour', 'none', 'An optional supporting brand color for selected components. Primary actions keep the primary accent.'],
22
+ ['Colour', 'tertiary-action', 'Tertiary accent', null, 'optional-colour', 'none', 'An optional third accent for categories, highlights and supporting content.'],
23
+ ['Colour', 'neutral-source', 'Neutral relationship', null, 'choice', 'text', 'Derive restrained neutrals from text, an accent family or an untinted gray. Shared border contrast and readable labels stay governed.'],
24
+ ['Colour', 'neutral-strength', 'Neutral fill strength', null, 'fraction', '0.25', 'How much neutral pigment appears in quiet filled controls. Borders remain shared with fields.'],
25
+ ['Colour', 'neutral-border-contrast', 'Border contrast', null, 'ratio', '2', 'Minimum border-to-surface contrast for outlined buttons and form fields, independent of text readability. 2:1 is the default; raise it for stronger boundaries.'],
26
+ ['Colour', 'status-chroma', 'Derived status saturation', null, 'fraction', '0.5', 'Coordinate derived status pigment, text and quiet tints with the primary accent. Brand mappings keep the chosen family intact.'],
27
+ ['Colour', 'success-source', 'Success relationship', null, 'choice', 'derived', 'Choose a semantic hue, harmonize it with primary, or use a governed brand-accent family.'],
28
+ ['Colour', 'danger-source', 'Error relationship', null, 'choice', 'derived', 'Choose a semantic hue, harmonize it with primary, or use a governed brand-accent family.'],
29
+ ['Colour', 'warning-source', 'Warning relationship', null, 'choice', 'derived', 'Choose a semantic hue, harmonize it with primary, or use a governed brand-accent family.'],
30
+ ['Colour', 'info-source', 'Info relationship', null, 'choice', 'derived', 'Choose a semantic hue, harmonize it with primary, or use a governed brand-accent family.'],
31
+ ['Colour', 'selection-accent', 'Selection accent', null, 'choice', 'primary', 'Choose the accent for selected rows and choices.'],
32
+ ['Colour', 'focus-accent', 'Focus accent', null, 'choice', 'primary', 'Choose the accent for keyboard focus rings.'],
33
+ ['Colour', 'link-accent', 'Link accent', null, 'choice', 'primary', 'Choose the accent for inline Text links and editor links.'],
34
+ ['Colour', 'canvas', 'Canvas', '--ui-surface', 'colour', '#f6f6f8', 'The ground behind surfaces.'],
35
+ ['Colour', 'surface', 'Surface', '--ui-surface-raised', 'colour', '#ffffff', 'Fields, cards and floating surfaces.'],
36
+ ['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
37
+ ['Colour', 'warmth', 'Surface warmth', null, 'fraction', '0.5', '0% cool, 50% original, 100% warm paper and ink; your brand hue stays independent.'],
38
+ ['Colour', 'color-intensity', 'Color intensity', null, 'fraction', '0.5', '0% quiet, 50% original, 100% vivid, coordinated across surfaces, actions and status colors.'],
39
+ ['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep base hues, or relate quiet surfaces to the brand through neighboring or complementary hues.'],
40
+ ['Type', 'body-font', 'Font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
41
+ ['Type', 'display-font', 'Heading font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
42
+ ['Type', 'text-size', 'Base text size', '--ui-font-size', 'positive-length', '16px', 'Sets the document root in px. Type, spacing and layout use rem relationships and scale from this base once.'],
43
+ ['Type', 'label-size', 'Field label size', '--ui-field-label-font-size', 'inherited-length', 'inherit', 'Follows body text unless you choose a separate size for field labels. Help text keeps its supporting size.'],
44
+ ['Type', 'text-weight', 'Emphasis weight', '--ui-font-medium', 'weight', '500', 'Control-label emphasis; body and headings keep their roles.'],
45
+ ['Size & spacing', 'control-roominess', 'Control roominess', '--ui-control-roominess', 'positive', '2.5', 'Shared control height as a multiple of base text size; buttons, fields and swatches stay aligned.'],
46
+ ['Size & spacing', 'rhythm', 'Rhythm', '--ui-rhythm', 'positive', '1', 'Vertical breathing room: opens text lines and the gaps between stacked groups without spreading navigation or button rows.'],
47
+ ['Size & spacing', 'spacing', 'Spacing', '--ui-spacing', 'positive', '1', 'Space between controls and around content. Changes gaps and padding independently of vertical rhythm.'],
48
+ ['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls. At 1 the sizes are equal; higher values separate them.'],
49
+ ['Size & spacing', 'space-curve', 'Spacing curve', '--ui-space-curve', 'nonnegative', '1', 'Spacing growth: 1 is linear, 2 is quadratic. Independent of control sizes.'],
50
+ ['Size & spacing', 'corner-roundness', 'Corner roundness', '--ui-radius-md', 'length', '8px', 'One corner family across controls and surfaces.'],
51
+ ['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
52
+ ['Material', 'outer-depth', 'Outer depth', '--ui-outer-depth', 'nonnegative', '0.6', 'How strongly controls and floating surfaces rise above the page.'],
53
+ ['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
54
+ ['Material', 'glassiness', 'Frosting', '--ui-glassiness', 'fraction', '0', 'Blurs the scene through glass, from clear to frosted. Most transmission is in the rim; content stays nearly opaque.'],
55
+ ['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
56
+ ['Material', 'translucency', 'Transparency', '--ui-overlay-translucency', 'fraction', '0', 'Reveals the scene through decorative rims. At maximum, the content plate also becomes slightly translucent.'],
57
+ ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
58
+ ['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
59
+ ['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Reflected light beyond a surface, from a quiet halo to a vivid bloom at the upper end.'],
60
+ ['Material', 'light-color', 'Light color', '--ui-light-color', 'colour', '#ffffff', 'The reflected environment, independent of object pigment. Neutral white by default; reflected hues remain readable.'],
61
+ ['Material', 'light-x', 'Light horizontal position', '--ui-light-x', 'fraction', '0.5', 'The light source from left to right, relative to each surface.'],
62
+ ['Material', 'light-y', 'Light vertical position', '--ui-light-y', 'fraction', '0', 'The light source from top to bottom, relative to each surface.'],
63
+ ['Material', 'light-spread', 'Light spread', '--ui-light-spread', 'fraction', '0.5', 'A concentrated spot through broad illumination.'],
64
+ ['Material', 'light-strength', 'Light strength', '--ui-light-strength', 'fraction', '0.65', 'Shared illumination; each material and component role determines its response.'],
65
+ ['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
66
+ ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
67
+ ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(type === 'choice' ? { choices: choicesFor(id) } : {}), ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
68
+
69
+ const baseRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
70
+ const starting = values => ({ schemaVersion: 1, values: { ...baseRecipe().values, ...values } });
71
+ const fontStarting = (values, body, display) => ({
72
+ ...starting({ ...values, 'body-font': googleFontStack(body), 'display-font': googleFontStack(display) }),
73
+ fonts: { 'body-font': body, 'display-font': display },
74
+ });
75
+ /** The documentation starts with Looma's coordinated identity; Minimal keeps the neutral baseline. */
76
+ export const defaultRecipe = () => ({ ...starting({ action: '#7658d6', canvas: '#fbf9f4', surface: '#fffdf8', text: '#2b2538', 'body-font': googleFontStack('Manrope'), 'display-font': googleFontStack('Manrope'), 'text-size': '17px', 'inner-depth': '0.7' }), fonts: { 'body-font': 'Manrope', 'display-font': 'Manrope' } });
77
+ export const startingPoints = {
78
+ looma: defaultRecipe(),
79
+ minimal: { ...starting({
80
+ 'neutral-source': 'gray', 'neutral-strength': '0.18', 'neutral-border-contrast': '2.2', 'status-chroma': '0.1',
81
+ 'corner-roundness': '6px', 'outer-depth': '0.2', 'inner-depth': '0', 'response-time': '100ms',
82
+ }), presets: { status: 'restrained' } },
83
+ gold: { ...fontStarting({
84
+ action: '#ffb84d', 'secondary-action': '#704d7b', canvas: '#ffffff', surface: '#ffffff', text: '#171717',
85
+ 'neutral-source': 'primary', 'neutral-strength': '0.18', 'neutral-border-contrast': '2.1',
86
+ 'info-source': 'secondary', 'status-chroma': '0.25', 'selection-accent': 'secondary', 'link-accent': 'primary',
87
+ 'text-size': '17px', 'label-size': '16px', 'corner-roundness': '16px', finish: '0.55', 'light-x': '0',
88
+ }, 'Lato', 'Fraunces'), presets: { status: 'semantic' } },
89
+ bookish: { ...fontStarting({
90
+ action: '#2e1228', 'secondary-action': '#fc5b69', 'tertiary-action': '#896853',
91
+ canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c',
92
+ 'neutral-source': 'primary', 'neutral-strength': '0.18', 'neutral-border-contrast': '2.1',
93
+ 'info-source': 'primary', 'success-source': 'primary', 'status-chroma': '0.1',
94
+ rhythm: '1.05', 'text-weight': '400', 'control-roominess': '2.625', 'scale-contrast': '1.18', 'corner-roundness': '6px',
95
+ 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18',
96
+ 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9',
97
+ 'response-time': '150ms', 'response-curve': 'ease',
98
+ }, 'Open Sans', 'Newsreader'), presets: { status: 'restrained' } },
99
+ wiki: starting({ rhythm: '0.95', 'text-weight': '400', 'space-curve': '0.8', action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.95', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
100
+ editorial: { ...starting({
101
+ action: '#182c24', 'secondary-action': '#9d4f38', 'tertiary-action': '#92743d',
102
+ canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24',
103
+ 'neutral-source': 'primary', 'neutral-strength': '0.12', 'neutral-border-contrast': '2.4',
104
+ 'success-source': 'primary', 'info-source': 'primary', 'warning-source': 'primary', 'danger-source': 'primary',
105
+ 'link-accent': 'secondary', 'focus-accent': 'secondary',
106
+ rhythm: '1.3', 'text-size': '17px', 'label-size': '16px', spacing: '1.12', 'space-curve': '1.4', 'scale-contrast': '1.3',
107
+ 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400',
108
+ 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'line-weight': '2px',
109
+ 'response-time': '90ms', 'response-curve': 'linear',
110
+ }), presets: { status: 'primary' } },
111
+ night: { ...fontStarting({
112
+ action: '#a7b8ff', 'secondary-action': '#68bfc2', 'tertiary-action': '#d0a56b',
113
+ canvas: '#11141c', surface: '#202634', text: '#edf1fb',
114
+ 'neutral-source': 'primary', 'neutral-strength': '0.3', 'neutral-border-contrast': '2.2',
115
+ 'success-source': 'secondary', 'info-source': 'primary', 'warning-source': 'tertiary', 'status-chroma': '0.3',
116
+ 'selection-accent': 'secondary', 'link-accent': 'secondary',
117
+ rhythm: '1.05', 'text-weight': '500', 'control-roominess': '2.375', spacing: '1.05', 'scale-contrast': '1.25',
118
+ 'corner-roundness': '12px', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25',
119
+ 'light-x': '0.25', 'light-spread': '0.65', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65',
120
+ 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)',
121
+ }, 'IBM Plex Sans', 'IBM Plex Mono'), presets: { status: 'semantic' } },
122
+ garden: { ...fontStarting({
123
+ action: '#256342', 'secondary-action': '#b9684b', 'tertiary-action': '#b9973e',
124
+ canvas: '#f4edda', surface: '#fff8e7', text: '#173121',
125
+ 'neutral-source': 'primary', 'neutral-strength': '0.2', 'neutral-border-contrast': '2',
126
+ 'success-source': 'primary', 'info-source': 'primary', 'warning-source': 'tertiary', 'status-chroma': '0.2', 'focus-accent': 'secondary',
127
+ rhythm: '1.2', 'text-weight': '400', 'space-curve': '1.15', 'control-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05',
128
+ 'corner-roundness': '22px', finish: '0.15', 'light-spread': '1', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2',
129
+ 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)',
130
+ }, 'Nunito Sans', 'Lora'), presets: { status: 'semantic' } },
131
+ };
132
+ export const startingPointLabels = { looma: 'Looma', minimal: 'Minimal', gold: 'Gold', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
133
+ const paletteKeys = themeFields.filter(field => field.family === 'Colour').map(field => field.id);
134
+ // Palette choices move only the shared color seeds; size, material and motion stay independent.
135
+ export const themePalettes = Object.fromEntries([
136
+ ['looma', 'Looma'], ['minimal', 'Indigo & mist'], ['gold', 'Gold'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
137
+ ['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
138
+ ].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
139
+
140
+ const materialKeys = themeFields.filter(field => field.family === 'Material').map(field => field.id);
141
+ const materialDefaults = defaultRecipe().values;
142
+ const material = (label, overrides) => ({ label, values: Object.fromEntries(materialKeys.map(key => [key, overrides[key] ?? materialDefaults[key]])) });
143
+ export const materialPresets = {
144
+ matte: material('Matte', { 'outer-depth': '0', 'inner-depth': '0' }),
145
+ soft: material('Soft', { 'outer-depth': '0.6', 'inner-depth': '0.3', 'edge-light': '0.3', finish: '0.2', 'light-spread': '0.8' }),
146
+ polished: material('Polished', { 'outer-depth': '1', 'inner-depth': '1', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9' }),
147
+ glass: material('Frosted glass', startingPoints.bookish.values),
148
+ };
149
+ const status = (label, source, chroma) => ({ label, values: {
150
+ ...Object.fromEntries(Object.keys(statusSeeds).map(role => [role + '-source', source])), 'status-chroma': chroma,
151
+ } });
152
+ export const statusPresets = {
153
+ semantic: status('Semantic colors', 'derived', '0.5'),
154
+ restrained: status('Less color', 'derived', '0.1'),
155
+ primary: status('Primary + icons', 'primary', '0.5'),
156
+ };
157
+ const refinementPresets = family => {
158
+ switch (family) {
159
+ case 'material': return materialPresets;
160
+ case 'palette': return themePalettes;
161
+ case 'status': return statusPresets;
162
+ }
163
+ };
164
+
165
+ /** Preset provenance is optional recipe data, never a second source of theme values. */
166
+ export function refinementState(input, family) {
167
+ const recipe = validateRecipe(input), presets = refinementPresets(family);
168
+ if (!presets) throw new Error('Unknown refinement family.');
169
+ const matches = preset => Object.entries(preset.values).every(([key, value]) => recipe.values[key] === value);
170
+ const id = recipe.presets?.[family] ?? Object.keys(presets).find(id => matches(presets[id])) ?? 'custom';
171
+ return { id, customized: id !== 'custom' && !matches(presets[id]) };
172
+ }
173
+
174
+ /** Apply or restore one family without changing the rest of the design system. */
175
+ export function applyRefinement(input, family, id) {
176
+ const recipe = validateRecipe(input), preset = refinementPresets(family)?.[id];
177
+ if (!preset) throw new Error('Unknown refinement preset.');
178
+ return validateRecipe({ ...recipe, values: { ...recipe.values, ...preset.values }, presets: { ...recipe.presets, [family]: id } });
179
+ }
180
+
181
+ /** Identify a coordinated palette without coupling it to the rest of a recipe. */
182
+ export function paletteFor(input) {
183
+ const { values } = validateRecipe(input);
184
+ return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
185
+ }
186
+
187
+ const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
188
+ const length = /^(?:0|[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em|ex|ch|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|cm|mm|Q|in|pt|pc))$/;
189
+ const font = /^(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*)(?:\s*,\s*(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*))*$/u;
190
+ const time = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:ms|s)$/;
191
+ function easing(value) {
192
+ if (/^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/.test(value)) return true;
193
+ const match = /^cubic-bezier\(\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*\)$/.exec(value);
194
+ return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1) && [2, 4].every(i => Number(match[i]) >= -1 && Number(match[i]) <= 2));
195
+ }
196
+
197
+ // Authoring bounds are shared by every entry path. Relative lengths keep their units;
198
+ // viewport/font-metric lengths are constrained at use time rather than guessed in JS.
199
+
200
+ const pixels = { px: 1, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
201
+ function boundedNumber(field, value) {
202
+ if (field.type === 'time') return parseFloat(value) * (value.endsWith('ms') ? 1 : 1000);
203
+ if (field.type.includes('length')) {
204
+ if (value === '0') return 0;
205
+ const unit = value.match(/[a-z]+$/i)?.[0];
206
+ return Object.hasOwn(pixels, unit) ? parseFloat(value) * pixels[unit] : undefined;
207
+ }
208
+ return Number(value);
209
+ }
210
+
211
+ /** Validate the whole recipe before previewing or writing any output. */
212
+ export function validateRecipe(input) {
213
+ if (!input || input.schemaVersion !== 1 || !input.values || typeof input.values !== 'object' || Array.isArray(input.values)) throw new Error('Unsupported theme recipe version or values.');
214
+ const known = new Set(themeFields.map(field => field.id));
215
+ for (const key of Object.keys(input.values)) if (!known.has(key)) throw new Error(`Unknown theme decision ${key}.`);
216
+ const defaults = defaultRecipe();
217
+ const values = { ...defaults.values, ...input.values };
218
+ for (const field of themeFields) {
219
+ const value = values[field.id];
220
+ let valid = typeof value === 'string' && value.length > 0;
221
+ if (valid) switch (field.type) {
222
+ case 'choice': valid = Object.hasOwn(field.choices, value); break;
223
+ case 'optional-colour': valid = value === field.defaultValue || /^#[\da-f]{6}$/i.test(value); break;
224
+ case 'colour': valid = /^#[\da-f]{6}$/i.test(value); break;
225
+ case 'font': valid = font.test(value); break;
226
+ case 'length': valid = length.test(value) && Number.isFinite(parseFloat(value)); break;
227
+ case 'inherited-length': valid = value === 'inherit' || (length.test(value) && Number.isFinite(parseFloat(value)) && parseFloat(value) > 0); break;
228
+ case 'positive-length': valid = length.test(value) && Number.isFinite(parseFloat(value)) && parseFloat(value) > 0; break;
229
+ case 'time': valid = time.test(value) && Number.isFinite(parseFloat(value)); break;
230
+ case 'easing': valid = easing(value); break;
231
+ default: {
232
+ const number = Number(value); valid = scalar.test(value) && Number.isFinite(number);
233
+ if (field.type === 'positive') valid &&= number > 0;
234
+ if (field.type === 'ratio') valid &&= number >= 1;
235
+ if (field.type === 'fraction') valid &&= number >= 0 && number <= 1;
236
+ if (field.type === 'weight') valid &&= number >= 1 && number <= 1000;
237
+ }
238
+ }
239
+ if (field.id === 'text-size' && valid && !value.endsWith('px')) throw new Error('Base text size: enter a px value for the document root.');
240
+ if (!valid) throw new Error(`${field.label}: enter a valid ${field.type.replaceAll('-', ' ')} value.`);
241
+ if (Object.hasOwn(bounds, field.id)) {
242
+ const number = boundedNumber(field, value), [min, max] = bounds[field.id];
243
+ if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
244
+ }
245
+ }
246
+ if (input.presets !== undefined && (!input.presets || typeof input.presets !== 'object' || Array.isArray(input.presets) || Object.entries(input.presets).some(([family, id]) => !Object.hasOwn(refinementPresets(family) ?? {}, id)))) throw new Error('Unknown refinement preset.');
247
+ if (input.fonts !== undefined && (!input.fonts || typeof input.fonts !== 'object' || Array.isArray(input.fonts) || Object.entries(input.fonts).some(([key, family]) => !['body-font', 'display-font'].includes(key) || !Object.hasOwn(googleFonts, family) || values[key] !== googleFontStack(family)))) throw new Error('Choose a Google font with its matching font stack.');
248
+ const inheritedFonts = Object.fromEntries(Object.entries(defaults.fonts ?? {}).filter(([role]) => !Object.hasOwn(input.values, role)));
249
+ const fonts = { ...inheritedFonts, ...input.fonts };
250
+ if (input.origin !== undefined && !Object.hasOwn(startingPoints, input.origin)) throw new Error('Unknown theme origin.');
251
+ return { schemaVersion: 1, values, ...(input.fonts === undefined && !Object.keys(fonts).length ? {} : { fonts }), ...(input.origin === undefined ? {} : { origin: input.origin }), ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
252
+ }
253
+
254
+ /** Match presets by their complete semantic recipe, including locally saved themes. */
255
+ export function startingPointFor(input) {
256
+ const recipe = JSON.stringify(validateRecipe(input).values);
257
+ return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value.values) === recipe)?.[0] ?? 'custom';
258
+ }
259
+
260
+ const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
261
+ const channelLuminance = channels => channels.map(n => { const s = n / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4; }).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
262
+ const luminance = hex => channelLuminance(rgb(hex));
263
+ const alphaSurface = (fill, background, alpha) => {
264
+ const a = rgb(fill), b = rgb(background);
265
+ return '#' + a.map((n, i) => Math.round(n * alpha + b[i] * (1 - alpha)).toString(16).padStart(2, '0')).join('');
266
+ };
267
+ const luminanceContrast = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
268
+ const contrast = (a, b) => luminanceContrast(luminance(a), luminance(b));
269
+ // Bound opaque ink against the complete sRGB gradient. Different chromatic
270
+ // stops can mix darker than either endpoint, so inspect the interior as well.
271
+ function materialLuminances(start, end) {
272
+ const a = rgb(start), b = rgb(end);
273
+ const at = t => channelLuminance(a.map((n, i) => n + (b[i] - n) * t));
274
+ let low = 0, high = 1;
275
+ for (let step = 0; step < 24; step++) {
276
+ const left = (2 * low + high) / 3, right = (low + 2 * high) / 3;
277
+ if (at(left) < at(right)) high = right; else low = left;
278
+ }
279
+ return [Math.min(at(0), at(1), at((low + high) / 2)), Math.max(at(0), at(1))];
280
+ }
281
+ function materialContrast(start, end, ink) {
282
+ const [minimum, maximum] = materialLuminances(start, end);
283
+ return ink < minimum ? luminanceContrast(minimum, ink) : ink > maximum ? luminanceContrast(maximum, ink) : 1;
284
+ }
285
+ // Darker colored paint needs more saturation to avoid a muddy shadow. Keep
286
+ // chroma before moving hue; a bounded hue adjustment recovers colorfulness when
287
+ // the requested shade otherwise loses it at the sRGB gamut boundary.
288
+ function chromaticRecession([l, c, h], lightness) {
289
+ const depth = Math.max(0, l - lightness);
290
+ if (c < .01) return fromLch([lightness, c * lightness / (l || 1), h]);
291
+ const target = c * (1 + depth * .5), minimum = c * (1 + depth * .25) - .002;
292
+ let best = fromLch([lightness, target, h]), bestChroma = toLch(best)[1];
293
+ for (let offset = 1; offset <= 12 && bestChroma < minimum; offset++) {
294
+ for (const direction of [-1, 1]) {
295
+ const candidate = fromLch([lightness, target, h + offset * direction]), chroma = toLch(candidate)[1];
296
+ if (chroma > bestChroma + .0005) { best = candidate; bestChroma = chroma; }
297
+ }
298
+ }
299
+ return best;
300
+ }
301
+
302
+ function materialColours(fill, text, light, minimum = 4.55, minimumRange = 0) {
303
+ const labels = (Array.isArray(text) ? text : [text]).map(label => typeof label === 'number' ? label : luminance(label));
304
+ const [l, c, h] = toLch(fill), [lightness] = toLch(light);
305
+ // Content surfaces retain restrained lighting. Filled pigment gains chroma
306
+ // as it recedes, so a darker gold reads as colored material rather than brown.
307
+ const response = 1 - .75 * l;
308
+ let depth = .05 * response;
309
+ const shaded = amount => {
310
+ if (amount === 0) return fill;
311
+ const lightness = Math.max(0, l - amount);
312
+ return minimumRange ? chromaticRecession([l, c, h], lightness) : fromLch([lightness, c * lightness / (l || 1), h]);
313
+ };
314
+ while (depth > 0 && labels.some(label => luminanceContrast(luminance(shaded(depth)), label) < minimum)) depth = Math.max(0, depth - .001);
315
+ let shade = shaded(depth);
316
+ // Lift chroma with lightness, plus a small pigment-dependent saturation boost,
317
+ // so the broad reflection stays colored instead of becoming a white wash.
318
+ const [, tintChroma, tintHue] = toLch(mix(light, fill, .18));
319
+ const reflectedLightness = l + (lightness - l) * .32 * response;
320
+ const reflectedChroma = c * Math.max(1, reflectedLightness / (l || 1)) * (1 + .06 * response);
321
+ const target = fromLch([reflectedLightness, Math.max(reflectedChroma, tintChroma), tintHue]);
322
+ let amount = 1;
323
+ const readableReflection = amount => {
324
+ const colour = mix(target, fill, amount);
325
+ const [reflectedL, reflectedC] = toLch(colour);
326
+ // Gamut mapping must not dilute saturation as the reflection brightens.
327
+ // Back toward the pigment when needed, allowing for sRGB byte quantization.
328
+ return reflectedC + .002 >= c * Math.max(1, reflectedL / (l || 1))
329
+ && labels.every(label => materialContrast(colour, shade, label) >= minimum);
330
+ };
331
+ while (amount > 0 && !readableReflection(amount)) amount = Math.max(0, amount - .005);
332
+ const reflection = mix(target, fill, amount);
333
+ // Bright saturated pigments have little reflection headroom in sRGB. Keep
334
+ // their material visible through a deeper chromatic shade rather than a
335
+ // white wash, bounded by the label's contrast across the complete gradient.
336
+ while (depth + .002 <= .16 && colourDistance(reflection, shade) < minimumRange * lightness) {
337
+ const candidate = shaded(depth + .002);
338
+ if (labels.some(label => materialContrast(reflection, candidate, label) < minimum)) break;
339
+ depth += .002;
340
+ shade = candidate;
341
+ }
342
+ // A tiny specular peak is allowed to brighten separately from the chromatic
343
+ // body reflection. Keep the same quantization margin as every material stop.
344
+ let glare = .55 * lightness;
345
+ while (glare > 0 && labels.some(label => luminanceContrast(luminance(alphaSurface('#ffffff', reflection, glare)), label) < minimum)) glare = Math.max(0, glare - .005);
346
+ return { reflection, shade, specular: alphaSurface('#ffffff', reflection, glare) };
347
+ }
348
+ const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
349
+
350
+ /** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
351
+ function readableColour(seed, surfaces, minimum, endpoint) {
352
+ if (surfaces.every(surface => contrast(seed, surface) >= minimum)) return seed;
353
+ for (let step = 1; step <= 256; step++) {
354
+ const next = mix(seed, endpoint, 1 - step / 256);
355
+ if (surfaces.every(surface => contrast(next, surface) >= minimum)) return next;
356
+ }
357
+ return endpoint;
358
+ }
359
+
360
+ /** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
361
+ // Perceptual distance in Cartesian Oklab, including chroma-only separation.
362
+ const colourDistance = (a, b) => {
363
+ const [l1, c1, h1] = toLch(a), [l2, c2, h2] = toLch(b);
364
+ return Math.sqrt((l1 - l2) ** 2 + (c1 - c2) ** 2 + 2 * c1 * c2 * (1 - Math.cos((h1 - h2) * Math.PI / 180)));
365
+ };
366
+ const surfaceOffset = (surface, ink, distance) => mix(ink, surface, Math.min(1, distance / Math.max(.001, colourDistance(surface, ink))));
367
+ const actionBoundary = (fill, surfaces, ink, amplitude) => {
368
+ const distance = Math.min(...surfaces.map(surface => colourDistance(fill, surface)));
369
+ const shortfall = Math.max(0, 1 - distance / amplitude);
370
+ // Blend assistance in continuously. Searching for an exact distance along an
371
+ // ink path can jump when that path initially reduces chroma separation.
372
+ return mix(surfaceOffset(surfaces[1], ink, amplitude), fill, shortfall ** 2);
373
+ };
374
+
375
+ // Adjust semantic ink in lightness before sacrificing its hue or chroma. Mixing
376
+ // amber toward black also removes its color, making a readable warning look brown.
377
+ function readableTone(seed, surfaces, minimum, endpoint, chromatic = false) {
378
+ const [l, c, h] = toLch(seed), end = toLch(endpoint)[0];
379
+ const backgrounds = surfaces.map(surface => typeof surface === 'number' ? surface : luminance(surface));
380
+ for (let step = 0; step <= 256; step++) {
381
+ const lightness = l + (end - l) * step / 256;
382
+ const next = chromatic && lightness < l ? chromaticRecession([l, c, h], lightness) : fromLch([lightness, c, h]);
383
+ if (backgrounds.every(background => luminanceContrast(luminance(next), background) >= minimum)) return next;
384
+ }
385
+ return endpoint;
386
+ }
387
+
388
+ // Preserve pigment rather than mixing it toward black/white. Deep solids need
389
+ // a larger lift than pale fills to make hover read with the same visual weight.
390
+ function interactionColour(fill, label, distance, direction = toLch(fill)[0] < .5 ? 1 : -1) {
391
+ const [l, c, h] = toLch(fill);
392
+ for (const sign of [direction, -direction]) {
393
+ const next = fromLch([l + sign * distance, c, h]);
394
+ if (contrast(next, label) >= 4.55) return next;
395
+ }
396
+ return readableTone(fill, [label], 4.55, foreground(label, '#ffffff', '#000000'));
397
+ }
398
+
399
+ // Calibrate translucent paint against its plate in Oklab. Raw percentages have
400
+ // very different perceptual weights for a dark plum, a bright gold and white ink.
401
+ function stateWash(pigment, plate, distance, dark) {
402
+ const [l, c, h] = toLch(pigment), washL = dark ? .65 : .72;
403
+ const wash = fromLch([washL, c * washL / Math.max(.001, l), h]);
404
+ let low = 0, high = 1;
405
+ for (let step = 0; step < 16; step++) {
406
+ const alpha = (low + high) / 2;
407
+ if (colourDistance(alphaSurface(wash, plate, alpha), plate) < distance) low = alpha; else high = alpha;
408
+ }
409
+ return wash + Math.round(high * 255).toString(16).padStart(2, '0');
410
+ }
411
+ const washSurface = (wash, surface) => alphaSurface(wash, surface, parseInt(wash.slice(7), 16) / 255);
412
+
413
+ const quietChromaScale = .74;
414
+ const paletteCache = new Map();
415
+ export function deriveTheme(input, { mode } = {}) {
416
+ if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
417
+ const { values } = validateRecipe(input);
418
+ const tokens = Object.fromEntries(themeFields.filter(field => field.property).map(field => [field.property, values[field.id]]));
419
+ for (const field of themeFields.filter(field => field.type.includes('length') && Object.hasOwn(bounds, field.id))) {
420
+ if (boundedNumber(field, values[field.id]) === undefined) {
421
+ const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
422
+ }
423
+ }
424
+ const cacheKey = JSON.stringify([mode, ...paletteKeys.map(key => values[key]), values['light-color'], values.translucency, values.finish, values['light-strength']]);
425
+ if (paletteCache.has(cacheKey)) {
426
+ const cached = paletteCache.get(cacheKey);
427
+ return { tokens: { ...tokens, ...cached.tokens }, adjustments: cached.adjustments.map(item => ({ ...item })) };
428
+ }
429
+ const geometry = new Set(Object.keys(tokens));
430
+ const seeds = paletteSeeds(values);
431
+ const adjustments = [];
432
+ const nativeInk = foreground(seeds.canvas, '#ffffff', '#000000');
433
+ const ink = mode === 'light' ? '#000000' : mode === 'dark' ? '#ffffff' : nativeInk;
434
+ const paper = ink === '#000000' ? '#ffffff' : '#000000';
435
+ // Preserve the requested palette in its native mode. The companion keeps a restrained
436
+ // tint of the same surface seeds; contrast resolution below governs all usable roles.
437
+ const surface = seed => readableColour(nativeInk === ink ? seed : companionSurface(seed, mode ?? (ink === '#000000' ? 'light' : 'dark')), [ink], 7, paper);
438
+ tokens['--ui-surface'] = surface(seeds.canvas);
439
+ tokens['--ui-surface-raised'] = surface(seeds.surface);
440
+ const dark = ink === '#ffffff';
441
+ if (dark) {
442
+ // Dark plates need a positive elevation step; independently flipping near-white
443
+ // seeds otherwise collapses both canvas and content to almost the same black.
444
+ const [canvasL, canvasC, canvasH] = toLch(tokens['--ui-surface']);
445
+ tokens['--ui-surface'] = fromLch([Math.min(canvasL, .38), canvasC, canvasH]);
446
+ const [raisedL, raisedC, raisedH] = toLch(tokens['--ui-surface-raised']);
447
+ tokens['--ui-surface-raised'] = readableColour(fromLch([Math.max(raisedL, Math.min(canvasL, .38) + .08), raisedC, raisedH]), [ink], 7, paper);
448
+ tokens['--ui-surface-sunken'] = fromLch([Math.max(0, Math.min(canvasL, .38) - .035), canvasC, canvasH]);
449
+ } else tokens['--ui-surface-sunken'] = surface(mix(tokens['--ui-surface-raised'], tokens['--ui-surface'], .92));
450
+ const surfaces = ['--ui-surface', '--ui-surface-raised', '--ui-surface-sunken'].map(name => tokens[name]);
451
+ const accents = { primary: seeds.action, secondary: seeds['secondary-action'], tertiary: seeds['tertiary-action'] };
452
+ const tones = { accent: accents.primary, 'accent-secondary': accents.secondary, 'accent-tertiary': accents.tertiary };
453
+ const brandChroma = Math.min(.20, Math.max(.08, toLch(accents.primary)[1]));
454
+ for (const role of Object.keys(statusSeeds)) {
455
+ const source = values[role + '-source'];
456
+ if (source === 'derived' || source === 'related') {
457
+ let [l, c, h] = toLch(statusSeeds[role]);
458
+ if (source === 'related') {
459
+ const brandHue = toLch(accents.primary)[2];
460
+ const delta = ((brandHue - h + 540) % 360) - 180;
461
+ h += Math.max(-15, Math.min(15, delta));
462
+ }
463
+ // Preserve each semantic anchor's balance; brand pigment bounds its intensity.
464
+ tones[role] = fromLch([l, Math.min(c, brandChroma * .7) * (.5 + Number(values['status-chroma'])) * (.6 + Number(values['color-intensity']) * .8), h]);
465
+ } else tones[role] = accents[source];
466
+ }
467
+ for (const [tone, original] of Object.entries(tones)) {
468
+ const fill = original;
469
+ const onFill = foreground(fill, '#ffffff', '#000000');
470
+ tokens[`--ui-${tone}`] = fill;
471
+ tokens[`--ui-on-${tone}`] = onFill;
472
+ tokens[`--ui-${tone}-on-strong`] = onFill;
473
+ for (const [part, colour] of Object.entries(materialColours(fill, onFill, values['light-color'], 4.55, .14))) tokens[`--ui-${tone}-${part}`] = colour;
474
+ const stateTarget = onFill === '#000000' ? '#ffffff' : '#000000';
475
+ const [fillL, fillC, fillH] = toLch(fill);
476
+ const hoverStep = .035 + .08 * Math.max(0, 1 - fillL / .5);
477
+ tokens[`--ui-${tone}-hover`] = interactionColour(fill, onFill, hoverStep);
478
+ const direction = onFill === '#000000' ? 1 : -1;
479
+ const room = direction > 0 ? 1 - fillL : fillL;
480
+ const activeL = Math.max(0, Math.min(1, fillL + direction * Math.max(.085, room * .26)));
481
+ tokens[`--ui-${tone}-active`] = readableColour(fromLch([activeL, fillC * activeL / (fillL || 1), fillH]), [onFill], 4.55, stateTarget);
482
+ // Move the whole painted plane by the same step. A fixed reflection hides
483
+ // fill changes; multiplying its density exaggerates deep pigments' response.
484
+ const shift = toLch(tokens[`--ui-${tone}-hover`])[0] - toLch(fill)[0];
485
+ for (const part of ['reflection', 'shade']) {
486
+ const [l, c, h] = toLch(tokens[`--ui-${tone}-${part}`]);
487
+ tokens[`--ui-${tone}-hover-${part}`] = readableTone(fromLch([l + shift, c, h]), [onFill], 4.55, stateTarget);
488
+ }
489
+ const contextual = readableColour(fill, surfaces, 4.5, ink);
490
+ const quiet = mix(contextual, tokens['--ui-surface-raised'], .20);
491
+ const [quietL] = toLch(quiet);
492
+ const [, pigmentC, pigmentH] = toLch(fill);
493
+ const plateL = toLch(tokens['--ui-surface-raised'])[0];
494
+ const warmLift = dark ? Math.max(0, 1 - Math.abs(pigmentH - 70) / 35) ** 2 : 0;
495
+ const semantic = Object.hasOwn(statusSeeds, tone) && ['derived', 'related'].includes(values[tone + '-source']);
496
+ const tintScale = semantic ? .25 + 1.5 * Number(values['status-chroma']) : 1;
497
+ const quietC = Math.min(pigmentC, (dark ? .035 + .015 * warmLift : .02) * tintScale) * quietChromaScale;
498
+ const softL = dark ? Math.max(quietL, plateL + .07) + .04 * warmLift : Math.max(.92, plateL - .04);
499
+ tokens[`--ui-${tone}-soft`] = readableColour(fromLch([softL, quietC, pigmentH]), [ink], 4.5, paper);
500
+ }
501
+ const choiceFill = tokens['--ui-accent'], choiceInk = tokens['--ui-on-accent'];
502
+ tokens['--ui-choice-hover-surface'] = interactionColour(choiceFill, choiceInk, .04);
503
+ const choiceShift = toLch(tokens['--ui-choice-hover-surface'])[0] - toLch(choiceFill)[0];
504
+ for (const part of ['reflection', 'shade']) {
505
+ const [l, c, h] = toLch(tokens[`--ui-accent-${part}`]);
506
+ tokens[`--ui-choice-hover-${part}`] = readableTone(fromLch([l + choiceShift, c, h]), [choiceInk], 4.55, foreground(choiceInk, '#ffffff', '#000000'));
507
+ }
508
+ const materialOpacity = Number(values.finish) * Number(values['light-strength']);
509
+ const preliminaryMaterial = materialColours(tokens['--ui-surface-raised'], ink, values['light-color']);
510
+ const washPlate = alphaSurface(mix(preliminaryMaterial.reflection, preliminaryMaterial.shade, .5), tokens['--ui-surface-raised'], materialOpacity);
511
+ for (const tone of Object.keys(tones)) {
512
+ const [l, c, h] = toLch(tokens[`--ui-${tone}`]);
513
+ const quietPigment = fromLch([l, c * quietChromaScale, h]);
514
+ tokens[`--ui-${tone}-wash`] = stateWash(quietPigment, washPlate, .04, dark);
515
+ tokens[`--ui-${tone}-wash-hover`] = stateWash(quietPigment, washPlate, .065, dark);
516
+ tokens[`--ui-${tone}-ghost-hover`] = stateWash(tokens[`--ui-${tone}`], tokens['--ui-surface'], .03, dark);
517
+ }
518
+ tokens['--ui-neutral-wash-hover'] = stateWash(ink, washPlate, .025, dark);
519
+ tokens['--ui-neutral-ghost-hover'] = stateWash(ink, tokens['--ui-surface'], .03, dark);
520
+ const washSurfaces = Object.entries(tokens).filter(([name]) => /-(wash|wash-hover|ghost-hover)$/.test(name))
521
+ .flatMap(([, wash]) => [...surfaces, washPlate].map(surface => washSurface(wash, surface)));
522
+ // Quiet fills are actual alpha composites. At an extreme light/dark flip,
523
+ // reduce their shared tint before it would leave no readable contextual ink.
524
+ const quietSurfaces = strength => [...surfaces, ...washSurfaces, ...surfaces.map(surface => alphaSurface(surface, ink, 1 - .1 * Number(values.translucency))), ...(dark ? [readableColour(mix(ink, tokens['--ui-surface-raised'], .22), [ink], 4.5, paper)] : []), ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-soft`]), ...Object.keys(tones).flatMap(tone => surfaces.flatMap(background => [
525
+ mix(tokens[`--ui-${tone}`], background, .12 * strength),
526
+ ...[.05, .12, .18, .19].map(alpha => alphaSurface(tokens[`--ui-${tone}`], background, alpha * strength)),
527
+ ]))];
528
+ let tonalOpacity = 1;
529
+ while (tonalOpacity > 0 && quietSurfaces(tonalOpacity).some(background => contrast(ink, background) < 4.5)) tonalOpacity = Math.max(0, tonalOpacity - .01);
530
+ tokens['--ui-tonal-opacity'] = String(Number(tonalOpacity.toFixed(2)));
531
+ const readingSurfaces = quietSurfaces(tonalOpacity);
532
+ const borderMinimum = Math.min(4.5, Number(values['neutral-border-contrast']) * (dark ? 1.15 : 1));
533
+ for (const tone of Object.keys(tones)) {
534
+ const [l, c, h] = toLch(tokens[`--ui-${tone}`]);
535
+ const semantic = Object.hasOwn(statusSeeds, tone) && ['derived', 'related'].includes(values[tone + '-source']);
536
+ const foregroundSeed = fromLch([dark ? Math.max(l, .80) : semantic ? l : Math.min(l, .50), Math.min(c, semantic ? .12 : dark ? .16 : .18), h]);
537
+ tokens[`--ui-${tone}-subtle-text`] = (semantic ? readableTone : readableColour)(foregroundSeed, readingSurfaces, 4.5, ink);
538
+ }
539
+ const textSeed = nativeInk === ink ? seeds.text : mix(seeds.text, ink, .08);
540
+ tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
541
+ tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
542
+ tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
543
+ // Colored action edges keep a boundary when their pigment approaches a surface.
544
+ // Neutral controls use their own shared contrast decision below.
545
+ const separation = Math.min(...surfaces.map(surface => colourDistance(tokens['--ui-accent'], surface)));
546
+ const amplitude = .10 + .16 * separation / (separation + .20);
547
+ for (const tone of Object.keys(tones)) for (const state of ['', '-hover', '-active']) {
548
+ tokens[`--ui-${tone}${state}-border`] = actionBoundary(tokens[`--ui-${tone}${state}`], surfaces, ink, amplitude);
549
+ }
550
+ // Neutral pigment governs fills; a shared contrast floor governs every neutral
551
+ // control boundary independently of how close the brand pigment is to its canvas.
552
+ const neutralSource = values['neutral-source'];
553
+ const [, neutralC, neutralH] = toLch(neutralSource === 'text' || neutralSource === 'gray' ? tokens['--ui-text'] : accents[neutralSource]);
554
+ const neutral = fromLch([toLch(tokens['--ui-text'])[0], neutralSource === 'gray' ? 0 : Math.min(.04, neutralC), neutralH]);
555
+ const fillAmount = .02 + Number(values['neutral-strength']) * .16;
556
+ for (const [state, amount] of [['surface', fillAmount], ['active', fillAmount + .08]]) {
557
+ const tinted = mix(neutral, tokens['--ui-surface-raised'], amount * (dark ? 2.25 : 1));
558
+ const [l, c, h] = toLch(tinted);
559
+ const fill = dark ? fromLch([Math.max(l, toLch(tokens['--ui-surface-raised'])[0] + amount * 1.5), c, h]) : tinted;
560
+ tokens[`--ui-neutral-${state}`] = readableColour(fill, [tokens['--ui-text']], 4.5, paper);
561
+ }
562
+ tokens['--ui-neutral-hover'] = interactionColour(tokens['--ui-neutral-surface'], tokens['--ui-text'], .035, dark ? 1 : -1);
563
+ const borderSurfaces = [...surfaces, tokens['--ui-neutral-surface']];
564
+ const engagedSurfaces = [...surfaces, tokens['--ui-neutral-hover'], tokens['--ui-neutral-active']];
565
+ tokens['--ui-control-border'] = readableColour(mix(neutral, tokens['--ui-surface-raised'], .14), borderSurfaces, borderMinimum, ink);
566
+ tokens['--ui-control-border-hover'] = readableColour(tokens['--ui-control-border'], engagedSurfaces, Math.min(4.5, borderMinimum + .4), ink);
567
+ tokens['--ui-border'] = mix(neutral, tokens['--ui-surface-raised'], .10);
568
+ tokens['--ui-border-strong'] = mix(neutral, tokens['--ui-surface-raised'], .18);
569
+ for (const [role, fill, labels] of [
570
+ ['surface', tokens['--ui-surface-raised'], [tokens['--ui-text'], tokens['--ui-text-secondary'], ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-subtle-text`])]],
571
+ ['neutral', tokens['--ui-neutral-surface'], tokens['--ui-text']],
572
+ ]) for (const [part, colour] of Object.entries(materialColours(fill, labels, values['light-color'], 4.55, role === 'neutral' ? .14 : 0))) tokens[`--ui-${role}-${part}`] = colour;
573
+ const neutralShift = toLch(tokens['--ui-neutral-hover'])[0] - toLch(tokens['--ui-neutral-surface'])[0];
574
+ for (const part of ['reflection', 'shade']) {
575
+ const [l, c, h] = toLch(tokens[`--ui-neutral-${part}`]);
576
+ tokens[`--ui-neutral-hover-${part}`] = readableTone(fromLch([l + neutralShift, c, h]), [tokens['--ui-text']], 4.55, paper);
577
+ }
578
+ // Borders surround the finished paint, including its translucent wash and
579
+ // material gradient. Text readability has its own independent contrast floor.
580
+ for (const tone of Object.keys(tones)) {
581
+ const pigment = tokens[`--ui-${tone}`];
582
+ const backgrounds = surfaces.flatMap(surface => {
583
+ const reflection = alphaSurface(tokens['--ui-surface-reflection'], surface, materialOpacity);
584
+ const shade = alphaSurface(tokens['--ui-surface-shade'], surface, materialOpacity);
585
+ return [surface, ...materialLuminances(reflection, shade),
586
+ ...['wash', 'wash-hover', 'ghost-hover'].flatMap(state => materialLuminances(washSurface(tokens[`--ui-${tone}-${state}`], reflection), washSurface(tokens[`--ui-${tone}-${state}`], shade))),
587
+ ...[.08, .12].flatMap(alpha => materialLuminances(alphaSurface(pigment, reflection, alpha * tonalOpacity), alphaSurface(pigment, shade, alpha * tonalOpacity))),
588
+ ...[.08, .12, .18].map(alpha => alphaSurface(pigment, surface, alpha * tonalOpacity))];
589
+ });
590
+ tokens[`--ui-${tone}-outline-border`] = readableTone(pigment, backgrounds, borderMinimum, ink, true);
591
+ tokens[`--ui-${tone}-subtle-text`] = readableTone(tokens[`--ui-${tone}-subtle-text`], [...readingSurfaces, ...backgrounds], 4.5, ink);
592
+ tokens[`--ui-${tone}-soft-border`] = alphaSurface(tokens[`--ui-${tone}-subtle-text`], tokens[`--ui-${tone}-soft`], .25);
593
+ }
594
+ // Existing validation/editor hooks must receive a contextual text role, never
595
+ // a pressed fill. Preserve consumer overrides at their established boundaries.
596
+ tokens['--ui-field-danger'] = tokens['--ui-danger-subtle-text'];
597
+ for (const hook of ['code-keyword', 'link-color', 'toolbar-active-text', 'mention-color']) tokens[`--ui-editor-${hook}`] = tokens['--ui-accent-subtle-text'];
598
+ // Knobs are painted foreground surfaces, not text. Bound them against the
599
+ // continuous track luminance range, not just its colored endpoints (3:1).
600
+ for (const [role, fill, track, trackFill] of [
601
+ ['knob', tokens['--ui-text-secondary'], 'surface', tokens['--ui-surface-raised']],
602
+ ['knob-on-accent', tokens['--ui-on-accent'], 'accent', tokens['--ui-accent']],
603
+ ]) {
604
+ const [low, high] = materialLuminances(tokens[`--ui-${track}-reflection`], tokens[`--ui-${track}-shade`]);
605
+ const range = [Math.min(low, luminance(trackFill)), Math.max(high, luminance(trackFill), luminance(tokens[`--ui-${track}-specular`]))];
606
+ for (const [part, colour] of Object.entries(materialColours(fill, range, values['light-color'], 3.05))) tokens[`--ui-${role}-${part}`] = colour;
607
+ }
608
+ const selection = values['selection-accent'] === 'primary' ? 'accent' : 'accent-' + values['selection-accent'];
609
+ const link = values['link-accent'] === 'primary' ? 'accent' : 'accent-' + values['link-accent'];
610
+ tokens['--ui-link-color'] = tokens[`--ui-${link}-subtle-text`];
611
+ tokens['--ui-editor-link-color'] = tokens['--ui-link-color'];
612
+ // Contextual pigment keeps even deep brand colors visible over a dark plate.
613
+ const selectionPigment = tokens[`--ui-${selection}${dark ? '-subtle-text' : ''}`];
614
+ tokens['--ui-selection-surface'] = readableColour(mix(selectionPigment, tokens['--ui-surface-raised'], dark ? .22 : .1), [ink], dark ? 7 : 4.5, paper);
615
+ tokens['--ui-selection-text'] = readableColour(tokens[`--ui-${selection}-subtle-text`], [...surfaces, tokens['--ui-selection-surface']], dark ? 7 : 4.5, ink);
616
+ tokens['--ui-selection-border'] = mix(selectionPigment, tokens['--ui-surface-raised'], dark ? .44 : .2);
617
+ tokens['--ui-control-surface'] = tokens['--ui-surface-raised'];
618
+ // A hint is quieter than body copy; resolve it against its field, not every tinted content surface.
619
+ tokens['--ui-control-placeholder'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-control-surface'], .5), [tokens['--ui-control-surface']], 4.5, ink);
620
+ tokens['--ui-disabled-text'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .45);
621
+ tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
622
+ tokens['--ui-disabled-border'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .2);
623
+ tokens['--ui-focus-ring'] = readableColour(accents[values['focus-accent']], readingSurfaces, 3, ink);
624
+ tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
625
+ tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
626
+ for (const [id, property] of [['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
627
+ if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
628
+ }
629
+ const colors = Object.fromEntries(Object.entries(tokens).filter(([key]) => !geometry.has(key) || /^#[\da-f]{6}$/i.test(tokens[key])));
630
+ paletteCache.set(cacheKey, { tokens: colors, adjustments });
631
+ if (paletteCache.size > 16) paletteCache.delete(paletteCache.keys().next().value);
632
+ return { tokens, adjustments: adjustments.map(item => ({ ...item })) };
633
+ }
634
+
635
+ /** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
636
+ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
637
+ if (!['portable', 'documentation'].includes(scope)) throw new Error('Unknown theme scope.');
638
+ // The early documentation stylesheet wins over the baseline loaded later without !important.
639
+ const selector = scope === 'documentation'
640
+ ? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
641
+ : ':root, [data-theme], [data-contrast]';
642
+ const recipe = validateRecipe(input);
643
+ const fontURL = googleFontURL(recipe.fonts);
644
+ const imports = fontURL ? `@import url("${fontURL}");\n` : '';
645
+ const light = deriveTheme(input, { mode: 'light' }).tokens;
646
+ const dark = deriveTheme(input, { mode: 'dark' }).tokens;
647
+ const palette = [], geometry = [];
648
+ const relativeLengths = new Map(themeFields.filter(field => field.property && field.type.includes('length') && recipe.values[field.id].endsWith('rem')).map(field => [field.property, recipe.values[field.id]]));
649
+ for (const [name, value] of Object.entries(light)) {
650
+ if (name === 'color-scheme') continue;
651
+ if (/^#[\da-f]{6}(?:[\da-f]{2})?$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
652
+ else {
653
+ const relative = relativeLengths.get(name);
654
+ const resolved = name === '--ui-tonal-opacity' ? Math.min(Number(value), Number(dark[name])) : relative ? value.replace(relative, `calc(${parseFloat(relative)} * var(--_ui-root-font-unit, 1rem))`) : value;
655
+ geometry.push(` ${name}: ${resolved};`);
656
+ }
657
+ }
658
+ const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
659
+ const chevron = color => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='${color.replace('#', '%23')}' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`;
660
+ const boundary = mode => scope === 'documentation'
661
+ ? `:root[data-looma-documentation-theme][data-theme="${mode}"], :root[data-looma-documentation-theme] [data-theme="${mode}"]`
662
+ : `[data-theme="${mode}"]`;
663
+ const chevrons = `${geometrySelector} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n@media (prefers-color-scheme: dark) { ${geometrySelector}:not([data-theme]) { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; } }\n${boundary('light')} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n${boundary('dark')} { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; }`;
664
+
665
+ // Resolve colors at their point of use, so a nearer mode boundary wins independently
666
+ // of the selector specificity needed to restore an early documentation theme.
667
+ // Keep the first comment stable: the local writer recognizes its generated files by this marker.
668
+ return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${imports}${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
669
+ }