@threadlabs/looma 0.39.1 → 0.39.2
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 +24 -3
- package/components/ui-checkbox/ui-checkbox.html +34 -18
- package/components/ui-light-editor/ui-light-editor.html +26 -25
- package/dist/index.js +1 -1
- package/docs/README.md +17 -6
- package/docs/catalog.json +2 -2
- package/docs/customizer-render.mjs +37 -33
- package/docs/customizer.js +82 -47
- package/docs/site.mjs +1 -1
- package/docs/theme-session.mjs +7 -8
- package/docs/theme.mjs +2 -20
- package/package.json +1 -1
- package/styles/ui-button.css +24 -1
- package/styles/ui-checkbox.css +39 -19
- package/styles/ui-light-editor.css +111 -0
- package/tokens.css +9 -2
- package/vanilla/UiButton.js +14 -12
- package/vanilla/UiLightEditor.js +2 -2
- package/vue/UiButton.vue +28 -7
- package/vue/UiCheckbox.vue +34 -19
- package/vue/UiLightEditor.vue +156 -40
- package/vue/chunks/UiButton.js +18 -10
- package/vue/chunks/UiCheckbox.js +1 -1
- package/vue/chunks/UiLightEditor.js +66 -58
- package/vue/components.css +232 -42
- package/vue/editor/index.js +46 -36
package/docs/README.md
CHANGED
|
@@ -86,17 +86,28 @@ The generated values follow rules. The medium control height is base text size t
|
|
|
86
86
|
|
|
87
87
|
Choose a coordinated palette, then refine surface warmth, color intensity and palette relationship. Warmth runs from cool to warm paper and ink, with 0.5 neutral; intensity runs from quiet to vivid, with 0.5 retaining original seed intensity. Keep my colors preserves the base hues; Related colors and Contrasting colors tint quiet surfaces toward neighboring and opposite hues. These decisions regenerate paired light/dark colors through Oklab/OKLCH operations, gamut repair and readability checks. They do not transform existing CSS colors live. Four Base colors remain visible for precise edits; the light/dark range previews and resolved values explain adjustments.
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
Documentation navigation stays out of the customization workspace. **Material & light** opens a movable light source, real component samples and bounded surface controls. **Palette** opens the coordinated light/dark ranges and their four Base colors. Each editor changes the same persistent theme. Returning to the overview, navigating to a component, or reloading keeps the result.
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
A refined preset keeps its name with “customized” beside it. **Reset material** resets only material and light; **Reset palette** resets only the palette. These family choices are recorded in the recipe's optional `presets` mapping. The `values` mapping remains the complete source of the generated theme; a preset is a named combination of those values.
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Number controls accept arithmetic such as `1rem / 1.2` or `150ms * 2`, calculate on entry and show the result. Unit changes preserve the quantity. Sliders include exact entry, and curve handles support dragging and arrow keys alongside numeric coordinates. Light and curve drags preserve the grab offset; Escape or pointer cancellation restores the gesture start without creating a saved edit.
|
|
93
|
+
Number controls accept arithmetic such as `1rem / 1.2` or `150ms * 2`, calculate on entry and show the result. Unit changes preserve the quantity. Sliders include exact entry, and curve handles support dragging and arrow keys alongside numeric coordinates. LightEditor keeps its coordinate fields under **Exact position**. Light and curve drags preserve the grab offset; Escape or pointer cancellation restores the gesture start without creating a saved edit.
|
|
96
94
|
|
|
97
95
|
Lengths retain their CSS units, such as `rem`; times retain `ms` or `s`. The downloaded CSS includes the canonical shared CSS derivation rules as well as resolved theme values. Download JSON separately to retain editable decisions and font/preset provenance. Import a downloaded JSON recipe to continue editing it. Keep hand-written CSS in a separate overrides file so regenerating the theme does not replace it.
|
|
98
96
|
|
|
99
|
-
|
|
97
|
+
The customizer opens on a dashboard summarizing the current starting theme, palette,
|
|
98
|
+
material, typography, spacing and motion. Open a summary to edit that subject; use
|
|
99
|
+
**Overview** to return to the same dashboard position. Only the active editor shows
|
|
100
|
+
its controls. Precise adjustments sit inside the relevant editor's detail sections.
|
|
101
|
+
|
|
102
|
+
Typography pairs font selection with headings and body text. Size & spacing shows
|
|
103
|
+
the shared control ladder and grouped content. Motion keeps a replayable study and
|
|
104
|
+
a real popover beside duration and curve editing; the study follows the shared
|
|
105
|
+
curve, travels at three times the feedback duration, and respects reduced motion.
|
|
106
|
+
**Browse components** searches the complete catalog, with previews loaded in batches.
|
|
107
|
+
**Export & save** contains downloads, recipe import and local repository saving.
|
|
108
|
+
|
|
109
|
+
Edits apply immediately. Undo and Redo move through completed changes; Reset restores
|
|
110
|
+
the current starting theme. There is no separate variation preview or apply step.
|
|
100
111
|
|
|
101
112
|
Changes apply throughout the documentation and remain in this browser across navigation and reload until reset. Repository files change only when you save locally. Starting points update the shell and all selected cards. Reset restores the selected starting theme’s values and remains undoable. Removing the header’s Custom theme chip instead clears the browser override and restores the repository baseline. Invalid input keeps the last valid preview and explains which decision needs attention.
|
|
102
113
|
|
package/docs/catalog.json
CHANGED
|
@@ -458,7 +458,7 @@
|
|
|
458
458
|
],
|
|
459
459
|
"schemaVersion": 1,
|
|
460
460
|
"package": "@threadlabs/looma",
|
|
461
|
-
"version": "0.39.
|
|
461
|
+
"version": "0.39.2",
|
|
462
462
|
"groups": [
|
|
463
463
|
{
|
|
464
464
|
"id": "layout",
|
|
@@ -4220,7 +4220,7 @@
|
|
|
4220
4220
|
"name": "99-theme-sampler",
|
|
4221
4221
|
"title": "Theme sampler",
|
|
4222
4222
|
"description": "A compact collection of real controls and a surface for judging a shared theme together.",
|
|
4223
|
-
"markup": "<ui-card treatment=\"expressive\"
|
|
4223
|
+
"markup": "<ui-card treatment=\"expressive\"><ui-stack gap=\"l\">\n <ui-cluster gap=\"xs\" justify=\"between\"><ui-text variant=\"eyebrow\">Fellowship office</ui-text><ui-badge tone=\"success\">Ready</ui-badge></ui-cluster>\n <ui-stack gap=\"s\"><ui-text variant=\"heading\" as=\"h3\">Second breakfast, sorted.</ui-text>\n <ui-text>Frodo plans. Sam follows through.</ui-text>\n <ui-text variant=\"caption\">Briefing at nine · Bring the maps.</ui-text></ui-stack>\n <ui-cluster>\n <ui-button variant=\"solid\" tone=\"accent\" title=\"Solid accent\">Send</ui-button>\n <ui-button variant=\"outline\" tone=\"accent\" title=\"Outline accent\">Save</ui-button>\n <ui-button variant=\"solid\" tone=\"neutral\" title=\"Solid neutral\">File</ui-button>\n <ui-button variant=\"outline\" tone=\"neutral\" title=\"Outline neutral\">Archive</ui-button>\n <ui-icon-button variant=\"outline\" tone=\"neutral\" match-button label=\"Acknowledge memo\"><ui-icon name=\"circle-check\"></ui-icon></ui-icon-button>\n <ui-button variant=\"ghost\" tone=\"neutral\" title=\"Ghost neutral\">Later</ui-button>\n </ui-cluster>\n <ui-input-group><ui-icon slot=\"prefix\" name=\"search\"></ui-icon><ui-input\n aria-label=\"Search office\"\n placeholder=\"Search Fellowship office\"\n ></ui-input></ui-input-group>\n <ui-cluster><ui-checkbox checked>Notify Sam</ui-checkbox><ui-switch checked>Available</ui-switch><ui-badge tone=\"accent\">Draft</ui-badge></ui-cluster>\n </ui-stack></ui-card>",
|
|
4224
4224
|
"resizable": false,
|
|
4225
4225
|
"bindings": "",
|
|
4226
4226
|
"frameworks": {}
|
|
@@ -2,7 +2,7 @@ import { googleFonts, systemFont } from './fonts.mjs';
|
|
|
2
2
|
import { escapeHtml as escape } from './catalog.mjs';
|
|
3
3
|
import { componentPath, previewPath } from './render.mjs';
|
|
4
4
|
import { valueEditor } from './value-editors.mjs';
|
|
5
|
-
import { defaultRecipe, themeFields, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState
|
|
5
|
+
import { defaultRecipe, themeFields, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
|
|
6
6
|
|
|
7
7
|
export function customizerResource(site, input = defaultRecipe()) {
|
|
8
8
|
const recipe = validateRecipe(input);
|
|
@@ -21,7 +21,7 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
21
21
|
let control;
|
|
22
22
|
if (field.type === 'font') {
|
|
23
23
|
const selected = field.id === 'display-font' && value === recipe.values['body-font'] ? 'inherit' : recipe.fonts?.[field.id] ?? (value === systemFont ? 'system' : 'custom');
|
|
24
|
-
control = `<ui-combobox id="theme-${field.id}" data-font-picker="${field.id}" label="${escape(field.label)}" value="${escape(selected)}" placeholder="Search Google Fonts…" disclosure>${field.id === 'display-font' ? '<option value="inherit">Same as main font</option>' : ''}<option value="system">System font</option><option value="custom" disabled
|
|
24
|
+
control = `<ui-combobox id="theme-${field.id}" data-font-picker="${field.id}" label="${escape(field.label)}" value="${escape(selected)}" placeholder="Search Google Fonts…" disclosure>${field.id === 'display-font' ? '<option value="inherit">Same as main font</option>' : ''}<option value="system">System font</option><option value="custom" disabled>${escape(value.split(',')[0].replaceAll('"', '').trim())}</option>${Object.values(googleFonts).map(font => `<option value="${escape(font.family)}" data-description="Google Fonts · ${font.category}">${escape(font.family)}</option>`).join('')}</ui-combobox><ui-text variant="caption" data-font-status="${field.id}" role="status">Google fonts preview here and are included in your CSS.</ui-text><ui-disclosure summary="Custom font stack"><ui-input id="theme-${field.id}-custom" ${common} value="${escape(value)}" aria-label="${escape(field.label)} stack"></ui-input><ui-text variant="caption">For an already loaded or self-hosted font.</ui-text></ui-disclosure>`;
|
|
25
25
|
} else if (field.type === 'choice') {
|
|
26
26
|
control = `<ui-select id="theme-${field.id}" ${common} value="${escape(value)}">${Object.entries(field.choices).map(([id,label]) => `<option value="${id}">${escape(label)}</option>`).join('')}</ui-select>`;
|
|
27
27
|
} else if (field.type === 'easing') {
|
|
@@ -31,8 +31,9 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
31
31
|
const numeric = `<${tag} id="theme-${field.id}" ${common} data-editor-kind="${editor.kind}" value="${escape(editor.value)}" unit="${editor.unit}" min="${editor.min}" max="${editor.max}" step="${editor.step}"></${tag}>`;
|
|
32
32
|
control = editor.units ? `<ui-cluster wrap="nowrap" gap="xs">${numeric}<ui-select size="sm" style="--ui-select-inline-size: auto" data-unit-for="${field.id}" aria-label="${escape(field.label)} unit" value="${editor.unit}">${[...new Set([...editor.units, editor.unit])].map(unit => `<option value="${unit}">${unit}</option>`).join('')}</ui-select></ui-cluster>` : numeric;
|
|
33
33
|
} else {
|
|
34
|
-
control =
|
|
34
|
+
control = field.type === 'colour' ? `<ui-color-picker id="theme-${field.id}" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : `<ui-input id="theme-${field.id}" ${common} value="${escape(value)}"></ui-input>`;
|
|
35
35
|
}
|
|
36
|
+
if (field.type === 'font') return `<ui-stack gap="s">${control}</ui-stack>`;
|
|
36
37
|
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>`;
|
|
37
38
|
};
|
|
38
39
|
const swatches = tokens => `<span class="palette-swatches" aria-hidden="true">${['--ui-accent','--ui-neutral-surface','--ui-surface','--ui-text'].map(token => `<span style="background:${escape(tokens ? tokens[token] : `var(${token})`)}"></span>`).join('')}</span>`;
|
|
@@ -52,52 +53,52 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
52
53
|
['neighboring', 'Related colors', 'Tint neutral surfaces with a neighboring hue for a closely coordinated palette.'],
|
|
53
54
|
['complementary', 'Contrasting colors', 'Tint neutral surfaces toward the opposite hue for warm–cool contrast.'],
|
|
54
55
|
].map(([id,label,description]) => `<ui-button variant="card" tone="neutral" data-action="palette-relationship" data-relationship="${id}" aria-pressed="${recipe.values['palette-relationship'] === id}"><ui-stack gap="xs"><ui-text variant="label">${label}</ui-text><span class="palette-range" data-relationship-preview="${id}" aria-hidden="true"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></span><ui-text variant="caption">${description}</ui-text></ui-stack><span slot="action" data-relationship-selected="${id}"${recipe.values['palette-relationship'] === id ? '' : ' hidden'}><ui-icon name="check"></ui-icon></span></ui-button>`).join('')}</ui-stack>`;
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
};
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const materialView =
|
|
64
|
-
const paletteView = refinement('palette', `<div class="refinement-layout"><div class="palette-preview"><ui-stack>${paletteDetails}</ui-stack></div><ui-stack><ui-text variant="heading" as="h3">Base colors</ui-text><ui-text variant="caption">The colors your theme’s light and dark palettes are built from.</ui-text>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}
|
|
56
|
+
const sizePreview = `<ui-text variant="caption">Shared size ladder: buttons, fields and swatches align at each step.</ui-text><ui-stack>${['sm','md','lg'].map((size,index) => `<ui-stack gap="xs" data-size-sample="${size}"><ui-text variant="caption">${['Small','Medium','Large'][index]}<span data-size-readout></span></ui-text><ui-cluster><ui-button size="${size}">Action</ui-button><ui-input size="${size}" style="--ui-input-inline-size: 10ch" label="${size} size sample" value="Frodo"></ui-input><ui-color-picker size="${size}" label="${size} swatch sample"></ui-color-picker></ui-cluster></ui-stack>`).join('')}</ui-stack><div class="spacing-samples" data-spacing-samples role="img" aria-label="Spacing progression"><span></span><span></span><span></span><span></span></div>`;
|
|
57
|
+
const fieldsFor = ids => ids.map(id => renderField(themeFields.find(field => field.id === id))).join('');
|
|
58
|
+
const viewTitles = { material: 'Material & light', palette: 'Palette', typography: 'Typography', layout: 'Size & spacing', motion: 'Motion', themes: 'Starting theme', export: 'Export & save', components: 'Component library' };
|
|
59
|
+
const refinement = (family, content) => `<section id="${family}" data-refinement-view="${family}" hidden tabindex="-1" aria-label="${viewTitles[family]} editor"><ui-stack gap="l"><ui-cluster><ui-button data-action="back"><ui-icon name="arrow-left"></ui-icon>Overview</ui-button><ui-text variant="heading" as="h2">${viewTitles[family]}</ui-text></ui-cluster>${content}</ui-stack></section>`;
|
|
60
|
+
const editor = (family, preview, controls) => refinement(family, `<div class="refinement-layout"><div class="editor-preview" data-editor-preview="${family}">${preview}</div><ui-stack>${controls}</ui-stack></div>`);
|
|
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
|
+
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
|
+
const motionView = editor('motion', `<ui-stack><ui-card><ui-stack><ui-text variant="eyebrow">Motion study</ui-text><div class="motion-track"><span data-motion-object aria-hidden="true"></span></div><ui-cluster><ui-button data-action="replay-motion"><ui-icon name="play"></ui-icon>Replay motion</ui-button><ui-text variant="caption" data-motion-readout></ui-text></ui-cluster><ui-text variant="caption" data-motion-preference></ui-text></ui-stack></ui-card></ui-stack>`, `${fieldsFor(['response-time'])}<ui-disclosure summary="Shape the curve"><ui-stack>${fieldsFor(['response-curve'])}</ui-stack></ui-disclosure><ui-text variant="caption">Replay shows the shared curve. Try the popover to feel it in a real component. System reduced-motion preferences are respected.</ui-text><ui-disclosure summary="Try a popover">${documentationCard('ui-popover', null, true)}</ui-disclosure>`);
|
|
64
|
+
const materialView = editor('material', `<ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor></ui-stack>`, `<ui-cluster>${renderPreset('material', 'refinement')}<ui-button data-action="restore-material">Reset material</ui-button></ui-cluster>${fieldsFor(['finish','edge-light','glow'])}<ui-text variant="caption" data-material-dependencies role="status"></ui-text><ui-disclosure summary="Light source"><ui-stack>${fieldsFor(['light-spread','light-strength'])}</ui-stack></ui-disclosure><ui-disclosure summary="Depth, glass and separation"><ui-stack>${fieldsFor(['outer-depth','inner-depth','glassiness','surface-separation','translucency'])}</ui-stack></ui-disclosure><ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-card','/04-expressive-surface')}${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure>`);
|
|
65
|
+
const paletteView = refinement('palette', `<ui-cluster>${renderPreset('palette', 'refinement')}<ui-button data-action="restore-palette">Reset palette</ui-button></ui-cluster><div class="refinement-layout"><div class="palette-preview"><ui-stack>${paletteDetails}</ui-stack></div><ui-stack><ui-text variant="heading" as="h3">Base colors</ui-text><ui-text variant="caption">The colors your theme’s light and dark palettes are built from.</ui-text>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}<ui-disclosure summary="Adjust color character"><ui-stack>${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}</ui-stack></ui-disclosure><ui-disclosure summary="Color relationships">${paletteRelationships}</ui-disclosure></ui-stack></div>`);
|
|
65
66
|
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('');
|
|
66
|
-
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'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
|
|
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');
|
|
67
68
|
return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
|
|
68
69
|
<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>
|
|
69
70
|
<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>
|
|
70
71
|
</defs>
|
|
71
72
|
<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 ?? '')}">
|
|
72
|
-
<ui-stack gap="l"
|
|
73
|
-
<ui-text variant="caption" role="status" aria-live="polite" data-message
|
|
73
|
+
<ui-stack gap="l">
|
|
74
|
+
<ui-text variant="caption" role="status" aria-live="polite" data-message></ui-text>
|
|
74
75
|
<ui-stack gap="xs"><ui-text variant="eyebrow">Theme changes</ui-text><ui-cluster><ui-button size="sm" data-action="undo" disabled>Undo</ui-button><ui-button size="sm" data-action="redo" disabled>Redo</ui-button><ui-button size="sm" data-action="reset" title="Restore this theme’s starting values">Reset</ui-button></ui-cluster></ui-stack>
|
|
75
|
-
|
|
76
|
-
${
|
|
77
|
-
|
|
78
|
-
<aside aria-label="Theme controls"><ui-stack>${choice('id="theme-start" data-preset', 'Starting point', startingPointFor(recipe), `<option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}`)}
|
|
79
|
-
${supplements.Colour}${supplements.Material}
|
|
80
|
-
<ui-disclosure summary="Type, size & motion"><ui-stack>${families}</ui-stack></ui-disclosure>
|
|
81
|
-
<ui-disclosure summary="Export & save"><ui-stack><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-cluster>
|
|
76
|
+
${materialView}${paletteView}${typographyView}${layoutView}${motionView}
|
|
77
|
+
${refinement('themes', `<ui-stack>${choice('id="theme-start" data-preset', 'Starting point', startingPointFor(recipe), `<option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}`)}<ui-text variant="caption">A starting theme coordinates color, typography, space, material, and motion. Refine any of them from the overview.</ui-text>${documentationCard('ui-cluster', '/99-theme-sampler', true)}</ui-stack>`)}
|
|
78
|
+
${refinement('export', `<ui-stack><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-cluster>
|
|
82
79
|
<input type="file" accept="application/json,.json" hidden data-recipe-file>
|
|
83
80
|
<ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
|
|
84
81
|
<ui-disclosure summary="Use your theme"><ui-text>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></ui-text></ui-disclosure>
|
|
85
|
-
<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></ui-stack
|
|
82
|
+
<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></ui-stack>`)}
|
|
83
|
+
<div class="customizer-layout" data-customizer-overview>
|
|
84
|
+
<aside aria-label="Theme controls"><ui-stack gap="s"><ui-button variant="card" data-action="refine-themes"><ui-stack gap="xs"><ui-text variant="eyebrow">Starting theme</ui-text><ui-text variant="heading" data-theme-summary></ui-text></ui-stack></ui-button>
|
|
85
|
+
<ui-list>${[['palette','Palette','Colors for light & dark'],['material','Material & light','Surface character'],['typography','Typography','Fonts & readable type'],['layout','Size & spacing','Proportions & breathing room'],['motion','Motion','Pace & response']].map(([id,label,description]) => `<ui-list-item wrap>${id === 'palette' ? `<span slot="leading">${swatches()}</span>` : ''}<a href="#${id}" data-action="refine-${id}">${label}</a><span slot="description" data-summary="${id}">${description}</span><ui-icon slot="trailing" name="chevron-right"></ui-icon></ui-list-item>`).join('')}</ui-list>
|
|
86
|
+
<ui-cluster><ui-button data-action="refine-components">Browse components</ui-button><ui-button data-action="refine-export">Export & save</ui-button></ui-cluster>
|
|
86
87
|
</ui-stack></aside>
|
|
87
88
|
<section data-theme-sampler><ui-stack gap="xs"><ui-text variant="heading" as="h2">Theme sampler</ui-text>${documentationCard('ui-cluster', '/99-theme-sampler', true)}</ui-stack></section>
|
|
88
|
-
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Search components</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><ui-text variant="caption" role="status" aria-live="polite" data-example-count></ui-text><ui-disclosure summary="Add an example"><ui-stack><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></ui-stack></ui-disclosure>
|
|
89
|
-
<ui-grid columns="2" min="sm"><section $each="card of $previews" $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>
|
|
90
|
-
<ui-button data-action="more-examples">Show more components</ui-button>
|
|
91
89
|
<noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
|
|
92
|
-
</ui-stack></
|
|
90
|
+
</div>${refinement('components', ` <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Search components</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><ui-text variant="caption" role="status" aria-live="polite" data-example-count></ui-text><ui-disclosure summary="Add an example"><ui-stack><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></ui-stack></ui-disclosure>
|
|
91
|
+
<ui-grid columns="2" min="sm"><section $each="card of $previews" $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>
|
|
92
|
+
<ui-button data-action="more-examples">Show more components</ui-button></ui-stack></section>
|
|
93
|
+
`)}</ui-stack></div>
|
|
93
94
|
<style>
|
|
94
|
-
[data-customizer-overview][hidden], [data-refinement-view][hidden]
|
|
95
|
+
[data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
|
|
95
96
|
.customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
|
|
96
|
-
aside { grid-row: 1 / span 2; position: sticky; inset-block-start:
|
|
97
|
+
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)); }
|
|
97
98
|
.examples { min-inline-size: 0; }
|
|
98
99
|
.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; }
|
|
99
|
-
[data-refinement-view] { scroll-margin-block-start:
|
|
100
|
-
.palette-preview { position: sticky; inset-block-start:
|
|
100
|
+
[data-refinement-view] { scroll-margin-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); }
|
|
101
|
+
.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; }
|
|
101
102
|
.spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
|
|
102
103
|
.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); }
|
|
103
104
|
.spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
|
|
@@ -111,6 +112,9 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
111
112
|
.palette-canvas { background: var(--_palette-canvas, var(--ui-surface)); }
|
|
112
113
|
.palette-content { background: var(--_palette-content, var(--ui-surface-raised)); }
|
|
113
114
|
.palette-action { background: var(--_palette-action, var(--ui-accent)); }
|
|
114
|
-
|
|
115
|
+
.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
|
+
[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; } }
|
|
118
|
+
@media (max-width: 64rem) { .palette-preview[data-page-flow], .editor-preview[data-page-flow] { position: static; } }
|
|
115
119
|
</style></template>`;
|
|
116
120
|
}
|
package/docs/customizer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { googleFontStack, systemFont, loadGoogleFonts } from './fonts.mjs';
|
|
2
2
|
import { documentationTheme, themeEvent } from './theme-session.mjs';
|
|
3
3
|
import { componentRegistry } from '@nextwebwg/html-next/runtime';
|
|
4
|
-
import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme
|
|
4
|
+
import { startingPoints, startingPointFor, startingPointLabels, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
|
|
5
5
|
|
|
6
6
|
import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
|
|
7
7
|
|
|
@@ -13,6 +13,7 @@ export function connect(host) {
|
|
|
13
13
|
const message = root.querySelector('[data-message]');
|
|
14
14
|
const file = root.querySelector('[data-recipe-file]');
|
|
15
15
|
const theme = documentationTheme(view);
|
|
16
|
+
const motionPreference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
|
16
17
|
let saved = validateRecipe(JSON.parse(root.dataset.recipe));
|
|
17
18
|
let recipe = theme?.recipe ?? structuredClone(saved);
|
|
18
19
|
let revision = root.dataset.revision;
|
|
@@ -24,8 +25,8 @@ export function connect(host) {
|
|
|
24
25
|
const catalogExamples = [...root.querySelectorAll('[data-add-example] option[data-example]')].map(option => JSON.parse(option.dataset.example));
|
|
25
26
|
const removedExamples = new Set();
|
|
26
27
|
let previewQuery = '', previewLimit = 9;
|
|
27
|
-
let refinementTrigger;
|
|
28
|
-
let previewFrame = 0, pending = false,
|
|
28
|
+
let refinementTrigger, overviewScroll = 0, motionAnimation, motionSignature;
|
|
29
|
+
let previewFrame = 0, pending = false, gestureStart;
|
|
29
30
|
const abort = new AbortController();
|
|
30
31
|
const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
|
|
31
32
|
const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
|
|
@@ -63,7 +64,38 @@ export function connect(host) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
};
|
|
67
|
+
const header = document.querySelector('[data-component~="markupress-header"]');
|
|
68
|
+
const previews = [...root.querySelectorAll('[data-editor-preview], .palette-preview')];
|
|
69
|
+
const updatePreviewLayout = () => {
|
|
70
|
+
const chrome = header?.getBoundingClientRect().height ?? 0;
|
|
71
|
+
root.style.setProperty('--_editor-chrome-height', `${chrome}px`);
|
|
72
|
+
for (const preview of previews) preview.toggleAttribute('data-page-flow', preview.offsetHeight > (view.innerHeight - chrome) / 2);
|
|
73
|
+
};
|
|
74
|
+
const replayMotion = () => {
|
|
75
|
+
const element = root.querySelector('[data-motion-object]');
|
|
76
|
+
const duration = parseFloat(convertValueUnit(recipe.values['response-time'], 'ms', unitContext()));
|
|
77
|
+
motionAnimation?.cancel();
|
|
78
|
+
const available = Math.max(0, element.parentElement.clientWidth - element.offsetWidth - 2 * parseFloat(view.getComputedStyle(element.parentElement).paddingInlineStart));
|
|
79
|
+
const start = available * .3, end = available * .7;
|
|
80
|
+
element.style.transform = `translateX(${start}px)`;
|
|
81
|
+
if (motionPreference.matches) return;
|
|
82
|
+
motionAnimation = element.animate([{ transform: `translateX(${start}px)` }, { transform: `translateX(${end}px)` }], { duration: Math.max(0, duration) * 3, easing: recipe.values['response-curve'], iterations: 2, direction: 'alternate', fill: 'none' });
|
|
83
|
+
};
|
|
66
84
|
const paint = () => {
|
|
85
|
+
const values = recipe.values;
|
|
86
|
+
root.querySelector('[data-theme-summary]').textContent = startingPointLabels[recipe.origin ?? startingPointFor(recipe)] ?? 'Your theme';
|
|
87
|
+
const summaries = {
|
|
88
|
+
palette: `${themePalettes[refinementState(recipe, 'palette').id]?.label ?? 'Custom colors'} · Light & dark`,
|
|
89
|
+
material: `${materialPresets[refinementState(recipe, 'material').id]?.label ?? 'Custom material'}`,
|
|
90
|
+
typography: `${recipe.fonts?.['body-font'] ?? values['body-font'].split(',')[0]} · ${values['text-size']}`,
|
|
91
|
+
layout: `${Number(values.rhythm) * Number(values.spacing) < .95 ? 'Compact' : Number(values.rhythm) * Number(values.spacing) > 1.2 ? 'Relaxed' : 'Comfortable'} · ${parseFloat(values['corner-roundness']) === 0 ? 'Square' : 'Rounded'} corners`,
|
|
92
|
+
motion: `${values['response-time']} · ${values['response-curve'].startsWith('cubic-bezier') ? 'Custom curve' : values['response-curve']}`,
|
|
93
|
+
};
|
|
94
|
+
for (const [family, text] of Object.entries(summaries)) root.querySelector(`[data-summary="${family}"]`).textContent = text;
|
|
95
|
+
root.querySelector('[data-motion-readout]').textContent = `${values['response-time']} feedback · ${parseFloat(convertValueUnit(values['response-time'], 'ms', unitContext())) * 3}ms travel`;
|
|
96
|
+
componentRegistry.update(root.querySelector('[data-action="replay-motion"]'), { disabled: motionPreference.matches });
|
|
97
|
+
root.querySelector('[data-motion-preference]').textContent = motionPreference.matches ? 'Reduced motion is on. The study stays still.' : 'Travel is three times the feedback duration, like a larger surface transition.';
|
|
98
|
+
|
|
67
99
|
const css = theme?.css || themeCSS(recipe);
|
|
68
100
|
if (boundsBase !== recipe.values['text-size']) {
|
|
69
101
|
boundsBase = recipe.values['text-size'];
|
|
@@ -86,13 +118,16 @@ export function connect(host) {
|
|
|
86
118
|
}
|
|
87
119
|
componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
|
|
88
120
|
}
|
|
89
|
-
for (const control of root.querySelectorAll('[data-font-picker]'))
|
|
121
|
+
for (const control of root.querySelectorAll('[data-font-picker]')) {
|
|
122
|
+
control.querySelector('option[value=custom]').textContent = recipe.values[control.dataset.fontPicker].split(',')[0].replaceAll('"', '').trim();
|
|
123
|
+
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') });
|
|
124
|
+
}
|
|
90
125
|
componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
|
|
91
126
|
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
92
127
|
const report = root.querySelector('[data-palette-adjustments]');
|
|
93
128
|
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.';
|
|
94
129
|
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('; ') + '. Base colors remain editable.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
|
|
95
|
-
for (const card of root.querySelectorAll('[data-example-card]'))
|
|
130
|
+
for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
|
|
96
131
|
const cssControl = root.querySelector('[data-css]');
|
|
97
132
|
if (cssControl.getClientRects().length) updateControl(cssControl, css);
|
|
98
133
|
for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
|
|
@@ -110,6 +145,11 @@ export function connect(host) {
|
|
|
110
145
|
const gaps = root.querySelector('[data-spacing-samples]');
|
|
111
146
|
gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
|
|
112
147
|
updateSizes();
|
|
148
|
+
updatePreviewLayout();
|
|
149
|
+
const nextMotion = `${values['response-time']}/${values['response-curve']}`;
|
|
150
|
+
if (!root.querySelector('[data-refinement-view="motion"]').hidden && motionSignature !== nextMotion) {
|
|
151
|
+
motionSignature = nextMotion; replayMotion();
|
|
152
|
+
}
|
|
113
153
|
};
|
|
114
154
|
const show = (next, persist = true) => {
|
|
115
155
|
recipe = validateRecipe(next);
|
|
@@ -120,15 +160,9 @@ export function connect(host) {
|
|
|
120
160
|
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
121
161
|
paint();
|
|
122
162
|
};
|
|
123
|
-
const clearPreview = () => {
|
|
124
|
-
if (!candidate) return;
|
|
125
|
-
candidate = undefined; theme?.compare();
|
|
126
|
-
root.querySelector('[data-variation-confirm]').hidden = true;
|
|
127
|
-
};
|
|
128
163
|
const preview = next => {
|
|
129
164
|
const validated = validateRecipe(next);
|
|
130
165
|
if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
|
|
131
|
-
clearPreview();
|
|
132
166
|
if (!pending) gestureStart = structuredClone(recipe);
|
|
133
167
|
recipe = validated; pending = true;
|
|
134
168
|
if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
|
|
@@ -205,49 +239,42 @@ export function connect(host) {
|
|
|
205
239
|
const link = document.createElement('a'); link.href = url; link.download = name; link.click();
|
|
206
240
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
207
241
|
};
|
|
242
|
+
const renderView = () => {
|
|
243
|
+
const family = view.location.hash.slice(1);
|
|
244
|
+
const section = [...root.querySelectorAll('[data-refinement-view]')].find(element => element.dataset.refinementView === family);
|
|
245
|
+
const overview = root.querySelector('[data-customizer-overview]');
|
|
246
|
+
if (section) refinementTrigger = root.querySelector(`[data-action="refine-${family}"]`);
|
|
247
|
+
for (const element of root.querySelectorAll('[data-refinement-view]')) element.hidden = element !== section;
|
|
248
|
+
overview.hidden = Boolean(section);
|
|
249
|
+
motionAnimation?.cancel();
|
|
250
|
+
if (family === 'motion') motionSignature = undefined;
|
|
251
|
+
paint();
|
|
252
|
+
if (section) { section.focus(); section.scrollIntoView({ block: 'start' }); }
|
|
253
|
+
else { refinementTrigger?.focus({ preventScroll: true }); view.scrollTo({ top: overviewScroll, behavior: 'instant' }); }
|
|
254
|
+
};
|
|
255
|
+
const navigate = family => {
|
|
256
|
+
flush();
|
|
257
|
+
view.history.pushState(null, '', view.location.pathname + view.location.search + (family ? '#' + family : ''));
|
|
258
|
+
renderView();
|
|
259
|
+
};
|
|
208
260
|
const click = async event => {
|
|
209
261
|
const button = event.target.closest?.('[data-action]');
|
|
210
262
|
if (!button) { view.requestAnimationFrame(paint); return; }
|
|
211
263
|
if (button.dataset.action === 'save' && saving) return;
|
|
264
|
+
if (button.matches('a')) event.preventDefault();
|
|
212
265
|
flush();
|
|
213
|
-
if (!['preview-variation', 'apply-variation', 'cancel-variation'].includes(button.dataset.action)) clearPreview();
|
|
214
266
|
try {
|
|
215
267
|
switch (button.dataset.action) {
|
|
268
|
+
case 'replay-motion': replayMotion(); break;
|
|
216
269
|
case 'undo': case 'redo':
|
|
217
270
|
theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
|
|
218
|
-
case '
|
|
219
|
-
clearPreview();
|
|
220
|
-
const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
|
|
221
|
-
candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').dataset.selectedValue, locks);
|
|
222
|
-
theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
|
|
223
|
-
const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
|
|
224
|
-
root.querySelector('[data-variation-description]').textContent = differences.length ? differences.map(field => `${field.label}: ${recipe.values[field.id]} → ${candidate.values[field.id]}`).join('; ') : 'No changes: the relevant families are locked or already at their bounds.';
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
case 'apply-variation': {
|
|
228
|
-
const selected = candidate; clearPreview();
|
|
229
|
-
if (selected) show(selected); sayApplied('Variation applied. Undo returns to your previous theme.'); break;
|
|
230
|
-
}
|
|
231
|
-
case 'cancel-variation': clearPreview(); paint(); say('Variation cancelled; your theme is unchanged.'); break;
|
|
232
|
-
case 'light-top': case 'light-left': case 'light-center': {
|
|
233
|
-
const positions = { 'light-top': [.5, 0], 'light-left': [.15, .15], 'light-center': [.5, .5] };
|
|
234
|
-
const [x, y] = positions[button.dataset.action];
|
|
235
|
-
show(refinedRecipe({ ...recipe.values, 'light-x': String(x), 'light-y': String(y) }));
|
|
236
|
-
sayApplied('Light direction applied throughout your theme.'); break;
|
|
237
|
-
}
|
|
238
|
-
case 'refine-material': case 'refine-palette': {
|
|
271
|
+
case 'refine-material': case 'refine-palette': case 'refine-typography': case 'refine-layout': case 'refine-motion': case 'refine-themes': case 'refine-export': case 'refine-components': {
|
|
239
272
|
const family = button.dataset.action.slice(7);
|
|
240
|
-
refinementTrigger = button;
|
|
241
|
-
|
|
242
|
-
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
|
|
243
|
-
const section = root.querySelector(`[data-refinement-view="${family}"]`);
|
|
244
|
-
paint(); section.focus(); section.scrollIntoView({ block: 'start' });
|
|
273
|
+
refinementTrigger = button; overviewScroll = view.scrollY;
|
|
274
|
+
navigate(family);
|
|
245
275
|
break;
|
|
246
276
|
}
|
|
247
|
-
case 'back':
|
|
248
|
-
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
|
|
249
|
-
root.querySelector('[data-customizer-overview]').hidden = false;
|
|
250
|
-
paint(); refinementTrigger?.focus(); break;
|
|
277
|
+
case 'back': navigate(''); break;
|
|
251
278
|
case 'restore-material': case 'restore-palette': {
|
|
252
279
|
const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
|
|
253
280
|
if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
|
|
@@ -302,7 +329,7 @@ export function connect(host) {
|
|
|
302
329
|
if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
|
|
303
330
|
input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
|
|
304
331
|
}
|
|
305
|
-
flush();
|
|
332
|
+
flush();
|
|
306
333
|
if (event.target.matches('[data-unit-for]')) {
|
|
307
334
|
const field = fields.get(event.target.dataset.unitFor);
|
|
308
335
|
try {
|
|
@@ -328,14 +355,14 @@ export function connect(host) {
|
|
|
328
355
|
const choose = event => {
|
|
329
356
|
if (!event.target.matches('[data-choice]') || event.detail.kind !== 'selection') return;
|
|
330
357
|
event.target.dataset.selectedValue = event.detail.value;
|
|
331
|
-
|
|
358
|
+
change(event);
|
|
332
359
|
};
|
|
333
360
|
const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
|
|
334
361
|
const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
|
|
335
362
|
const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
|
|
336
363
|
const keyboard = event => {
|
|
337
364
|
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,select,[contenteditable="true"]')) return;
|
|
338
|
-
event.preventDefault(); flush();
|
|
365
|
+
event.preventDefault(); flush(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
|
|
339
366
|
};
|
|
340
367
|
const ready = event => {
|
|
341
368
|
if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
|
|
@@ -352,7 +379,7 @@ export function connect(host) {
|
|
|
352
379
|
let fontRequest = 0;
|
|
353
380
|
const selectFont = async event => {
|
|
354
381
|
const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
|
|
355
|
-
flush();
|
|
382
|
+
flush();
|
|
356
383
|
const id = control.dataset.fontPicker, family = event.detail.value;
|
|
357
384
|
const fonts = { ...recipe.fonts };
|
|
358
385
|
if (family === 'system' || family === 'inherit') delete fonts[id]; else fonts[id] = family;
|
|
@@ -377,6 +404,11 @@ export function connect(host) {
|
|
|
377
404
|
selectedExample = event.detail.value ?? '';
|
|
378
405
|
filterExamples();
|
|
379
406
|
};
|
|
407
|
+
const motionPreferenceChanged = () => { motionAnimation?.cancel(); paint(); };
|
|
408
|
+
const previewObserver = new view.ResizeObserver(updatePreviewLayout);
|
|
409
|
+
for (const element of [...previews, ...(header ? [header] : [])]) previewObserver.observe(element);
|
|
410
|
+
view.addEventListener('resize', updatePreviewLayout);
|
|
411
|
+
motionPreference.addEventListener('change', motionPreferenceChanged);
|
|
380
412
|
root.addEventListener('toggle', updateSizes, true);
|
|
381
413
|
root.addEventListener('looma-example-remove', remove);
|
|
382
414
|
root.addEventListener('input', input);
|
|
@@ -389,9 +421,12 @@ export function connect(host) {
|
|
|
389
421
|
root.addEventListener('change', change);
|
|
390
422
|
root.addEventListener('click', click);
|
|
391
423
|
view.addEventListener('message', ready);
|
|
424
|
+
view.addEventListener('popstate', renderView);
|
|
425
|
+
view.addEventListener('hashchange', renderView);
|
|
392
426
|
view.addEventListener('beforeunload', flush);
|
|
393
427
|
view.addEventListener(themeEvent, synchronize);
|
|
394
428
|
show(recipe, false); filterExamples();
|
|
429
|
+
if (view.location.hash) renderView();
|
|
395
430
|
if (theme?.storageAvailable === false) sayApplied('');
|
|
396
431
|
if (root.dataset.mode === 'local') {
|
|
397
432
|
fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
|
|
@@ -403,5 +438,5 @@ export function connect(host) {
|
|
|
403
438
|
root.querySelector('[data-action="save"]').hidden = false;
|
|
404
439
|
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
405
440
|
}
|
|
406
|
-
return () => { root.removeEventListener('toggle', updateSizes, true);
|
|
441
|
+
return () => { previewObserver.disconnect(); view.removeEventListener('resize', updatePreviewLayout); motionPreference.removeEventListener('change', motionPreferenceChanged); root.removeEventListener('toggle', updateSizes, true); motionAnimation?.cancel(); flush(); root.removeEventListener('cancel', cancel); root.removeEventListener('keydown', keyboard); alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', choose); root.removeEventListener('value-change', selectExample); root.removeEventListener('value-change', selectFont); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('beforeunload', flush); view.removeEventListener('message', ready); view.removeEventListener('popstate', renderView); view.removeEventListener('hashchange', renderView); view.removeEventListener(themeEvent, synchronize); };
|
|
407
442
|
}
|
package/docs/site.mjs
CHANGED
|
@@ -144,7 +144,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
144
144
|
const customizer = path.join(generated, 'customizer.html');
|
|
145
145
|
await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
|
|
146
146
|
const customizerPage = path.join(content, 'customize.md');
|
|
147
|
-
await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n#
|
|
147
|
+
await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Customize your theme\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
|
|
148
148
|
await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
|
|
149
149
|
const redirects = {};
|
|
150
150
|
for (const component of combined.components) {
|
package/docs/theme-session.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export const themeEvent = 'looma-documentation-theme';
|
|
|
7
7
|
export function createThemeSession(view, { base, baseline, systemCSS, historyLimit = 100 }) {
|
|
8
8
|
const key = `looma:documentation-theme:v1:${base}`;
|
|
9
9
|
let original = validateRecipe(baseline);
|
|
10
|
-
let selected, draft
|
|
10
|
+
let selected, draft;
|
|
11
11
|
const past = [], future = [];
|
|
12
12
|
let portableCSS = '';
|
|
13
13
|
let storageAvailable = true;
|
|
@@ -17,7 +17,7 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
|
|
|
17
17
|
const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
|
|
18
18
|
try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
|
|
19
19
|
const paint = () => {
|
|
20
|
-
const displayed =
|
|
20
|
+
const displayed = draft ?? selected;
|
|
21
21
|
portableCSS = displayed ? themeCSS(displayed, systemCSS) : '';
|
|
22
22
|
view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(displayed));
|
|
23
23
|
style.textContent = displayed ? themeCSS(displayed, systemCSS, { scope: 'documentation' }) : '';
|
|
@@ -42,22 +42,21 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
|
|
|
42
42
|
preview(recipe) { draft = validateRecipe(recipe); paint(); },
|
|
43
43
|
commit() { if (draft) session.apply(draft); },
|
|
44
44
|
cancel() { draft = undefined; paint(); },
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = comparison = undefined; persist(); },
|
|
45
|
+
undo() { if (!past.length) return; future.push(structuredClone(selected)); selected = past.pop(); draft = undefined; persist(); },
|
|
46
|
+
redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = undefined; persist(); },
|
|
48
47
|
apply(recipe) {
|
|
49
48
|
const next = validateRecipe(recipe);
|
|
50
49
|
if (JSON.stringify(next) !== JSON.stringify(selected)) remember();
|
|
51
|
-
selected = next; draft =
|
|
50
|
+
selected = next; draft = undefined; persist();
|
|
52
51
|
},
|
|
53
|
-
reset() { if (selected !== undefined) remember(); selected = draft =
|
|
52
|
+
reset() { if (selected !== undefined) remember(); selected = draft = undefined; persist(); return structuredClone(original); },
|
|
54
53
|
baseline(recipe) { original = validateRecipe(recipe); },
|
|
55
54
|
};
|
|
56
55
|
view.addEventListener('storage', event => {
|
|
57
56
|
if (event.key !== key && event.key !== null) return;
|
|
58
57
|
const next = read(event.newValue);
|
|
59
58
|
if (event.newValue !== null && !next) return;
|
|
60
|
-
selected = next; draft =
|
|
59
|
+
selected = next; draft = undefined; past.length = future.length = 0; paint();
|
|
61
60
|
});
|
|
62
61
|
view.addEventListener('markupress-header-notice-dismiss', () => session.reset());
|
|
63
62
|
paint();
|