@threadlabs/looma 0.34.0 → 0.35.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-badge/ui-badge.html +11 -3
- package/components/ui-button/ui-button.html +29 -4
- package/components/ui-card/ui-card.html +15 -13
- package/components/ui-checkbox/ui-checkbox.html +4 -2
- package/components/ui-dialog/ui-dialog.html +2 -0
- package/components/ui-icon-button/ui-icon-button.html +22 -8
- package/components/ui-input/ui-input.html +2 -1
- package/components/ui-input-group/ui-input-group.html +4 -1
- package/components/ui-light-editor/ui-light-editor.html +45 -0
- package/components/ui-light-editor/ui-light-editor.js +53 -0
- package/components/ui-menu/ui-menu.html +2 -0
- package/components/ui-number-input/ui-number-input.html +2 -1
- package/components/ui-number-input/ui-number-input.js +6 -3
- package/components/ui-popover/ui-popover.html +2 -0
- package/components/ui-select/ui-select.html +9 -7
- package/components/ui-switch/ui-switch.html +3 -0
- package/components/ui-textarea/ui-textarea.html +2 -1
- package/dist/index.js +24 -23
- package/docs/README.md +26 -2
- package/docs/catalog.json +566 -44
- package/docs/customizer-render.mjs +24 -11
- package/docs/customizer.js +67 -17
- package/docs/index.d.ts +4 -1
- package/docs/index.mjs +1 -1
- package/docs/render.mjs +1 -1
- package/docs/site.mjs +30 -20
- package/docs/theme-session.mjs +4 -2
- package/docs/theme.mjs +59 -7
- package/package.json +2 -2
- package/styles/ui-badge.css +11 -6
- package/styles/ui-button.css +29 -4
- package/styles/ui-card.css +9 -9
- package/styles/ui-checkbox.css +4 -2
- package/styles/ui-dialog.css +4 -0
- package/styles/ui-icon-button.css +22 -8
- package/styles/ui-input-group.css +4 -1
- package/styles/ui-input.css +2 -1
- package/styles/ui-light-editor.css +28 -0
- package/styles/ui-menu.css +2 -0
- package/styles/ui-popover.css +2 -0
- package/styles/ui-select.css +9 -7
- package/styles/ui-switch.css +3 -0
- package/styles/ui-textarea.css +2 -1
- package/theme-light.css +1 -0
- package/theme-system.css +43 -7
- package/tokens.css +4 -0
- package/vanilla/UiCard.d.ts +2 -1
- package/vanilla/UiCard.js +12 -12
- package/vanilla/UiLightEditor.d.ts +29 -0
- package/vanilla/UiLightEditor.js +102 -0
- package/vanilla/UiNumberInput.js +27 -20
- package/vanilla/index.js +1 -0
- package/vue/UiBadge.vue +29 -4
- package/vue/UiButton.vue +33 -4
- package/vue/UiCard.d.ts +8 -2
- package/vue/UiCard.vue +41 -17
- package/vue/UiCheckbox.vue +4 -2
- package/vue/UiDialog.vue +4 -0
- package/vue/UiIconButton.vue +26 -11
- package/vue/UiInput.vue +2 -1
- package/vue/UiInputGroup.vue +4 -1
- package/vue/UiLightEditor.d.ts +75 -0
- package/vue/UiLightEditor.js +2 -0
- package/vue/UiLightEditor.vue +402 -0
- package/vue/UiMenu.vue +2 -0
- package/vue/UiNumberInput.vue +32 -3
- package/vue/UiPopover.vue +2 -0
- package/vue/UiSelect.vue +16 -10
- package/vue/UiSwitch.vue +3 -0
- package/vue/UiTextarea.vue +2 -1
- package/vue/chunks/UiBadge.js +1 -1
- package/vue/chunks/UiButton.js +1 -1
- package/vue/chunks/UiCard.js +33 -5
- package/vue/chunks/UiCheckbox.js +1 -1
- package/vue/chunks/UiDialog.js +1 -1
- package/vue/chunks/UiIconButton.js +1 -1
- package/vue/chunks/UiInput.js +1 -1
- package/vue/chunks/UiInputGroup.js +1 -1
- package/vue/chunks/UiLightEditor.js +347 -0
- package/vue/chunks/UiMenu.js +1 -1
- package/vue/chunks/UiNumberInput.js +21 -8
- package/vue/chunks/UiPopover.js +1 -1
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/chunks/UiSwitch.js +1 -1
- package/vue/chunks/UiTextarea.js +1 -1
- package/vue/components.css +306 -66
- package/vue/index.d.ts +1 -0
- package/vue/index.js +2 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { escapeHtml as escape } from './catalog.mjs';
|
|
2
2
|
import { componentPath, previewPath } from './render.mjs';
|
|
3
3
|
import { valueEditor } from './value-editors.mjs';
|
|
4
|
-
import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes,
|
|
4
|
+
import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
|
|
5
5
|
|
|
6
6
|
export function customizerResource(site, input = defaultRecipe()) {
|
|
7
7
|
const recipe = validateRecipe(input);
|
|
8
8
|
const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
|
|
9
9
|
const examples = site.catalog.components.flatMap(component => component.examples.map(example => ({ id: example.id, title: component.title, description: component.description, exampleTitle: example.title, src: site.base + previewPath(example).slice(1), resizable: Boolean(example.resizable), category: component.group, tag: component.tag, packageName: component.package, nativeRoot: component.root ? `<${component.root}>` : '', href: site.base + componentPath(component).slice(1) })));
|
|
10
10
|
const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
|
|
11
|
-
const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-
|
|
11
|
+
const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-expressive-surface'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
|
|
12
12
|
if (!initial.length && examples.length) initial.push(toCard(examples[0]));
|
|
13
13
|
const renderField = field => {
|
|
14
14
|
const value = recipe.values[field.id], editor = valueEditor(field, value);
|
|
@@ -25,42 +25,55 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
25
25
|
}
|
|
26
26
|
return `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span>${control}</ui-form-field>`;
|
|
27
27
|
};
|
|
28
|
+
const renderPreset = (family, location) => {
|
|
29
|
+
const presets = family === 'material' ? materialPresets : themePalettes;
|
|
30
|
+
const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
|
|
31
|
+
return `<ui-form-field><label slot="label" for="${location}-${family}">${label} preset</label><ui-select id="${location}-${family}" data-refinement-preset="${family}" aria-label="${label} preset" value="${state.id}"><option value="custom" disabled>Your ${family}</option>${Object.entries(presets).map(([id, preset]) => `<option value="${id}">${escape(preset.label)}${state.id === id && state.customized ? ' · customized' : ''}</option>`).join('')}</ui-select></ui-form-field>`;
|
|
32
|
+
};
|
|
33
|
+
const paletteDetails = `<ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</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="caption">Fellowship office</ui-text></ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure>`;
|
|
28
34
|
const supplements = {
|
|
29
|
-
'Colour':
|
|
30
|
-
'Material':
|
|
35
|
+
'Colour': `${renderPreset('palette', 'overview')}<p>One coordinated palette generates readable light and dark ranges.</p><ui-button data-action="refine-palette">Refine palette</ui-button>`,
|
|
36
|
+
'Material': `${renderPreset('material', 'overview')}<p>Shared material and light shape the whole design system.</p><ui-button data-action="refine-material">Refine material & light</ui-button>`,
|
|
31
37
|
'Size & spacing': `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
32
38
|
};
|
|
33
|
-
const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'
|
|
34
|
-
const
|
|
35
|
-
const
|
|
39
|
+
const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id) && !['Colour','Material'].includes(family)).map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
|
|
40
|
+
const refinement = (family, content) => `<section data-refinement-view="${family}" hidden tabindex="-1" aria-label="${family === 'material' ? 'Material and light refinement' : 'Palette refinement'}"><ui-stack gap="xl"><ui-cluster justify="between"><ui-button data-action="back">Back to customization</ui-button><ui-button data-action="restore-${family}">Restore ${family} preset</ui-button></ui-cluster><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><p>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</p>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
|
|
41
|
+
const materialView = refinement('material', `<div class="refinement-layout"><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="sm"><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-card variant="elevated" treatment="expressive"><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">Second breakfast briefing</ui-text><ui-text>Sam has prepared the agenda. Frodo has the maps, and Sméagol is taking minutes.</ui-text><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="quiet">Read agenda</ui-button></ui-cluster></ui-card><ui-card variant="elevated"><ui-text variant="label">Standard elevated surface</ui-text><ui-text>Subtle lift stays independent from expressive treatment.</ui-text></ui-card></ui-stack><ui-stack><ui-text variant="label">Surface response</ui-text>${themeFields.filter(field => ['finish','edge-light','glow'].includes(field.id)).map(renderField).join('')}<ui-text variant="label">Light source</ui-text>${themeFields.filter(field => ['light-spread','light-strength'].includes(field.id)).map(renderField).join('')}<ui-disclosure summary="Depth, glass and separation"><ui-stack>${themeFields.filter(field => ['outer-depth','inner-depth','glassiness','surface-separation','translucency'].includes(field.id)).map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
|
|
42
|
+
const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}<ui-card><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">There and back by Friday</ui-text><ui-text>Choose a coordinated range, then refine action, paper and ink. Readable roles follow automatically in both modes.</ui-text><ui-cluster><ui-button preset="primary">Share route</ui-button><ui-button preset="standard">Save draft</ui-button></ui-cluster></ui-card></ui-stack><ui-stack>${themeFields.filter(field => field.family === 'Colour').map(renderField).join('')}</ui-stack></div>`);
|
|
43
|
+
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('');
|
|
44
|
+
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'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
|
|
36
45
|
return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
|
|
37
46
|
<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>
|
|
38
47
|
</defs>
|
|
39
48
|
<div data-looma-customizer data-recipe="${escape(JSON.stringify(recipe))}" data-base="${escape(site.base)}" data-mode="${site.config.local ? 'local' : 'public'}" data-revision="${escape(site.config.revision ?? '')}">
|
|
40
49
|
<ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Changes apply throughout your documentation and are remembered in this browser until reset. Save to repository writes the theme to your project; downloads let you use it elsewhere.</p>
|
|
41
|
-
<
|
|
50
|
+
<p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
|
|
51
|
+
${materialView}${paletteView}
|
|
52
|
+
<div class="customizer-layout" data-customizer-overview>
|
|
42
53
|
<aside><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
|
|
43
54
|
${families}
|
|
44
55
|
<ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
|
|
45
56
|
<input type="file" accept="application/json,.json" hidden data-recipe-file>
|
|
46
57
|
<ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
|
|
47
58
|
<ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
|
|
48
|
-
<p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
|
|
49
59
|
<ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
|
|
50
60
|
</ui-stack></aside>
|
|
51
|
-
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-
|
|
61
|
+
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-combobox id="example-choice" data-add-example label="Choose a component example" placeholder="Search buttons, fields, dialogs…" disclosure clearable>${options}</ui-combobox><ui-button data-action="add" disabled>Add to preview</ui-button>
|
|
52
62
|
<ui-grid columns="2" min="sm"><section $each="card of $cards" $key="$card.id"><looma-example-card ${cardFields.filter(name => name !== 'id').map(name => `from:${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="$card.${name}"`).join(' ')} from:identity="$card.id" loading="eager" removable></looma-example-card></section></ui-grid>
|
|
53
63
|
<noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
|
|
54
64
|
</ui-stack></section></div></ui-stack></div>
|
|
55
65
|
<style>
|
|
66
|
+
[data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
|
|
56
67
|
.customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
|
|
57
68
|
aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
|
|
58
69
|
.examples { min-inline-size: 0; }
|
|
70
|
+
.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; }
|
|
71
|
+
[data-refinement-view] { scroll-margin-block-start: 5rem; }
|
|
59
72
|
.palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
|
|
60
73
|
.palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
|
|
61
74
|
.palette-canvas { background: var(--ui-surface); }
|
|
62
75
|
.palette-content { background: var(--ui-surface-raised); }
|
|
63
76
|
.palette-action { background: var(--ui-accent); }
|
|
64
|
-
@media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
|
|
77
|
+
@media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
|
|
65
78
|
</style></template>`;
|
|
66
79
|
}
|
package/docs/customizer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { documentationTheme, themeEvent } from './theme-session.mjs';
|
|
2
2
|
import { componentRegistry } from '@nextwebwg/html-next/runtime';
|
|
3
|
-
import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes,
|
|
3
|
+
import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
|
|
4
4
|
|
|
5
5
|
import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
|
|
6
6
|
|
|
@@ -19,6 +19,8 @@ export function connect(host) {
|
|
|
19
19
|
let changed = false;
|
|
20
20
|
let saving = false;
|
|
21
21
|
let alive = true;
|
|
22
|
+
let selectedExample = '';
|
|
23
|
+
let refinementTrigger;
|
|
22
24
|
const abort = new AbortController();
|
|
23
25
|
const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
|
|
24
26
|
const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
|
|
@@ -41,13 +43,21 @@ export function connect(host) {
|
|
|
41
43
|
}
|
|
42
44
|
};
|
|
43
45
|
const paint = () => {
|
|
44
|
-
const css = themeCSS(recipe);
|
|
45
|
-
|
|
46
|
+
const css = theme?.css || themeCSS(recipe);
|
|
47
|
+
for (const family of ['material','palette']) {
|
|
48
|
+
const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
|
|
49
|
+
for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
|
|
50
|
+
for (const option of select.options) if (presets[option.value]) option.textContent = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
|
|
51
|
+
updateControl(select, state.id);
|
|
52
|
+
}
|
|
53
|
+
componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
|
|
54
|
+
}
|
|
55
|
+
componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
|
|
46
56
|
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
47
57
|
const report = root.querySelector('[data-palette-adjustments]');
|
|
48
58
|
report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
49
59
|
root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
50
|
-
for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css
|
|
60
|
+
for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
|
|
51
61
|
updateControl(root.querySelector('[data-css]'), css);
|
|
52
62
|
};
|
|
53
63
|
const show = (next, persist = true) => {
|
|
@@ -61,24 +71,35 @@ export function connect(host) {
|
|
|
61
71
|
};
|
|
62
72
|
const filterExamples = () => {
|
|
63
73
|
const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
64
|
-
const
|
|
65
|
-
const options = [...select.options];
|
|
66
|
-
for (const option of options) option.hidden = !words.every(word => option.dataset.search.includes(word));
|
|
67
|
-
for (const group of select.querySelectorAll('optgroup')) group.hidden = [...group.children].every(option => option.hidden);
|
|
68
|
-
if (select.selectedOptions[0]?.hidden) select.value = options.find(option => !option.hidden)?.value ?? '';
|
|
74
|
+
const options = [...root.querySelectorAll('[data-add-example] option')];
|
|
69
75
|
for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
|
|
70
|
-
const matches = options.filter(option =>
|
|
76
|
+
const matches = options.filter(option => words.every(word => option.dataset.search.includes(word))).length;
|
|
71
77
|
root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
|
|
72
|
-
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled:
|
|
78
|
+
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
|
|
79
|
+
};
|
|
80
|
+
const refinedRecipe = values => {
|
|
81
|
+
const presets = { ...recipe.presets };
|
|
82
|
+
for (const family of ['material', 'palette']) {
|
|
83
|
+
const state = refinementState(recipe, family);
|
|
84
|
+
if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
|
|
85
|
+
}
|
|
86
|
+
return { ...recipe, presets, values };
|
|
73
87
|
};
|
|
74
88
|
const input = event => {
|
|
75
89
|
if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
|
|
90
|
+
if (event.target.matches('[data-light-editor]')) {
|
|
91
|
+
try {
|
|
92
|
+
show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
|
|
93
|
+
sayApplied('Light position remembered throughout your design system.');
|
|
94
|
+
} catch (error) { say(error.message, true); }
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
76
97
|
const control = event.target.closest?.('[data-token]');
|
|
77
98
|
if (!control) return;
|
|
78
99
|
const field = fields.get(control.dataset.token);
|
|
79
100
|
const raw = event.detail?.value ?? control.value;
|
|
80
101
|
const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
|
|
81
|
-
const next = {
|
|
102
|
+
const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
|
|
82
103
|
try {
|
|
83
104
|
recipe = validateRecipe(next);
|
|
84
105
|
theme?.apply(recipe); changed = true;
|
|
@@ -99,12 +120,31 @@ export function connect(host) {
|
|
|
99
120
|
if (!button || (button.dataset.action === 'save' && saving)) return;
|
|
100
121
|
try {
|
|
101
122
|
switch (button.dataset.action) {
|
|
123
|
+
case 'refine-material': case 'refine-palette': {
|
|
124
|
+
const family = button.dataset.action.slice(7);
|
|
125
|
+
refinementTrigger = button;
|
|
126
|
+
root.querySelector('[data-customizer-overview]').hidden = true;
|
|
127
|
+
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
|
|
128
|
+
const section = root.querySelector(`[data-refinement-view="${family}"]`);
|
|
129
|
+
section.focus(); section.scrollIntoView({ block: 'start' });
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'back':
|
|
133
|
+
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
|
|
134
|
+
root.querySelector('[data-customizer-overview]').hidden = false;
|
|
135
|
+
refinementTrigger?.focus(); break;
|
|
136
|
+
case 'restore-material': case 'restore-palette': {
|
|
137
|
+
const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
|
|
138
|
+
if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
102
141
|
case 'add': {
|
|
103
|
-
const
|
|
104
|
-
|
|
142
|
+
const option = [...root.querySelectorAll('[data-add-example] option')].find(option => option.value === selectedExample);
|
|
143
|
+
if (!option) break;
|
|
105
144
|
const selected = JSON.parse(option.dataset.example);
|
|
106
145
|
const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
|
|
107
146
|
if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
|
|
147
|
+
say(`${card.title} · ${card.exampleTitle} added to preview.`);
|
|
108
148
|
view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
|
|
109
149
|
}
|
|
110
150
|
case 'css': {
|
|
@@ -144,7 +184,11 @@ export function connect(host) {
|
|
|
144
184
|
sayApplied(`${field.label} units updated and remembered in this browser.`);
|
|
145
185
|
} catch (error) { updateUnits(); say(error.message, true); }
|
|
146
186
|
}
|
|
147
|
-
if (event.target.matches('[data-
|
|
187
|
+
if (event.target.matches('[data-refinement-preset]')) {
|
|
188
|
+
const family = event.target.dataset.refinementPreset;
|
|
189
|
+
show(applyRefinement(recipe, family, event.target.value));
|
|
190
|
+
sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
|
|
191
|
+
}
|
|
148
192
|
if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
|
|
149
193
|
if (event.target === file && file.files[0]) {
|
|
150
194
|
try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
|
|
@@ -157,9 +201,15 @@ export function connect(host) {
|
|
|
157
201
|
const ready = event => {
|
|
158
202
|
if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
|
|
159
203
|
};
|
|
160
|
-
const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
|
|
204
|
+
const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
|
|
205
|
+
const selectExample = event => {
|
|
206
|
+
if (!event.target.matches('[data-add-example]')) return;
|
|
207
|
+
selectedExample = event.detail.value ?? '';
|
|
208
|
+
filterExamples();
|
|
209
|
+
};
|
|
161
210
|
root.addEventListener('looma-example-remove', remove);
|
|
162
211
|
root.addEventListener('input', input);
|
|
212
|
+
root.addEventListener('value-change', selectExample);
|
|
163
213
|
root.addEventListener('blur', blur, true);
|
|
164
214
|
root.addEventListener('change', change);
|
|
165
215
|
root.addEventListener('click', click);
|
|
@@ -177,5 +227,5 @@ export function connect(host) {
|
|
|
177
227
|
root.querySelector('[data-action="save"]').hidden = false;
|
|
178
228
|
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
179
229
|
}
|
|
180
|
-
return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
|
|
230
|
+
return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
|
|
181
231
|
}
|
package/docs/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface DocumentationCatalog {
|
|
|
17
17
|
resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
|
|
18
18
|
}
|
|
19
19
|
export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
|
|
20
|
-
export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string
|
|
20
|
+
export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string>; presets?: Partial<Record<'material' | 'palette', string>> }
|
|
21
21
|
export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string; min?: number; max?: number }
|
|
22
22
|
export interface DocumentationConfiguration {
|
|
23
23
|
title?: string; base?: string; origin?: string; outDir?: string; content?: string;
|
|
@@ -50,6 +50,9 @@ export function previewProjectDocumentation(input?: DocumentationInput & { port?
|
|
|
50
50
|
export const themeFields: readonly ThemeField[];
|
|
51
51
|
export const startingPoints: Record<string, ThemeRecipe>;
|
|
52
52
|
export function defaultRecipe(): ThemeRecipe;
|
|
53
|
+
export const materialPresets: Record<string, { label: string; values: Record<string, string> }>;
|
|
54
|
+
export function applyRefinement(input: ThemeRecipe, family: 'material' | 'palette', id: string): ThemeRecipe;
|
|
55
|
+
export function refinementState(input: ThemeRecipe, family: 'material' | 'palette'): { id: string; customized: boolean };
|
|
53
56
|
export function validateRecipe(input: unknown): ThemeRecipe;
|
|
54
57
|
export interface ResolvedTheme { tokens: Record<string, string>; adjustments: { id: string; requested: string; resolved: string; reason: string }[] }
|
|
55
58
|
export function deriveTheme(input: ThemeRecipe, options?: { mode?: 'light' | 'dark' }): ResolvedTheme;
|
package/docs/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { readCatalog, discoverComponents, mergeCatalogs } from './catalog.mjs';
|
|
2
2
|
export { prepareDocumentation, documentationOptions, documentationPlugin, buildDocumentation } from './site.mjs';
|
|
3
|
-
export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS } from './theme.mjs';
|
|
3
|
+
export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS, materialPresets, applyRefinement, refinementState } from './theme.mjs';
|
|
4
4
|
export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
|
package/docs/render.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const literal = value => escape(value).replaceAll('{', '\\{');
|
|
|
8
8
|
const code = value => `<code>${literal(value ?? '—')}</code>`;
|
|
9
9
|
const table = (head, rows) => `<table><thead><tr>${head.map(h => `<th>${literal(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${value}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
10
10
|
export const exampleKey = id => createHash('sha256').update(id).digest('hex').slice(0, 20);
|
|
11
|
-
export const componentPath = component =>
|
|
11
|
+
export const componentPath = component => `/${component.group === 'editor' ? 'editor' : 'components'}/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
|
|
12
12
|
export const previewPath = example => `/_looma/preview/${exampleKey(example.id)}/`;
|
|
13
13
|
|
|
14
14
|
export function apiBody(api, prefix = '') {
|
package/docs/site.mjs
CHANGED
|
@@ -96,9 +96,10 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
96
96
|
const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
|
|
97
97
|
for (const component of combined.components) {
|
|
98
98
|
if (!component.navigationParent) {
|
|
99
|
-
const
|
|
99
|
+
const section = component.group === 'editor' ? ['editor'] : ['components', groupDirectories.get(component.group)];
|
|
100
|
+
const page = path.join(content, ...section, component.tag + '.md');
|
|
100
101
|
await write(page, referencePage(component, combined, relativeHref(page, card), base));
|
|
101
|
-
generatedReferences.add(
|
|
102
|
+
generatedReferences.add(`${component.group === 'editor' ? 'editor' : 'components'}/${component.tag}.md`);
|
|
102
103
|
}
|
|
103
104
|
for (const example of component.examples) {
|
|
104
105
|
const key = exampleKey(example.id);
|
|
@@ -123,6 +124,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
123
124
|
}).join('\n\n');
|
|
124
125
|
await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
|
|
125
126
|
await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
|
|
127
|
+
if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
|
|
126
128
|
await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
|
|
127
129
|
if (config.content) {
|
|
128
130
|
const authored = path.resolve(root, config.content);
|
|
@@ -134,7 +136,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
134
136
|
let exists = false;
|
|
135
137
|
try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
136
138
|
// A project's landing page and footer are branding/content, never API overrides.
|
|
137
|
-
if (exists && !['index.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
|
|
139
|
+
if (exists && !['index.md', 'editor.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
|
|
138
140
|
await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -147,7 +149,6 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
147
149
|
for (const component of combined.components) {
|
|
148
150
|
const target = componentPath(component);
|
|
149
151
|
redirects[`/components/${component.group}/${component.tag}/`] = target;
|
|
150
|
-
if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
|
|
151
152
|
if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
|
|
152
153
|
}
|
|
153
154
|
return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
|
|
@@ -220,20 +221,29 @@ export async function buildDocumentation({ root = process.cwd(), config = {}, ca
|
|
|
220
221
|
|
|
221
222
|
async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
|
|
222
223
|
const { build } = await tool('vite', site.root);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
224
|
+
// Preview entries and outputs are independent. Keep compiler memory bounded
|
|
225
|
+
// and retain the build lock until every in-flight bundle has settled.
|
|
226
|
+
const remaining = [...site.examples];
|
|
227
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(2, remaining.length) }, async () => {
|
|
228
|
+
try {
|
|
229
|
+
for (let example; (example = remaining.pop());) {
|
|
230
|
+
const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
|
|
231
|
+
let html = await readFile(htmlFile, 'utf8');
|
|
232
|
+
const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
|
|
233
|
+
if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
|
|
234
|
+
for (const [tag, url] of scripts) {
|
|
235
|
+
const input = path.join(outDir, url.slice(site.base.length));
|
|
236
|
+
const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
|
|
237
|
+
const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
|
|
238
|
+
const name = `${exampleKey(example.id)}.js`;
|
|
239
|
+
await writeOutput('_looma/runtime/' + name, code);
|
|
240
|
+
html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
|
|
241
|
+
}
|
|
242
|
+
html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
|
|
243
|
+
await writeFile(htmlFile, html);
|
|
244
|
+
}
|
|
245
|
+
} catch (error) { remaining.length = 0; throw error; }
|
|
246
|
+
}));
|
|
247
|
+
const failed = results.find(result => result.status === 'rejected');
|
|
248
|
+
if (failed) throw failed.reason;
|
|
239
249
|
}
|
package/docs/theme-session.mjs
CHANGED
|
@@ -8,6 +8,7 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
|
|
|
8
8
|
const key = `looma:documentation-theme:v1:${base}`;
|
|
9
9
|
let original = validateRecipe(baseline);
|
|
10
10
|
let selected;
|
|
11
|
+
let portableCSS = '';
|
|
11
12
|
let storageAvailable = true;
|
|
12
13
|
const style = view.document.createElement('style');
|
|
13
14
|
style.dataset.loomaDocumentationTheme = '';
|
|
@@ -15,9 +16,10 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
|
|
|
15
16
|
const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
|
|
16
17
|
try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
|
|
17
18
|
const paint = () => {
|
|
19
|
+
portableCSS = selected ? themeCSS(selected, systemCSS) : '';
|
|
18
20
|
view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
|
|
19
21
|
style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
|
|
20
|
-
view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css:
|
|
22
|
+
view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: portableCSS } }));
|
|
21
23
|
};
|
|
22
24
|
const persist = () => {
|
|
23
25
|
try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
|
|
@@ -27,7 +29,7 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
|
|
|
27
29
|
get recipe() { return structuredClone(selected ?? original); },
|
|
28
30
|
get hasOverride() { return selected !== undefined; },
|
|
29
31
|
get storageAvailable() { return storageAvailable; },
|
|
30
|
-
get css() { return
|
|
32
|
+
get css() { return portableCSS; },
|
|
31
33
|
apply(recipe) { selected = validateRecipe(recipe); persist(); },
|
|
32
34
|
reset() { selected = undefined; persist(); return structuredClone(original); },
|
|
33
35
|
baseline(recipe) { original = validateRecipe(recipe); },
|
package/docs/theme.mjs
CHANGED
|
@@ -27,6 +27,12 @@ export const themeFields = [
|
|
|
27
27
|
['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.'],
|
|
28
28
|
['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
|
|
29
29
|
['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
|
|
30
|
+
['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
|
|
31
|
+
['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Soft reflected light beyond a surface, derived from its tone.'],
|
|
32
|
+
['Material', 'light-x', 'Light horizontal position', '--ui-light-x', 'fraction', '0.5', 'The light source from left to right, relative to each surface.'],
|
|
33
|
+
['Material', 'light-y', 'Light vertical position', '--ui-light-y', 'fraction', '0', 'The light source from top to bottom, relative to each surface.'],
|
|
34
|
+
['Material', 'light-spread', 'Light spread', '--ui-light-spread', 'fraction', '0.5', 'A concentrated spot through broad illumination.'],
|
|
35
|
+
['Material', 'light-strength', 'Light strength', '--ui-light-strength', 'fraction', '0.65', 'Shared illumination; each material and component role determines its response.'],
|
|
30
36
|
['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
|
|
31
37
|
['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
|
|
32
38
|
].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
|
|
@@ -35,14 +41,12 @@ export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntri
|
|
|
35
41
|
const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
|
|
36
42
|
export const startingPoints = {
|
|
37
43
|
clear: defaultRecipe(),
|
|
38
|
-
bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', 'response-time': '150ms', 'response-curve': 'ease' }),
|
|
44
|
+
bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', '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' }),
|
|
39
45
|
wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
|
|
40
46
|
editorial: starting({ 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' }),
|
|
41
47
|
night: starting({ 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)' }),
|
|
42
48
|
garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
|
|
43
49
|
};
|
|
44
|
-
// Preserve the public historical startingPoints.library import without a duplicate UI preset.
|
|
45
|
-
Object.defineProperty(startingPoints, 'library', { value: startingPoints.bookish });
|
|
46
50
|
export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
|
|
47
51
|
const paletteKeys = ['action', 'canvas', 'surface', 'text'];
|
|
48
52
|
// Palette choices move only the shared color seeds; size, material and motion stay independent.
|
|
@@ -51,6 +55,33 @@ export const themePalettes = Object.fromEntries([
|
|
|
51
55
|
['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
|
|
52
56
|
].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
|
|
53
57
|
|
|
58
|
+
const materialKeys = themeFields.filter(field => field.family === 'Material').map(field => field.id);
|
|
59
|
+
const materialDefaults = defaultRecipe().values;
|
|
60
|
+
const material = (label, overrides) => ({ label, values: Object.fromEntries(materialKeys.map(key => [key, overrides[key] ?? materialDefaults[key]])) });
|
|
61
|
+
export const materialPresets = {
|
|
62
|
+
matte: material('Matte', { 'outer-depth': '0', 'inner-depth': '0' }),
|
|
63
|
+
soft: material('Soft', { 'outer-depth': '0.6', 'inner-depth': '0.3', 'edge-light': '0.3', finish: '0.2', 'light-spread': '0.8' }),
|
|
64
|
+
polished: material('Polished', { 'outer-depth': '1', 'inner-depth': '1', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9' }),
|
|
65
|
+
glass: material('Frosted glass', startingPoints.bookish.values),
|
|
66
|
+
};
|
|
67
|
+
const refinementPresets = family => family === 'material' ? materialPresets : family === 'palette' ? themePalettes : undefined;
|
|
68
|
+
|
|
69
|
+
/** Preset provenance is optional recipe data, never a second source of theme values. */
|
|
70
|
+
export function refinementState(input, family) {
|
|
71
|
+
const recipe = validateRecipe(input), presets = refinementPresets(family);
|
|
72
|
+
if (!presets) throw new Error('Unknown refinement family.');
|
|
73
|
+
const matches = preset => Object.entries(preset.values).every(([key, value]) => recipe.values[key] === value);
|
|
74
|
+
const id = recipe.presets?.[family] ?? Object.keys(presets).find(id => matches(presets[id])) ?? 'custom';
|
|
75
|
+
return { id, customized: id !== 'custom' && !matches(presets[id]) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply or restore one family without changing the rest of the design system. */
|
|
79
|
+
export function applyRefinement(input, family, id) {
|
|
80
|
+
const recipe = validateRecipe(input), preset = refinementPresets(family)?.[id];
|
|
81
|
+
if (!preset) throw new Error('Unknown refinement preset.');
|
|
82
|
+
return validateRecipe({ ...recipe, values: { ...recipe.values, ...preset.values }, presets: { ...recipe.presets, [family]: id } });
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
/** Identify a coordinated palette without coupling it to the rest of a recipe. */
|
|
55
86
|
export function paletteFor(input) {
|
|
56
87
|
const { values } = validateRecipe(input);
|
|
@@ -111,19 +142,28 @@ export function validateRecipe(input) {
|
|
|
111
142
|
if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
|
|
112
143
|
}
|
|
113
144
|
}
|
|
114
|
-
|
|
145
|
+
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.');
|
|
146
|
+
return { schemaVersion: 1, values, ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
/** Match presets by their complete semantic recipe, including locally saved themes. */
|
|
118
150
|
export function startingPointFor(input) {
|
|
119
|
-
const recipe = JSON.stringify(validateRecipe(input));
|
|
120
|
-
return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value) === recipe)?.[0] ?? 'custom';
|
|
151
|
+
const recipe = JSON.stringify(validateRecipe(input).values);
|
|
152
|
+
return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value.values) === recipe)?.[0] ?? 'custom';
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
|
124
156
|
const mix = (a, b, weight) => '#' + rgb(a).map((n, i) => Math.round(n * weight + rgb(b)[i] * (1 - weight)).toString(16).padStart(2, '0')).join('');
|
|
125
157
|
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);
|
|
126
158
|
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
|
|
159
|
+
// The brightest reflection stop is itself readable. Interpolating it over a readable
|
|
160
|
+
// fill cannot wash out the label, even at maximum finish and light strength.
|
|
161
|
+
function reflection(fill, text) {
|
|
162
|
+
const labels = Array.isArray(text) ? text : [text];
|
|
163
|
+
let amount = .32;
|
|
164
|
+
while (amount > 0 && labels.some(label => contrast(mix('#ffffff', fill, amount), label) < 4.5)) amount = Math.max(0, amount - .005);
|
|
165
|
+
return mix('#ffffff', fill, amount);
|
|
166
|
+
}
|
|
127
167
|
const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
|
|
128
168
|
|
|
129
169
|
/** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
|
|
@@ -164,6 +204,7 @@ export function deriveTheme(input, { mode } = {}) {
|
|
|
164
204
|
tokens[`--ui-${tone}`] = fill;
|
|
165
205
|
tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
|
|
166
206
|
tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
|
|
207
|
+
tokens[`--ui-${tone}-reflection`] = reflection(fill, tokens[`--ui-on-${tone}`]);
|
|
167
208
|
tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
|
|
168
209
|
tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
|
|
169
210
|
tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
|
|
@@ -177,6 +218,11 @@ export function deriveTheme(input, { mode } = {}) {
|
|
|
177
218
|
tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
|
|
178
219
|
tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
|
|
179
220
|
tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
|
|
221
|
+
for (const [state, amount] of [['surface', .06], ['hover', .1], ['active', .14]]) {
|
|
222
|
+
tokens[`--ui-neutral-${state}`] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], amount), [tokens['--ui-text']], 4.5, paper);
|
|
223
|
+
}
|
|
224
|
+
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`])]);
|
|
225
|
+
tokens['--ui-neutral-reflection'] = reflection(tokens['--ui-neutral-surface'], tokens['--ui-text']);
|
|
180
226
|
tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
|
|
181
227
|
tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
|
|
182
228
|
tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
|
|
@@ -212,8 +258,14 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
|
|
|
212
258
|
else geometry.push(` ${name}: ${value};`);
|
|
213
259
|
}
|
|
214
260
|
const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
|
|
261
|
+
const chevron = color => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='${color.replace('#', '%23')}' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`;
|
|
262
|
+
const boundary = mode => scope === 'documentation'
|
|
263
|
+
? `:root[data-looma-documentation-theme][data-theme="${mode}"], :root[data-looma-documentation-theme] [data-theme="${mode}"]`
|
|
264
|
+
: `[data-theme="${mode}"]`;
|
|
265
|
+
const chevrons = `${geometrySelector} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n@media (prefers-color-scheme: dark) { ${geometrySelector}:not([data-theme]) { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; } }\n${boundary('light')} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n${boundary('dark')} { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; }`;
|
|
266
|
+
|
|
215
267
|
// Resolve colors at their point of use, so a nearer mode boundary wins independently
|
|
216
268
|
// of the selector specificity needed to restore an early documentation theme.
|
|
217
269
|
// Keep the first comment stable: the local writer recognizes its generated files by this marker.
|
|
218
|
-
return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n`;
|
|
270
|
+
return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
|
|
219
271
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadlabs/looma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"@tiptap/pm": ">=2 <3",
|
|
128
128
|
"@tiptap/vue-3": ">=2 <3",
|
|
129
129
|
"vue": "^3.5.43",
|
|
130
|
-
"markupress": "^0.8.
|
|
130
|
+
"markupress": "^0.8.4",
|
|
131
131
|
"@nextwebwg/htmlkit": "1.0.0-alpha.69",
|
|
132
132
|
"vite": "^8.3.4"
|
|
133
133
|
},
|