@threadlabs/looma 0.41.2 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/components/ui-button/ui-button.html +33 -12
  2. package/components/ui-button/ui-button.js +2 -2
  3. package/components/ui-callout/ui-callout.html +1 -1
  4. package/components/ui-card/ui-card.html +7 -7
  5. package/components/ui-checkbox/ui-checkbox.html +1 -1
  6. package/components/ui-dialog/ui-dialog.html +8 -10
  7. package/components/ui-editor-mention-menu/ui-editor-mention-menu.html +1 -1
  8. package/components/ui-editor-mention-menu-item/ui-editor-mention-menu-item.html +1 -1
  9. package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +1 -1
  10. package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +2 -2
  11. package/components/ui-icon-button/ui-icon-button.html +45 -10
  12. package/components/ui-light-editor/ui-light-editor.html +1 -1
  13. package/components/ui-menu/ui-menu.html +8 -10
  14. package/components/ui-menu-item/ui-menu-item.html +1 -1
  15. package/components/ui-popover/ui-popover.html +9 -11
  16. package/components/ui-radio/ui-radio.html +53 -1
  17. package/components/ui-section/ui-section.html +1 -1
  18. package/components/ui-status-message/ui-status-message.html +1 -1
  19. package/components/ui-switch/ui-switch.html +5 -2
  20. package/components/ui-tabs/ui-tabs.html +3 -3
  21. package/components/ui-text/ui-text.html +2 -2
  22. package/components/ui-tooltip/ui-tooltip.html +8 -10
  23. package/dist/index.js +1 -1
  24. package/docs/README.md +5 -1
  25. package/docs/catalog.json +405 -33
  26. package/docs/customizer-render.mjs +9 -5
  27. package/docs/customizer.js +2 -0
  28. package/docs/fonts.mjs +1 -1
  29. package/docs/theme.mjs +136 -35
  30. package/package.json +1 -1
  31. package/styles/ui-button.css +33 -12
  32. package/styles/ui-callout.css +1 -1
  33. package/styles/ui-card.css +6 -6
  34. package/styles/ui-checkbox.css +1 -1
  35. package/styles/ui-dialog.css +17 -21
  36. package/styles/ui-editor-mention-menu-item.css +1 -1
  37. package/styles/ui-editor-mention-menu.css +1 -1
  38. package/styles/ui-editor-table-context-menu.css +1 -1
  39. package/styles/ui-editor-table-toolbar.css +2 -2
  40. package/styles/ui-icon-button.css +45 -10
  41. package/styles/ui-light-editor.css +2 -2
  42. package/styles/ui-menu-item.css +1 -1
  43. package/styles/ui-menu.css +9 -11
  44. package/styles/ui-popover.css +10 -12
  45. package/styles/ui-radio.css +62 -1
  46. package/styles/ui-section.css +1 -1
  47. package/styles/ui-status-message.css +1 -1
  48. package/styles/ui-switch.css +5 -2
  49. package/styles/ui-tabs.css +3 -3
  50. package/styles/ui-text.css +2 -2
  51. package/styles/ui-tooltip.css +9 -11
  52. package/theme-system.css +10 -8
  53. package/tokens.css +23 -0
  54. package/vue/UiButton.vue +45 -12
  55. package/vue/UiCallout.vue +1 -1
  56. package/vue/UiCard.vue +7 -13
  57. package/vue/UiCheckbox.vue +1 -1
  58. package/vue/UiDialog.vue +21 -45
  59. package/vue/UiEditorMentionMenu.vue +1 -1
  60. package/vue/UiEditorMentionMenuItem.vue +1 -1
  61. package/vue/UiEditorTableContextMenu.vue +1 -1
  62. package/vue/UiEditorTableToolbar.vue +2 -2
  63. package/vue/UiIconButton.vue +45 -10
  64. package/vue/UiLightEditor.vue +2 -2
  65. package/vue/UiMenu.vue +11 -21
  66. package/vue/UiMenuItem.vue +1 -1
  67. package/vue/UiPopover.vue +12 -22
  68. package/vue/UiRadio.vue +57 -1
  69. package/vue/UiSection.vue +1 -1
  70. package/vue/UiStatusMessage.vue +1 -1
  71. package/vue/UiSwitch.vue +5 -2
  72. package/vue/UiTabs.vue +3 -3
  73. package/vue/UiText.vue +2 -2
  74. package/vue/UiTooltip.vue +11 -21
  75. package/vue/chunks/UiButton.js +1 -1
  76. package/vue/chunks/UiCallout.js +1 -1
  77. package/vue/chunks/UiCard.js +2 -2
  78. package/vue/chunks/UiCheckbox.js +1 -1
  79. package/vue/chunks/UiDialog.js +1 -1
  80. package/vue/chunks/UiEditorMentionMenu.js +1 -1
  81. package/vue/chunks/UiEditorMentionMenuItem.js +1 -1
  82. package/vue/chunks/UiEditorTableContextMenu.js +1 -1
  83. package/vue/chunks/UiEditorTableToolbar.js +1 -1
  84. package/vue/chunks/UiIconButton.js +1 -1
  85. package/vue/chunks/UiLightEditor.js +1 -1
  86. package/vue/chunks/UiMenu.js +1 -1
  87. package/vue/chunks/UiMenuItem.js +1 -1
  88. package/vue/chunks/UiPopover.js +1 -1
  89. package/vue/chunks/UiRadio.js +1 -1
  90. package/vue/chunks/UiSection.js +1 -1
  91. package/vue/chunks/UiStatusMessage.js +1 -1
  92. package/vue/chunks/UiSwitch.js +1 -1
  93. package/vue/chunks/UiTabs.js +1 -1
  94. package/vue/chunks/UiText.js +1 -1
  95. package/vue/chunks/UiTooltip.js +1 -1
  96. package/vue/components.css +342 -233
  97. package/vue/editor/index.js +1 -1
@@ -47,7 +47,7 @@ export function customizerResource(site, input = defaultRecipe()) {
47
47
  const preview = family === 'palette' ? `<span slot="start" data-selected-palette>${swatches()}</span><template slot="option"><span class="palette-option">${Object.keys(presets).map(id => `<template $if="$option.value = '${id}'">${swatches(deriveTheme(applyRefinement(recipe, 'palette', id), { mode: 'light' }).tokens)}</template>`).join('')}<span>{$option.label}</span></span></template>` : '';
48
48
  return choice(`id="${location}-${family}" data-refinement-preset="${family}"${muted ? ' muted' : ''}`, `${label} preset`, state.id, options, preview);
49
49
  };
