@threadlabs/looma 0.41.1 → 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.
- package/components/ui-button/ui-button.html +33 -12
- package/components/ui-button/ui-button.js +2 -2
- package/components/ui-callout/ui-callout.html +1 -1
- package/components/ui-card/ui-card.html +7 -7
- package/components/ui-checkbox/ui-checkbox.html +1 -1
- package/components/ui-dialog/ui-dialog.html +8 -10
- package/components/ui-editor-mention-menu/ui-editor-mention-menu.html +1 -1
- package/components/ui-editor-mention-menu-item/ui-editor-mention-menu-item.html +1 -1
- package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +1 -1
- package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +2 -2
- package/components/ui-icon-button/ui-icon-button.html +45 -10
- package/components/ui-light-editor/ui-light-editor.html +1 -1
- package/components/ui-menu/ui-menu.html +8 -10
- package/components/ui-menu-item/ui-menu-item.html +1 -1
- package/components/ui-popover/ui-popover.html +9 -11
- package/components/ui-radio/ui-radio.html +53 -1
- package/components/ui-section/ui-section.html +1 -1
- package/components/ui-site-header/ui-site-header.html +6 -2
- package/components/ui-status-message/ui-status-message.html +1 -1
- package/components/ui-switch/ui-switch.html +5 -2
- package/components/ui-tabs/ui-tabs.html +3 -3
- package/components/ui-text/ui-text.html +2 -2
- package/components/ui-tooltip/ui-tooltip.html +8 -10
- package/dist/index.js +1 -1
- package/docs/README.md +5 -1
- package/docs/catalog.json +410 -35
- package/docs/customizer-render.mjs +9 -5
- package/docs/customizer.js +2 -0
- package/docs/fonts.mjs +1 -1
- package/docs/theme.mjs +136 -35
- package/package.json +1 -1
- package/styles/ui-button.css +33 -12
- package/styles/ui-callout.css +1 -1
- package/styles/ui-card.css +6 -6
- package/styles/ui-checkbox.css +1 -1
- package/styles/ui-dialog.css +17 -21
- package/styles/ui-editor-mention-menu-item.css +1 -1
- package/styles/ui-editor-mention-menu.css +1 -1
- package/styles/ui-editor-table-context-menu.css +1 -1
- package/styles/ui-editor-table-toolbar.css +2 -2
- package/styles/ui-icon-button.css +45 -10
- package/styles/ui-light-editor.css +2 -2
- package/styles/ui-menu-item.css +1 -1
- package/styles/ui-menu.css +9 -11
- package/styles/ui-popover.css +10 -12
- package/styles/ui-radio.css +62 -1
- package/styles/ui-section.css +1 -1
- package/styles/ui-site-header.css +6 -2
- package/styles/ui-status-message.css +1 -1
- package/styles/ui-switch.css +5 -2
- package/styles/ui-tabs.css +3 -3
- package/styles/ui-text.css +2 -2
- package/styles/ui-tooltip.css +9 -11
- package/theme-system.css +10 -8
- package/tokens.css +23 -0
- package/vue/UiButton.vue +45 -12
- package/vue/UiCallout.vue +1 -1
- package/vue/UiCard.vue +7 -13
- package/vue/UiCheckbox.vue +1 -1
- package/vue/UiDialog.vue +21 -45
- package/vue/UiEditorMentionMenu.vue +1 -1
- package/vue/UiEditorMentionMenuItem.vue +1 -1
- package/vue/UiEditorTableContextMenu.vue +1 -1
- package/vue/UiEditorTableToolbar.vue +2 -2
- package/vue/UiIconButton.vue +45 -10
- package/vue/UiLightEditor.vue +2 -2
- package/vue/UiMenu.vue +11 -21
- package/vue/UiMenuItem.vue +1 -1
- package/vue/UiPopover.vue +12 -22
- package/vue/UiRadio.vue +57 -1
- package/vue/UiSection.vue +1 -1
- package/vue/UiSiteHeader.vue +6 -2
- package/vue/UiStatusMessage.vue +1 -1
- package/vue/UiSwitch.vue +5 -2
- package/vue/UiTabs.vue +3 -3
- package/vue/UiText.vue +2 -2
- package/vue/UiTooltip.vue +11 -21
- package/vue/chunks/UiButton.js +1 -1
- package/vue/chunks/UiCallout.js +1 -1
- package/vue/chunks/UiCard.js +2 -2
- package/vue/chunks/UiCheckbox.js +1 -1
- package/vue/chunks/UiDialog.js +1 -1
- package/vue/chunks/UiEditorMentionMenu.js +1 -1
- package/vue/chunks/UiEditorMentionMenuItem.js +1 -1
- package/vue/chunks/UiEditorTableContextMenu.js +1 -1
- package/vue/chunks/UiEditorTableToolbar.js +1 -1
- package/vue/chunks/UiIconButton.js +1 -1
- package/vue/chunks/UiLightEditor.js +1 -1
- package/vue/chunks/UiMenu.js +1 -1
- package/vue/chunks/UiMenuItem.js +1 -1
- package/vue/chunks/UiPopover.js +1 -1
- package/vue/chunks/UiRadio.js +1 -1
- package/vue/chunks/UiSection.js +1 -1
- package/vue/chunks/UiSiteHeader.js +1 -1
- package/vue/chunks/UiStatusMessage.js +1 -1
- package/vue/chunks/UiSwitch.js +1 -1
- package/vue/chunks/UiTabs.js +1 -1
- package/vue/chunks/UiText.js +1 -1
- package/vue/chunks/UiTooltip.js +1 -1
- package/vue/components.css +348 -235
- 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"
|
|
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
|
|
65
|
-
const
|
|
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
|
}
|
package/docs/customizer.js
CHANGED
|
@@ -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
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
48
|
-
const starting = values => ({ schemaVersion: 1, 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
|
-
|
|
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
|
|
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
|
|
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
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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 =
|
|
280
|
+
const fill = seed;
|
|
281
|
+
const onFill = foreground(fill, '#ffffff', '#000000');
|
|
223
282
|
tokens[`--ui-${tone}`] = fill;
|
|
224
|
-
tokens[`--ui-on-${tone}`] =
|
|
225
|
-
tokens[`--ui-${tone}-on-strong`] =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
tokens[`--ui-${tone}-
|
|
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
|
-
//
|
|
232
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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 [['
|
|
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
package/styles/ui-button.css
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
:
|
|
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(--
|
|
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
|
|
package/styles/ui-callout.css
CHANGED
|
@@ -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-
|
|
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;
|