50
- const paletteDetails = `<ui-grid columns="2" gap="s" style="--ui-grid-min: 8rem">${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-stack gap="s"><ui-text variant="eyebrow">${mode === 'light' ? 'Light' : 'Dark'}</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="heading" as="h3">Fellowship</ui-text><ui-text>There and back.</ui-text><ui-text variant="caption">Second breakfast at nine.</ui-text><ui-button size="sm" preset="primary">Accent</ui-button><ui-button size="sm" preset="standard">Neutral</ui-button><ui-button size="sm" variant="outline" tone="accent">Outline</ui-button></ui-stack></ui-card>`).join('')}</ui-grid><ui-text variant="caption" data-palette-adjustments role="status" aria-live="polite"></ui-text><ui-disclosure summary="Resolved palette values"><ui-text variant="caption" data-palette-adjustment-details></ui-text></ui-disclosure>`;
50
+ const paletteDetails = `<ui-grid columns="2" gap="s" style="--ui-grid-min: 8rem">${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-stack gap="s"><ui-text variant="eyebrow">${mode === 'light' ? 'Light' : 'Dark'}</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="heading" as="h3">Fellowship</ui-text><ui-text>There and back.</ui-text><ui-text variant="caption">Second breakfast at nine.</ui-text><ui-action-bar><ui-button slot="tertiary" size="sm" variant="outline" tone="accent">Outline</ui-button><ui-button slot="secondary" size="sm" preset="standard">Neutral</ui-button><ui-button slot="primary" size="sm" preset="primary">Accent</ui-button></ui-action-bar></ui-stack></ui-card>`).join('')}</ui-grid><ui-text variant="caption" data-palette-adjustments role="status" aria-live="polite"></ui-text><ui-disclosure summary="Resolved palette values"><ui-text variant="caption" data-palette-adjustment-details></ui-text></ui-disclosure>`;
51
51
  const paletteRelationships = `<ui-stack gap="s"><ui-text variant="heading" as="h3">How should the colors work together?</ui-text>${[
52
52
  ['preserve', 'Keep my colors', 'Keep the base colors as chosen. Warmth and intensity still refine the result.'],
53
53
  ['neighboring', 'Related colors', 'Tint neutral surfaces with a neighboring hue for a closely coordinated palette.'],
@@ -61,10 +61,12 @@ export function customizerResource(site, input = defaultRecipe()) {
61
61
  const typographyView = editor('typography', `<ui-card><ui-stack><ui-text variant="heading" as="h2">There and back again.</ui-text><ui-text>Frodo plans the journey. Sam brings lunch. Clear notes keep the Fellowship moving.</ui-text><ui-text variant="label">Memo ready</ui-text><ui-text variant="caption">Notes by Sméagol · Updated today</ui-text></ui-stack></ui-card>`, `${fieldsFor(['body-font','text-size'])}<ui-disclosure summary="Heading font"><ui-stack>${fieldsFor(['display-font'])}</ui-stack></ui-disclosure><ui-disclosure summary="Label emphasis"><ui-stack>${fieldsFor(['text-weight'])}</ui-stack></ui-disclosure>`);
62
62
  const layoutView = editor('layout', `<ui-stack>${sizePreview}</ui-stack>`, `${fieldsFor(['control-roominess','rhythm','spacing','corner-roundness'])}<ui-disclosure summary="Scale & edges"><ui-stack>${fieldsFor(['scale-contrast','space-curve','line-weight'])}</ui-stack></ui-disclosure>`);
63
63
  const motionView = editor('motion', `<ui-stack><ui-card><ui-stack><ui-text variant="eyebrow">Motion study</ui-text><div class="motion-track"><span data-motion-object aria-hidden="true"></span></div><ui-cluster><ui-button data-action="replay-motion"><ui-icon name="play"></ui-icon>Replay motion</ui-button><ui-text variant="caption" data-motion-readout></ui-text></ui-cluster><ui-text variant="caption" data-motion-preference></ui-text></ui-stack></ui-card></ui-stack>`, `${fieldsFor(['response-time'])}<ui-disclosure summary="Shape the curve"><ui-stack>${fieldsFor(['response-curve'])}</ui-stack></ui-disclosure><ui-text variant="caption">Replay shows the shared curve. Try the popover to feel it in a real component. System reduced-motion preferences are respected.</ui-text><ui-disclosure summary="Try a popover">${documentationCard('ui-popover', null, true)}</ui-disclosure>`);
64
- const materialView = editor('material', `<ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor></ui-stack>`, `<ui-cluster>${renderPreset('material', 'refinement')}<ui-button data-action="restore-material">Reset material</ui-button></ui-cluster>${fieldsFor(['finish','edge-light','glow'])}<ui-text variant="caption" data-material-dependencies role="status"></ui-text><ui-disclosure summary="Light source"><ui-stack>${fieldsFor(['light-spread','light-strength'])}</ui-stack></ui-disclosure><ui-disclosure summary="Depth, glass and separation"><ui-stack>${fieldsFor(['outer-depth','inner-depth','glassiness','surface-separation','translucency'])}</ui-stack></ui-disclosure><ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-card','/04-expressive-surface')}${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure>`);
65
- const paletteView = refinement('palette', `<ui-cluster>${renderPreset('palette', 'refinement')}<ui-button data-action="restore-palette">Reset palette</ui-button></ui-cluster><div class="refinement-layout"><div class="palette-preview"><ui-stack>${paletteDetails}</ui-stack></div><ui-stack><ui-text variant="heading" as="h3">Base colors</ui-text><ui-text variant="caption">The colors your theme’s light and dark palettes are built from.</ui-text>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}<ui-disclosure summary="Adjust color character"><ui-stack>${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}</ui-stack></ui-disclosure><ui-disclosure summary="Color relationships">${paletteRelationships}</ui-disclosure></ui-stack></div>`);
64
+ const materialSample = `<ui-card treatment="expressive"><ui-stack gap="m"><ui-text variant="heading" as="h3">Fellowship briefing</ui-text><ui-text>Readable content stays on an opaque plate while the decorative rim catches the scene.</ui-text><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input><ui-action-bar><ui-button slot="secondary" preset="standard">Keep draft</ui-button><ui-button slot="primary" preset="primary">Send briefing</ui-button></ui-action-bar></ui-stack></ui-card>`;
65
+ const lightEditor = `<ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-text variant="heading">Move the light</ui-text><ui-button preset="primary">Material response</ui-button></ui-stack></ui-light-editor>`;
66
+ const materialView = editor('material', materialSample, `<ui-cluster>${renderPreset('material', 'refinement')}<ui-button data-action="restore-material">Reset material</ui-button></ui-cluster>${fieldsFor(['finish','translucency','glassiness'])}<ui-text variant="caption" data-material-dependencies role="status"></ui-text><ui-stack gap="s" data-glass-hint hidden><ui-text variant="caption">Frosting needs some transparency to reveal the scene behind the decorative rim.</ui-text><ui-button size="sm" data-action="use-glass">Use frosted glass</ui-button></ui-stack><ui-disclosure summary="Advanced material" data-advanced="material"><ui-stack>${lightEditor}${fieldsFor(['light-color','light-spread','light-strength','edge-light','glow','outer-depth','inner-depth','surface-separation'])}</ui-stack></ui-disclosure><ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-card','/04-expressive-surface')}${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure>`);
67
+ const paletteView = refinement('palette', `<ui-cluster>${renderPreset('palette', 'refinement')}<ui-button data-action="restore-palette">Reset palette</ui-button></ui-cluster><div class="refinement-layout"><div class="palette-preview"><ui-stack>${paletteDetails}</ui-stack></div><ui-stack><ui-text variant="heading" as="h3">Base colors</ui-text><ui-text variant="caption">The colors your theme’s light and dark palettes are built from.</ui-text>${themeFields.filter(field => field.family === 'Colour' && field.type === 'colour').map(renderField).join('')}<ui-disclosure summary="Adjust color character"><ui-stack>${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}</ui-stack></ui-disclosure><ui-disclosure summary="Color relationships">${paletteRelationships}</ui-disclosure></ui-stack></div>`);
66
68
  const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-description="${escape(example.category + ' · ' + example.tag)}" data-example="${escape(JSON.stringify(example))}" data-search="${escape(`${example.title} ${example.tag} ${example.description} ${example.exampleTitle}`.toLocaleLowerCase())}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
67
- const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text','ui-light-editor','ui-combobox','ui-checkbox','ui-list','ui-list-item'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
69
+ const links = ['ui-action-bar','ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text','ui-light-editor','ui-combobox','ui-checkbox','ui-list','ui-list-item'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
68
70
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
69
71
  <state name="previews" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
70
72
  <state name="cards" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
@@ -92,6 +94,7 @@ export function customizerResource(site, input = defaultRecipe()) {
92
94
  <ui-button data-action="more-examples">Show more components</ui-button></ui-stack></section>
93
95
  `)}</ui-stack></div>
94
96
  <style>
97
+ :host { background-color: var(--ui-surface); background-image: repeating-linear-gradient(0deg, transparent 0 15px, color-mix(in srgb, var(--ui-text) 12%, transparent) 15px 16px), repeating-linear-gradient(90deg, transparent 0 15px, color-mix(in srgb, var(--ui-text) 12%, transparent) 15px 16px); background-attachment: fixed; }
95
98
  [data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
96
99
  .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
97
100
  aside { grid-row: 1 / span 2; position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); max-block-size: calc(100dvh - var(--_editor-chrome-height, 4.25rem) - var(--ui-space-4)); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; scroll-padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)); }
@@ -99,6 +102,7 @@ export function customizerResource(site, input = defaultRecipe()) {
99
102
  .refinement-layout { display: grid !important; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
100
103
  [data-refinement-view] { scroll-margin-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); }
101
104
  .palette-preview, .editor-preview { position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); max-block-size: calc(100dvh - var(--_editor-chrome-height, 4.25rem) - var(--ui-space-4)); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; }
105
+ [data-editor-preview="material"] { background: transparent; }
102
106
  .spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
103
107
  .spacing-samples > span { display: block !important; block-size: var(--ui-space-1); inline-size: var(--ui-space-1); border-radius: 50%; background: var(--ui-accent); }
104
108
  .spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
@@ -114,7 +118,7 @@ export function customizerResource(site, input = defaultRecipe()) {
114
118
  .palette-action { background: var(--_palette-action, var(--ui-accent)); }
115
119
  .motion-track { overflow: clip; display: flex; align-items: center; block-size: 5rem; padding: var(--ui-space-3); border-radius: var(--ui-radius-md); background: var(--ui-surface-muted); }
116
120
  [data-motion-object] { display: block; inline-size: 2rem; block-size: 2rem; border-radius: var(--ui-radius-sm); background: var(--ui-accent-solid); box-shadow: var(--ui-action-shadow); }
117
- @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 1; position: static; max-block-size: none; } .palette-preview, .editor-preview { position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); z-index: 1; background: var(--ui-surface); max-block-size: none; overflow: visible; padding: 0 !important; } [data-theme-sampler] { order: 2; } .examples { order: 3; } }
121
+ @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 1; position: static; max-block-size: none; } .palette-preview, .editor-preview { position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); z-index: 1; background: var(--ui-surface); max-block-size: none; overflow: visible; padding: 0 !important; } .editor-preview { position: static; } [data-theme-sampler] { order: 2; } .examples { order: 3; } }
118
122
  @media (max-width: 64rem) { .palette-preview[data-page-flow], .editor-preview[data-page-flow] { position: static; } }
119
123
  </style></template>`;
120
124
  }
@@ -131,6 +131,7 @@ export function connect(host) {
131
131
  componentRegistry.update(control, { value: control.dataset.fontPicker === 'display-font' && recipe.values['display-font'] === recipe.values['body-font'] ? 'inherit' : recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
132
132
  }
133
133
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
134
+ root.querySelector('[data-glass-hint]').hidden = !(Number(recipe.values.glassiness) > 0 && Number(recipe.values.translucency) === 0);
134
135
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
135
136
  const report = root.querySelector('[data-palette-adjustments]');
136
137
  report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your base colors stay editable; resolved values are shown below.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
@@ -283,6 +284,7 @@ export function connect(host) {
283
284
  break;
284
285
  }
285
286
  case 'back': navigate(''); break;
287
+ case 'use-glass': show(applyRefinement(recipe, 'material', 'glass')); sayApplied('Frosted glass applied. Your colors and type are unchanged.'); break;
286
288
  case 'restore-material': case 'restore-palette': {
287
289
  const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
288
290
  if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
package/docs/fonts.mjs CHANGED
@@ -4,7 +4,7 @@ export const googleFonts = Object.fromEntries([
4
4
  ['Source Sans 3', 'sans-serif'], ['Lato', 'sans-serif'], ['Nunito Sans', 'sans-serif'],
5
5
  ['DM Sans', 'sans-serif'], ['Manrope', 'sans-serif'], ['Work Sans', 'sans-serif'],
6
6
  ['IBM Plex Sans', 'sans-serif'], ['Public Sans', 'sans-serif'], ['Atkinson Hyperlegible', 'sans-serif'],
7
- ['Outfit', 'sans-serif'], ['Merriweather', 'serif'], ['Lora', 'serif'],
7
+ ['Outfit', 'sans-serif'], ['Fraunces', 'serif'], ['Merriweather', 'serif'], ['Lora', 'serif'],
8
8
  ['Source Serif 4', 'serif'], ['Libre Baskerville', 'serif'], ['Crimson Pro', 'serif'],
9
9
  ['EB Garamond', 'serif'], ['Playfair Display', 'serif'], ['Roboto Mono', 'monospace'],
10
10
  ['IBM Plex Mono', 'monospace'], ['JetBrains Mono', 'monospace'], ['Fira Code', 'monospace'],
package/docs/theme.mjs CHANGED
@@ -10,7 +10,7 @@ const bounds = {
10
10
 
11
11
  /** The shared authoring schema; ids remain stable in saved recipes. */
12
12
  export const themeFields = [
13
- ['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary commands and selected controls.'],
13
+ ['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary fill; readable labels and contextual action text are derived separately.'],
14
14
  ['Colour', 'canvas', 'Canvas', '--ui-surface', 'colour', '#f6f6f8', 'The ground behind surfaces.'],
15
15
  ['Colour', 'surface', 'Surface', '--ui-surface-raised', 'colour', '#ffffff', 'Fields, cards and floating surfaces.'],
16
16
  ['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
@@ -30,12 +30,13 @@ export const themeFields = [
30
30
  ['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
31
31
  ['Material', 'outer-depth', 'Outer depth', '--ui-outer-depth', 'nonnegative', '0.6', 'How strongly controls and floating surfaces rise above the page.'],
32
32
  ['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
33
- ['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Clear through richly frosted: the upper range adds stronger blur, saturation and glass edges. Content stays readable.'],
33
+ ['Material', 'glassiness', 'Frosting', '--ui-glassiness', 'fraction', '0', 'Backdrop blur on decorative glass, from none to frosted. Content remains opaque.'],
34
34
  ['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.'],
35
- ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating material around opaque content.'],
35
+ ['Material', 'translucency', 'Transparency', '--ui-overlay-translucency', 'fraction', '0', 'How much of the scene comes through decorative glass rims, from opaque to clear. Content remains opaque.'],
36
36
  ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
37
37
  ['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
38
38
  ['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Reflected light beyond a surface, from a quiet halo to a vivid bloom at the upper end.'],
39
+ ['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.'],
39
40
  ['Material', 'light-x', 'Light horizontal position', '--ui-light-x', 'fraction', '0.5', 'The light source from left to right, relative to each surface.'],
40
41
  ['Material', 'light-y', 'Light vertical position', '--ui-light-y', 'fraction', '0', 'The light source from top to bottom, relative to each surface.'],
41
42
  ['Material', 'light-spread', 'Light spread', '--ui-light-spread', 'fraction', '0.5', 'A concentrated spot through broad illumination.'],
@@ -44,21 +45,25 @@ export const themeFields = [
44
45
  ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
45
46
  ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(type === 'choice' ? { choices: { preserve: 'Preserve seed hues', neighboring: 'Neighboring hues', complementary: 'Complementary hues' } } : {}), ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
46
47
 
47
- export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
48
- const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
48
+ const baseRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
49
+ const starting = values => ({ schemaVersion: 1, values: { ...baseRecipe().values, ...values } });
50
+ /** The documentation starts with Looma's coordinated identity; Clear keeps the neutral baseline. */
51
+ export const defaultRecipe = () => ({ ...starting({ action: '#7658d6', canvas: '#fbf9f4', surface: '#fffdf8', text: '#2b2538', 'body-font': googleFontStack('Manrope'), 'display-font': googleFontStack('Manrope'), 'text-size': '17px' }), fonts: { 'body-font': 'Manrope', 'display-font': 'Manrope' } });
49
52
  export const startingPoints = {
50
- clear: defaultRecipe(),
53
+ looma: defaultRecipe(),
54
+ clear: baseRecipe(),
55
+ gold: { ...starting({ action: '#ffb84d', canvas: '#ffffff', surface: '#ffffff', text: '#171717', 'body-font': googleFontStack('Lato'), 'display-font': googleFontStack('Fraunces'), 'text-size': '17px', 'corner-roundness': '16px', finish: '0.55', 'light-x': '0' }), fonts: { 'body-font': 'Lato', 'display-font': 'Fraunces' } },
51
56
  bookish: starting({ rhythm: '1.05', action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
52
57
  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' }),
53
58
  editorial: starting({ rhythm: '1.3', 'text-size': '17px', spacing: '1.12', 'space-curve': '1.4', 'scale-contrast': '1.3', action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
54
59
  night: starting({ rhythm: '1.05', 'display-font': 'ui-monospace, monospace', 'text-weight': '500', 'control-roominess': '2.375', spacing: '1.05', 'scale-contrast': '1.25', 'space-curve': '1', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25', 'light-x': '0.25', 'light-spread': '0.65', action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
55
60
  garden: starting({ rhythm: '1.2', 'body-font': 'ui-rounded, system-ui, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'space-curve': '1.15', finish: '0.15', 'light-spread': '1', action: '#256342', canvas: '#f4edda', surface: '#fff8e7', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
56
61
  };
57
- export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
62
+ export const startingPointLabels = { looma: 'Looma', clear: 'Clear', gold: 'Gold', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
58
63
  const paletteKeys = themeFields.filter(field => field.family === 'Colour').map(field => field.id);
59
64
  // Palette choices move only the shared color seeds; size, material and motion stay independent.
60
65
  export const themePalettes = Object.fromEntries([
61
- ['clear', 'Indigo & mist'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
66
+ ['looma', 'Looma'], ['clear', 'Indigo & mist'], ['gold', 'Gold'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
62
67
  ['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
63
68
  ].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
64
69
 
@@ -124,7 +129,8 @@ export function validateRecipe(input) {
124
129
  if (!input || input.schemaVersion !== 1 || !input.values || typeof input.values !== 'object' || Array.isArray(input.values)) throw new Error('Unsupported theme recipe version or values.');
125
130
  const known = new Set(themeFields.map(field => field.id));
126
131
  for (const key of Object.keys(input.values)) if (!known.has(key)) throw new Error(`Unknown theme decision ${key}.`);
127
- const values = { ...defaultRecipe().values, ...input.values };
132
+ const defaults = defaultRecipe();
133
+ const values = { ...defaults.values, ...input.values };
128
134
  for (const field of themeFields) {
129
135
  const value = values[field.id];
130
136
  let valid = typeof value === 'string' && value.length > 0;
@@ -153,8 +159,10 @@ export function validateRecipe(input) {
153
159
  }
154
160
  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.');
155
161
  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.');
162
+ const inheritedFonts = Object.fromEntries(Object.entries(defaults.fonts ?? {}).filter(([role]) => !Object.hasOwn(input.values, role)));
163
+ const fonts = { ...inheritedFonts, ...input.fonts };
156
164
  if (input.origin !== undefined && !Object.hasOwn(startingPoints, input.origin)) throw new Error('Unknown theme origin.');
157
- return { schemaVersion: 1, values, ...(input.fonts === undefined ? {} : { fonts: { ...input.fonts } }), ...(input.origin === undefined ? {} : { origin: input.origin }), ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
165
+ return { schemaVersion: 1, values, ...(input.fonts === undefined && !Object.keys(fonts).length ? {} : { fonts }), ...(input.origin === undefined ? {} : { origin: input.origin }), ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
158
166
  }
159
167
 
160
168
  /** Match presets by their complete semantic recipe, including locally saved themes. */
@@ -164,15 +172,51 @@ export function startingPointFor(input) {
164
172
  }
165
173
 
166
174
  const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
167
- const luminance = hex => rgb(hex).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);
168
- const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
169
- // The brightest reflection stop is itself readable. Interpolating it over a readable
170
- // fill cannot wash out the label, even at maximum finish and light strength.
171
- function reflection(fill, text) {
172
- const labels = Array.isArray(text) ? text : [text];
173
- let amount = .6;
174
- while (amount > 0 && labels.some(label => contrast(mix('#ffffff', fill, amount), label) < 4.5)) amount = Math.max(0, amount - .005);
175
- return mix('#ffffff', fill, amount);
175
+ 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);
176
+ const luminance = hex => channelLuminance(rgb(hex));
177
+ const alphaSurface = (fill, background, alpha) => {
178
+ const a = rgb(fill), b = rgb(background);
179
+ return '#' + a.map((n, i) => Math.round(n * alpha + b[i] * (1 - alpha)).toString(16).padStart(2, '0')).join('');
180
+ };
181
+ const luminanceContrast = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
182
+ const contrast = (a, b) => luminanceContrast(luminance(a), luminance(b));
183
+ // Bound both material stops for opaque ink. The shade scales each sRGB channel
184
+ // down, so the darkest mixtures lie on its segment with the reflected light.
185
+ function materialLuminances(start, end) {
186
+ const a = rgb(start), b = rgb(end);
187
+ const at = t => channelLuminance(a.map((n, i) => n + (b[i] - n) * t));
188
+ let low = 0, high = 1;
189
+ for (let step = 0; step < 24; step++) {
190
+ const left = (2 * low + high) / 3, right = (low + 2 * high) / 3;
191
+ if (at(left) < at(right)) high = right; else low = left;
192
+ }
193
+ return [Math.min(at(0), at(1), at((low + high) / 2)), Math.max(at(0), at(1))];
194
+ }
195
+ function materialContrast(start, end, ink) {
196
+ const [minimum, maximum] = materialLuminances(start, end);
197
+ return ink < minimum ? luminanceContrast(minimum, ink) : ink > maximum ? luminanceContrast(maximum, ink) : 1;
198
+ }
199
+ function materialColours(fill, text, light, minimum = 4.55) {
200
+ const labels = (Array.isArray(text) ? text : [text]).map(label => typeof label === 'number' ? label : luminance(label));
201
+ const [l, c, h] = toLch(fill), [lightness] = toLch(light);
202
+ // Recede perceptually while retaining relative saturation and hue. Scaling
203
+ // L and C together scales linear RGB, keeping every shade channel <= fill.
204
+ let depth = .05;
205
+ const shaded = amount => amount === 0 ? fill : fromLch([Math.max(0, l - amount), c * Math.max(0, l - amount) / (l || 1), h]);
206
+ while (depth > 0 && labels.some(label => luminanceContrast(luminance(shaded(depth)), label) < minimum)) depth = Math.max(0, depth - .001);
207
+ const shade = shaded(depth);
208
+ // Environment influences reflected hue, not the pigment's saturation. Neutral
209
+ // light lifts lightness without the broad white mixture that washes colour out.
210
+ const [, tintChroma, tintHue] = toLch(mix(light, fill, .18));
211
+ const target = fromLch([l + (lightness - l) * .16, Math.max(c * .95, tintChroma), tintHue]);
212
+ let amount = 1;
213
+ while (amount > 0 && labels.some(label => materialContrast(mix(target, fill, amount), shade, label) < minimum)) amount = Math.max(0, amount - .005);
214
+ const reflection = mix(target, fill, amount);
215
+ // A tiny specular peak is allowed to brighten separately from the chromatic
216
+ // body reflection. Keep the same quantization margin as every material stop.
217
+ let glare = .55 * lightness;
218
+ while (glare > 0 && labels.some(label => luminanceContrast(luminance(alphaSurface('#ffffff', reflection, glare)), label) < minimum)) glare = Math.max(0, glare - .005);
219
+ return { reflection, shade, specular: alphaSurface('#ffffff', reflection, glare) };
176
220
  }
177
221
  const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
178
222
 
@@ -187,6 +231,20 @@ function readableColour(seed, surfaces, minimum, endpoint) {
187
231
  }
188
232
 
189
233
  /** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
234
+ // Perceptual distance in Cartesian Oklab, including chroma-only separation.
235
+ const colourDistance = (a, b) => {
236
+ const [l1, c1, h1] = toLch(a), [l2, c2, h2] = toLch(b);
237
+ return Math.sqrt((l1 - l2) ** 2 + (c1 - c2) ** 2 + 2 * c1 * c2 * (1 - Math.cos((h1 - h2) * Math.PI / 180)));
238
+ };
239
+ const surfaceOffset = (surface, ink, distance) => mix(ink, surface, Math.min(1, distance / Math.max(.001, colourDistance(surface, ink))));
240
+ const actionBoundary = (fill, surfaces, ink, amplitude) => {
241
+ const distance = Math.min(...surfaces.map(surface => colourDistance(fill, surface)));
242
+ const shortfall = Math.max(0, 1 - distance / amplitude);
243
+ // Blend assistance in continuously. Searching for an exact distance along an
244
+ // ink path can jump when that path initially reduces chroma separation.
245
+ return mix(surfaceOffset(surfaces[1], ink, amplitude), fill, shortfall ** 2);
246
+ };
247
+
190
248
  const paletteCache = new Map();
191
249
  export function deriveTheme(input, { mode } = {}) {
192
250
  if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
@@ -197,7 +255,7 @@ export function deriveTheme(input, { mode } = {}) {
197
255
  const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
198
256
  }
199
257
  }
200
- const cacheKey = JSON.stringify([mode, ...paletteKeys.map(key => values[key])]);
258
+ const cacheKey = JSON.stringify([mode, ...paletteKeys.map(key => values[key]), values['light-color']]);
201
259
  if (paletteCache.has(cacheKey)) {
202
260
  const cached = paletteCache.get(cacheKey);
203
261
  return { tokens: { ...tokens, ...cached.tokens }, adjustments: cached.adjustments.map(item => ({ ...item })) };
@@ -219,29 +277,73 @@ export function deriveTheme(input, { mode } = {}) {
219
277
  for (const [tone, original] of Object.entries(tones)) {
220
278
  const [l, c, h] = toLch(original);
221
279
  const seed = tone === 'accent' ? original : fromLch([l, c * (.6 + Number(values['color-intensity']) * .8), h]);
222
- const fill = readableColour(seed, surfaces, 5, ink);
280
+ const fill = seed;
281
+ const onFill = foreground(fill, '#ffffff', '#000000');
223
282
  tokens[`--ui-${tone}`] = fill;
224
- tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
225
- tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
226
- tokens[`--ui-${tone}-reflection`] = reflection(fill, tokens[`--ui-on-${tone}`]);
227
- tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
228
- tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
283
+ tokens[`--ui-on-${tone}`] = onFill;
284
+ tokens[`--ui-${tone}-on-strong`] = onFill;
285
+ for (const [part, colour] of Object.entries(materialColours(fill, onFill, values['light-color']))) tokens[`--ui-${tone}-${part}`] = colour;
286
+ const stateTarget = onFill === '#000000' ? '#ffffff' : '#000000';
287
+ tokens[`--ui-${tone}-hover`] = mix(fill, stateTarget, .86);
288
+ tokens[`--ui-${tone}-active`] = mix(fill, stateTarget, .74);
229
289
  tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
230
290
  }
231
- // Readable roles cover the quiet tinted surfaces as well as the neutral family.
232
- const readingSurfaces = [...surfaces, ...Object.keys(tones).flatMap(tone => surfaces.map(background => mix(tokens[`--ui-${tone}`], background, .12)))];
291
+ // Quiet fills are actual alpha composites. At an extreme light/dark flip,
292
+ // reduce their shared tint before it would leave no readable contextual ink.
293
+ const quietSurfaces = strength => [...surfaces, ...Object.keys(tones).flatMap(tone => surfaces.flatMap(background => [
294
+ mix(tokens[`--ui-${tone}`], background, .12 * strength),
295
+ ...[.05, .12, .18].map(alpha => alphaSurface(tokens[`--ui-${tone}`], background, alpha * strength)),
296
+ ]))];
297
+ let tonalOpacity = 1;
298
+ while (tonalOpacity > 0 && quietSurfaces(tonalOpacity).some(background => contrast(ink, background) < 4.5)) tonalOpacity = Math.max(0, tonalOpacity - .01);
299
+ tokens['--ui-tonal-opacity'] = String(Number(tonalOpacity.toFixed(2)));
300
+ const readingSurfaces = quietSurfaces(tonalOpacity);
233
301
  for (const tone of Object.keys(tones)) {
234
302
  tokens[`--ui-${tone}-subtle-text`] = readableColour(tokens[`--ui-${tone}`], readingSurfaces, 4.5, ink);
235
303
  }
304
+ // Existing validation/editor hooks must receive a contextual text role, never
305
+ // a pressed fill. Preserve consumer overrides at their established boundaries.
306
+ tokens['--ui-field-danger'] = tokens['--ui-danger-subtle-text'];
307
+ for (const hook of ['code-keyword', 'link-color', 'toolbar-active-text', 'mention-color']) tokens[`--ui-editor-${hook}`] = tokens['--ui-accent-subtle-text'];
236
308
  const textSeed = nativeInk === ink ? seeds.text : mix(seeds.text, ink, .08);
237
309
  tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
238
310
  tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
239
311
  tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
240
- for (const [state, amount] of [['surface', .06], ['hover', .1], ['active', .14]]) {
241
- tokens[`--ui-neutral-${state}`] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], amount), [tokens['--ui-text']], 4.5, paper);
312
+ // One continuous action-family range: primary pigment determines the neutral
313
+ // companion and their boundaries. These are design calibration values, not an
314
+ // empirical attention score. A floor preserves near-surface action boundaries.
315
+ const separation = Math.min(...surfaces.map(surface => colourDistance(tokens['--ui-accent'], surface)));
316
+ const amplitude = .10 + .16 * separation / (separation + .20);
317
+ for (const tone of Object.keys(tones)) for (const state of ['', '-hover', '-active']) {
318
+ tokens[`--ui-${tone}${state}-border`] = actionBoundary(tokens[`--ui-${tone}${state}`], surfaces, ink, amplitude);
319
+ }
320
+ // Keep secondary contours quieter than the weakest resolved primary edge,
321
+ // including warm surfaces where an assisted edge can approach its backdrop.
322
+ const familyRange = Math.min(amplitude, ...['', '-hover', '-active'].flatMap(state => surfaces.map(surface =>
323
+ colourDistance(tokens[`--ui-accent${state}-border`], surface),
324
+ )));
325
+ for (const [state, fillScale, edgeScale] of [['surface', .25, .45], ['hover', .35, .55], ['active', .45, .65]]) {
326
+ tokens[`--ui-neutral-${state}`] = readableColour(surfaceOffset(tokens['--ui-surface-raised'], tokens['--ui-text'], familyRange * fillScale), [tokens['--ui-text']], 4.5, paper);
327
+ tokens[`--ui-neutral-${state === 'surface' ? '' : state + '-'}border`] = surfaceOffset(tokens['--ui-surface-raised'], tokens['--ui-text'], familyRange * edgeScale);
328
+ }
329
+ // Enclosure lines describe regions; native field affordances retain their
330
+ // independently resolved control border below.
331
+ tokens['--ui-border'] = surfaceOffset(tokens['--ui-surface-raised'], tokens['--ui-text'], familyRange * .20);
332
+ tokens['--ui-border-strong'] = surfaceOffset(tokens['--ui-surface-raised'], tokens['--ui-text'], familyRange * .35);
333
+ for (const [role, fill, labels] of [
334
+ ['surface', tokens['--ui-surface-raised'], [tokens['--ui-text'], tokens['--ui-text-secondary'], ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-subtle-text`])]],
335
+ ['neutral', tokens['--ui-neutral-surface'], tokens['--ui-text']],
336
+ ]) for (const [part, colour] of Object.entries(materialColours(fill, labels, values['light-color']))) tokens[`--ui-${role}-${part}`] = colour;
337
+ // Knobs are painted foreground surfaces, not text. Bound them against the
338
+ // continuous track luminance range, not just its colored endpoints (3:1).
339
+ for (const [role, fill, track, trackFill] of [
340
+ ['knob', tokens['--ui-text-secondary'], 'surface', tokens['--ui-surface-raised']],
341
+ ['knob-on-accent', tokens['--ui-on-accent'], 'accent', tokens['--ui-accent']],
342
+ ]) {
343
+ const [low, high] = materialLuminances(tokens[`--ui-${track}-reflection`], tokens[`--ui-${track}-shade`]);
344
+ const range = [Math.min(low, luminance(trackFill)), Math.max(high, luminance(trackFill), luminance(tokens[`--ui-${track}-specular`]))];
345
+ for (const [part, colour] of Object.entries(materialColours(fill, range, values['light-color'], 3.05))) tokens[`--ui-${role}-${part}`] = colour;
242
346
  }
243
- tokens['--ui-surface-reflection'] = reflection(tokens['--ui-surface-raised'], [tokens['--ui-text'], tokens['--ui-text-secondary'], ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-subtle-text`])]);
244
- tokens['--ui-neutral-reflection'] = reflection(tokens['--ui-neutral-surface'], tokens['--ui-text']);
245
347
  tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
246
348
  tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
247
349
  tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
@@ -250,14 +352,13 @@ export function deriveTheme(input, { mode } = {}) {
250
352
  tokens['--ui-control-placeholder'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-control-surface'], .5), [tokens['--ui-control-surface']], 4.5, ink);
251
353
  tokens['--ui-control-border'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .43), readingSurfaces, 3, ink);
252
354
  tokens['--ui-control-border-hover'] = tokens['--ui-control-border'];
253
- tokens['--ui-border-strong'] = tokens['--ui-control-border'];
254
355
  tokens['--ui-disabled-text'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .45);
255
356
  tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
256
357
  tokens['--ui-disabled-border'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .2);
257
358
  tokens['--ui-focus-ring'] = readableColour(tokens['--ui-accent'], readingSurfaces, 3, ink);
258
359
  tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
259
360
  tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
260
- for (const [id, property] of [['action', '--ui-accent'], ['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
361
+ for (const [id, property] of [['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
261
362
  if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
262
363
  }
263
364
  const colors = Object.fromEntries(Object.entries(tokens).filter(([key]) => !geometry.has(key) || /^#[\da-f]{6}$/i.test(tokens[key])));
@@ -285,7 +386,7 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
285
386
  if (/^#[\da-f]{6}$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
286
387
  else {
287
388
  const relative = relativeLengths.get(name);
288
- const resolved = relative ? value.replace(relative, `calc(${parseFloat(relative)} * var(--_ui-root-font-unit, 1rem))`) : value;
389
+ 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;
289
390
  geometry.push(` ${name}: ${resolved};`);
290
391
  }
291
392
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.41.2",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -92,12 +92,15 @@
92
92
  inset calc(-1 * var(--_ui-action-start)) -1px 0 light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.08))
93
93
  );
94
94
  --_ui-action-shadow: var(--_ui-action-edge), 0 1px 2px light-dark(rgb(0 0 0 / 0.10), rgb(0 0 0 / 0.20));
95
+ --_tone-border: var(--ui-accent-border, var(--ui-accent));
96
+ --_tone-hover-border: var(--ui-accent-hover-border, var(--ui-accent-hover));
97
+ --_tone-active-border: var(--ui-accent-active-border, var(--ui-accent-active));
95
98
  --_tone-highlight: var(--ui-accent-highlight, none);
96
99
  --_tone-glow: var(--ui-accent-glow, 0 0 transparent);
97
100
  --_hue: var(--ui-accent);
98
101
  --_hue-hover: var(--ui-accent-hover);
99
102
  --_hue-active: var(--ui-accent-active);
100
- --_hue-text: var(--ui-accent-active);
103
+ --_hue-text: var(--ui-accent-subtle-text);
101
104
  --_on-hue: var(--ui-on-accent);
102
105
 
103
106
  --_tone: var(--_hue);
@@ -135,9 +138,9 @@
135
138
  min-block-size: var(--ui-button-min-block-size, var(--ui-control-size-md));
136
139
  border-style: solid;
137
140
  border-width: var(--ui-border-width);
138
- border-color: var(--ui-button-border, var(--_tone));
141
+ border-color: var(--ui-button-border, var(--_tone-text));
139
142
  border-radius: var(--ui-button-radius, var(--ui-action-radius, var(--ui-radius-md)));
140
- background-color: var(--ui-button-surface, oklch(from var(--_tone) l c h / 5%));
143
+ background-color: var(--ui-button-surface, oklch(from var(--_tone) l c h / calc(5% * var(--ui-tonal-opacity, 1))));
141
144
  color: var(--ui-button-text, var(--_tone-text));
142
145
  font: inherit;
143
146
  font-weight: var(--ui-font-medium);
@@ -280,8 +283,8 @@
280
283
  /* A link is never :enabled, so interactive states test "not unavailable" instead: for a button that
281
284
  is exactly :enabled, at the same specificity, and it also matches a link that can be followed. */
282
285
  :scope:where([data-component~="ui-button"]):hover:not(:disabled, [aria-disabled="true"]) {
283
- border-color: var(--ui-button-hover-border, var(--_tone-hover));
284
- background-color: var(--ui-button-hover-surface, oklch(from var(--_tone) l c h / 12%));
286
+ border-color: var(--ui-button-hover-border, var(--_tone-text));
287
+ background-color: var(--ui-button-hover-surface, oklch(from var(--_tone) l c h / calc(12% * var(--ui-tonal-opacity, 1))));
285
288
  }
286
289
 
287
290
  :scope:where([data-component~="ui-button"])[data-appearance="card"][data-tone="neutral"] {
@@ -301,13 +304,13 @@
301
304
  /* Pressed reads as pressed in: the highlight goes, and the shadow moves inside. */
302
305
  :scope:where([data-component~="ui-button"]):active:not(:disabled, [aria-disabled="true"]) {
303
306
  scale: var(--ui-press-scale, 1);
304
- background-color: var(--ui-button-active-surface, oklch(from var(--_tone) l c h / 18%));
307
+ background-color: var(--ui-button-active-surface, oklch(from var(--_tone) l c h / calc(18% * var(--ui-tonal-opacity, 1))));
305
308
  background-image: none;
306
309
  box-shadow: var(--ui-pressed);
307
310
  }
308
311
 
309
312
  :scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"] {
310
- border-color: var(--_tone);
313
+ border-color: var(--ui-button-border, var(--_tone-border));
311
314
  background-color: var(--_tone);
312
315
  color: var(--_on-tone);
313
316
  background-image: var(--_tone-highlight);
@@ -316,14 +319,15 @@
316
319
  var(--_tone-glow);
317
320
  }
318
321
 
319
- :is(:scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"]):hover:not(:disabled, [aria-disabled="true"]) {
322
+ :scope:where([data-component~="ui-button"])[data-appearance="solid"]:hover:not(:disabled, [aria-disabled="true"]),
323
+ :scope:where([data-component~="ui-button"])[data-appearance="danger"]:hover:not(:disabled, [aria-disabled="true"]) {
320
324
  background-color: var(--_tone-hover);
321
- border-color: var(--_tone-hover);
325
+ border-color: var(--ui-button-hover-border, var(--_tone-hover-border));
322
326
  }
323
327
 
324
328
  :is(:scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"]):active:not(:disabled, [aria-disabled="true"]) {
325
329
  background-color: var(--_tone-active);
326
- border-color: var(--_tone-active);
330
+ border-color: var(--_tone-active-border);
327
331
  background-image: none;
328
332
  box-shadow: var(--ui-pressed);
329
333
  }
@@ -331,6 +335,9 @@
331
335
  /* Tone is the colour, variant is the volume. Every pairing works: a danger outline is a
332
336
  destructive secondary action, not a special case. */
333
337
  :scope:where([data-component~="ui-button"])[data-tone="neutral"] {
338
+ --_tone-border: var(--ui-neutral-border, var(--ui-neutral-surface));
339
+ --_tone-hover-border: var(--ui-neutral-hover-border, var(--ui-neutral-hover));
340
+ --_tone-active-border: var(--ui-neutral-active-border, var(--ui-neutral-active));
334
341
  --_tone-highlight: var(--ui-neutral-highlight, none);
335
342
  --_tone-glow: var(--ui-neutral-glow, 0 0 transparent);
336
343
  --_hue: var(--ui-text);
@@ -341,7 +348,7 @@
341
348
  }
342
349
 
343
350
  :scope:where([data-component~="ui-button"])[data-tone="neutral"][data-appearance="solid"] {
344
- border-color: var(--ui-button-border, var(--ui-control-border));
351
+ border-color: var(--ui-button-border, var(--_tone-border));
345
352
  background-color: var(--ui-button-surface, var(--ui-neutral-surface));
346
353
  color: var(--ui-button-text, var(--ui-text));
347
354
  }
@@ -358,13 +365,18 @@
358
365
  background-color: var(--ui-button-active-surface, var(--ui-neutral-active));
359
366
  }
360
367
  :scope:where([data-component~="ui-button"])[data-tone="neutral"][data-appearance="solid"]:hover:not(:disabled, [aria-disabled="true"]) {
368
+ border-color: var(--ui-button-hover-border, var(--_tone-hover-border));
361
369
  background-color: var(--ui-button-hover-surface, var(--ui-neutral-hover));
362
370
  }
363
371
  :scope:where([data-component~="ui-button"])[data-tone="neutral"][data-appearance="solid"]:active:not(:disabled, [aria-disabled="true"]) {
372
+ border-color: var(--_tone-active-border);
364
373
  background-color: var(--ui-button-active-surface, var(--ui-neutral-active));
365
374
  }
366
375
 
367
376
  :scope:where([data-component~="ui-button"])[data-tone="danger"], :scope:where([data-component~="ui-button"])[data-appearance="danger"] {
377
+ --_tone-border: var(--ui-danger-border, var(--ui-danger));
378
+ --_tone-hover-border: var(--ui-danger-hover-border, var(--ui-danger-hover));
379
+ --_tone-active-border: var(--ui-danger-active-border, var(--ui-danger-active));
368
380
  --_tone-highlight: var(--ui-danger-highlight, none);
369
381
  --_tone-glow: var(--ui-danger-glow, 0 0 transparent);
370
382
  --_hue: var(--ui-danger);
@@ -375,6 +387,9 @@
375
387
  }
376
388
 
377
389
  :scope:where([data-component~="ui-button"])[data-tone="success"] {
390
+ --_tone-border: var(--ui-success-border, var(--ui-success));
391
+ --_tone-hover-border: var(--ui-success-hover-border, var(--ui-success-hover));
392
+ --_tone-active-border: var(--ui-success-active-border, var(--ui-success-active));
378
393
  --_tone-highlight: var(--ui-success-highlight, none);
379
394
  --_tone-glow: var(--ui-success-glow, 0 0 transparent);
380
395
  --_hue: var(--ui-success);
@@ -385,6 +400,9 @@
385
400
  }
386
401
 
387
402
  :scope:where([data-component~="ui-button"])[data-tone="warning"] {
403
+ --_tone-border: var(--ui-warning-border, var(--ui-warning));
404
+ --_tone-hover-border: var(--ui-warning-hover-border, var(--ui-warning-hover));
405
+ --_tone-active-border: var(--ui-warning-active-border, var(--ui-warning-active));
388
406
  --_tone-highlight: var(--ui-warning-highlight, none);
389
407
  --_tone-glow: var(--ui-warning-glow, 0 0 transparent);
390
408
  --_hue: var(--ui-warning);
@@ -395,6 +413,9 @@
395
413
  }
396
414
 
397
415
  :scope:where([data-component~="ui-button"])[data-tone="info"] {
416
+ --_tone-border: var(--ui-info-border, var(--ui-info));
417
+ --_tone-hover-border: var(--ui-info-hover-border, var(--ui-info-hover));
418
+ --_tone-active-border: var(--ui-info-active-border, var(--ui-info-active));
398
419
  --_tone-highlight: var(--ui-info-highlight, none);
399
420
  --_tone-glow: var(--ui-info-glow, 0 0 transparent);
400
421
  --_hue: var(--ui-info);
@@ -440,7 +461,7 @@
440
461
 
441
462
  /* Tints derive from the text colour, not a surface token, so hover and press show on any background. */
442
463
  :scope:where([data-component~="ui-button"])[data-appearance="ghost"]:hover:not(:disabled, [aria-disabled="true"]) {
443
- background-color: var(--ui-button-ghost-hover-surface, oklch(from var(--_tone) l c h / 10%));
464
+ background-color: var(--ui-button-ghost-hover-surface, oklch(from var(--_tone) l c h / calc(10% * var(--ui-tonal-opacity, 1))));
444
465
  border-color: transparent;
445
466
  }
446
467
 
@@ -169,7 +169,7 @@
169
169
 
170
170
  /* A link in the callout's text is underlined, so it reads as a link by more than colour. */
171
171
  .content :where([data-slotted], [data-slotted] *):is(a) {
172
- color: var(--ui-accent-active);
172
+ color: var(--ui-accent-subtle-text);
173
173
  text-decoration: underline;
174
174
  text-decoration-thickness: 0.06em;
175
175
  text-underline-offset: 0.18em;