@threadlabs/looma 0.37.5 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ui-button/ui-button.html +20 -5
- package/components/ui-combobox/ui-combobox.html +24 -3
- package/components/ui-combobox/ui-combobox.js +18 -3
- package/components/ui-icon-button/ui-icon-button.html +17 -8
- package/components/ui-menu-item/ui-menu-item.html +6 -1
- package/components/ui-select/ui-select.html +12 -0
- package/components/ui-skeleton/ui-skeleton.html +91 -0
- package/dist/index.js +1 -1
- package/docs/README.md +23 -10
- package/docs/catalog.json +246 -31
- package/docs/customizer-render.mjs +36 -20
- package/docs/customizer.js +76 -25
- package/docs/documentation.css +2 -0
- package/docs/palette.mjs +3 -3
- package/docs/site.mjs +1 -1
- package/docs/theme-session.mjs +1 -0
- package/docs/theme.mjs +11 -10
- package/package.json +2 -2
- package/styles/ui-button.css +19 -9
- package/styles/ui-combobox.css +40 -2
- package/styles/ui-icon-button.css +12 -2
- package/styles/ui-menu-item.css +6 -1
- package/styles/ui-select.css +9 -0
- package/styles/ui-skeleton.css +91 -0
- package/theme-system.css +7 -2
- package/vanilla/UiButton.js +1 -1
- package/vanilla/UiCombobox.d.ts +1 -0
- package/vanilla/UiCombobox.js +38 -37
- package/vanilla/UiIconButton.d.ts +1 -1
- package/vanilla/UiIconButton.js +1 -1
- package/vanilla/UiSelect.d.ts +1 -0
- package/vanilla/UiSelect.js +7 -7
- package/vanilla/UiSkeleton.d.ts +26 -0
- package/vanilla/UiSkeleton.js +42 -0
- package/vanilla/index.js +1 -0
- package/vue/UiButton.vue +30 -12
- package/vue/UiCombobox.d.ts +6 -0
- package/vue/UiCombobox.vue +70 -6
- package/vue/UiIconButton.d.ts +2 -2
- package/vue/UiIconButton.vue +23 -18
- package/vue/UiMenuItem.vue +6 -1
- package/vue/UiSelect.d.ts +6 -0
- package/vue/UiSelect.vue +30 -1
- package/vue/UiSkeleton.d.ts +37 -0
- package/vue/UiSkeleton.js +2 -0
- package/vue/UiSkeleton.vue +235 -0
- package/vue/chunks/UiButton.js +4 -4
- package/vue/chunks/UiCombobox.js +72 -46
- package/vue/chunks/UiIconButton.js +6 -7
- package/vue/chunks/UiMenuItem.js +1 -1
- package/vue/chunks/UiSelect.js +27 -2
- package/vue/chunks/UiSkeleton.js +151 -0
- package/vue/components.css +197 -25
- package/vue/editor/index.js +2 -3
- package/vue/index.d.ts +1 -0
- package/vue/index.js +2 -1
|
@@ -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, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
|
|
5
|
+
import { defaultRecipe, themeFields, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
|
|
6
6
|
|
|
7
7
|
export function customizerResource(site, input = defaultRecipe()) {
|
|
8
8
|
const recipe = validateRecipe(input);
|
|
@@ -20,8 +20,8 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
20
20
|
const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
|
|
21
21
|
let control;
|
|
22
22
|
if (field.type === 'font') {
|
|
23
|
-
const selected = 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
|
|
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>Custom font stack</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="${field.id === 'display-font' ? 'heading' : 'body'}">Frodo’s quarterly review · Aa 0123</ui-text><ui-text variant="caption" data-font-status="${field.id}" role="status">System fonts need no download. Google selections load in this preview and travel 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') {
|
|
@@ -35,47 +35,59 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
35
35
|
}
|
|
36
36
|
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
37
|
};
|
|
38
|
+
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>`;
|
|
39
|
+
const choice = (attributes, label, value, options, content = '') => `<ui-combobox ${attributes} data-choice label="${escape(label)}" value="${escape(value)}" disclosure open-on-focus>${options}${content}</ui-combobox>`;
|
|
38
40
|
const renderPreset = (family, location) => {
|
|
39
41
|
const presets = family === 'material' ? materialPresets : themePalettes;
|
|
40
42
|
const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
|
|
41
|
-
|
|
43
|
+
const origin = startingPoints[recipe.origin ?? startingPointFor(recipe)];
|
|
44
|
+
const muted = origin && themeFields.filter(field => field.family === (family === 'palette' ? 'Colour' : 'Material')).every(field => origin.values[field.id] === recipe.values[field.id]);
|
|
45
|
+
const options = `<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('')}`;
|
|
46
|
+
const preview = family === 'palette' ? `<span slot="start" data-selected-palette>${swatches()}</span><template slot="option"><span class="palette-option">${Object.keys(presets).map(id => `<template $if="$option.value = '${id}'">${swatches(deriveTheme(applyRefinement(recipe, 'palette', id), { mode: 'light' }).tokens)}</template>`).join('')}<span>{$option.label}</span></span></template>` : '';
|
|
47
|
+
return choice(`id="${location}-${family}" data-refinement-preset="${family}"${muted ? ' muted' : ''}`, `${label} preset`, state.id, options, preview);
|
|
42
48
|
};
|
|
43
|
-
const paletteDetails = `<ui-
|
|
49
|
+
const paletteDetails = `<ui-grid columns="2" gap="s" style="--ui-grid-min: 8rem">${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-stack gap="s"><ui-text variant="eyebrow">${mode === 'light' ? 'Light' : 'Dark'}</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="heading" as="h3">Fellowship</ui-text><ui-text>There and back.</ui-text><ui-text variant="caption">Second breakfast at nine.</ui-text><ui-button size="sm" preset="primary">Accent</ui-button><ui-button size="sm" preset="standard">Neutral</ui-button><ui-button size="sm" variant="outline" tone="accent">Outline</ui-button></ui-stack></ui-card>`).join('')}</ui-grid><ui-text variant="caption" data-palette-adjustments role="status" aria-live="polite"></ui-text><ui-disclosure summary="Resolved palette values"><ui-text variant="caption" data-palette-adjustment-details></ui-text></ui-disclosure>`;
|
|
50
|
+
const paletteRelationships = `<ui-stack gap="s"><ui-text variant="heading" as="h3">How should the colors work together?</ui-text>${[
|
|
51
|
+
['preserve', 'Keep my colors', 'Keep the base colors as chosen. Warmth and intensity still refine the result.'],
|
|
52
|
+
['neighboring', 'Related colors', 'Tint neutral surfaces with a neighboring hue for a closely coordinated palette.'],
|
|
53
|
+
['complementary', 'Contrasting colors', 'Tint neutral surfaces toward the opposite hue for warm–cool contrast.'],
|
|
54
|
+
].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>`;
|
|
44
55
|
const supplements = {
|
|
45
|
-
'Colour': `${renderPreset('palette', 'overview')}<ui-text variant="caption">
|
|
56
|
+
'Colour': `${renderPreset('palette', 'overview')}<ui-text variant="caption">Colors only, in light and dark. Starting points also set type, shape, material and motion.</ui-text><ui-button data-action="refine-palette">Refine palette</ui-button>`,
|
|
46
57
|
'Material': `${renderPreset('material', 'overview')}<ui-text variant="caption">Material and light, shared everywhere.</ui-text><ui-button data-action="refine-material">Refine material & light</ui-button>`,
|
|
47
58
|
'Motion': `<ui-text variant="caption">Open and close this real documentation example to feel the response pace and curve. Reduced motion follows your system preference.</ui-text>${documentationCard('ui-popover')}`,
|
|
48
|
-
'Size & spacing': `<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><ui-disclosure summary="How the scale grows"><ui-stack><
|
|
59
|
+
'Size & spacing': `<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><ui-disclosure summary="How the scale grows"><ui-stack><ui-text>Control roominess relates every field and button to base text. Size ratio separates small from large. Visible proportions stay the same on touch; hit areas are a separate design choice. Spacing and its curve shape page rhythm independently.</ui-text>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
49
60
|
};
|
|
50
61
|
const families = [...new Set(themeFields.map(field => field.family))].filter(family => !['Colour', 'Material'].includes(family)).map(family => `<ui-disclosure summary="${family}"><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id)).map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
|
|
51
|
-
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="
|
|
62
|
+
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="l"><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-stack gap="s"><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><ui-text>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</ui-text></ui-stack>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
|
|
52
63
|
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="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-cluster><ui-button size="sm" data-action="light-top">Top light</ui-button><ui-button size="sm" data-action="light-left">Upper left</ui-button><ui-button size="sm" data-action="light-center">Centered</ui-button></ui-cluster><ui-text variant="caption" data-material-dependencies role="status"></ui-text>${documentationCard('ui-card','/04-expressive-surface')}<ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure></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>`);
|
|
53
|
-
const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}
|
|
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('')}${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}${paletteRelationships}</ui-stack></div>`);
|
|
54
65
|
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('');
|
|
55
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');
|
|
56
67
|
return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
|
|
68
|
+
<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>
|
|
57
69
|
<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>
|
|
58
70
|
</defs>
|
|
59
71
|
<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 ?? '')}">
|
|
60
72
|
<ui-stack gap="l"><ui-text>Choose a starting point, refine shared decisions, and see them on real documentation examples.</ui-text>
|
|
61
73
|
<ui-text variant="caption" role="status" aria-live="polite" data-message>Changes are remembered across the documentation.</ui-text>
|
|
62
|
-
<ui-
|
|
63
|
-
<ui-disclosure summary="Explore nearby styles"><ui-stack><ui-text variant="caption">Preview a small change before applying it. Locks preserve your selected families.</ui-text><ui-cluster>${[['palette','Palette'],['material','Material'],['type','Type & spacing']].map(([id,label]) => `<ui-checkbox size="sm" data-family-lock="${id}">Lock ${label}</ui-checkbox>`).join('')}</ui-cluster
|
|
74
|
+
<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
|
+
<ui-disclosure summary="Explore nearby styles"><ui-stack><ui-text variant="caption">Preview a small change before applying it. Locks preserve your selected families.</ui-text><ui-cluster>${[['palette','Palette'],['material','Material'],['type','Type & spacing']].map(([id,label]) => `<ui-checkbox size="sm" data-family-lock="${id}">Lock ${label}</ui-checkbox>`).join('')}</ui-cluster>${choice(`id="nearby-style" data-variation-choice data-selected-value="${Object.keys(themeVariations)[0]}"`, 'Nearby style', Object.keys(themeVariations)[0], Object.entries(themeVariations).map(([id,label]) => `<option value="${id}">${label}</option>`).join(''))}<ui-button data-action="preview-variation">Preview variation</ui-button><div data-variation-confirm hidden><ui-stack><ui-text data-variation-description role="status"></ui-text><ui-cluster><ui-button preset="primary" data-action="apply-variation">Apply variation</ui-button><ui-button data-action="cancel-variation">Cancel preview</ui-button></ui-cluster></ui-stack></div></ui-stack></ui-disclosure>
|
|
64
76
|
${materialView}${paletteView}
|
|
65
77
|
<div class="customizer-layout" data-customizer-overview>
|
|
66
|
-
<aside aria-label="Theme controls"><ui-stack
|
|
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('')}`)}
|
|
67
79
|
${supplements.Colour}${supplements.Material}
|
|
68
80
|
<ui-disclosure summary="Type, size & motion"><ui-stack>${families}</ui-stack></ui-disclosure>
|
|
69
|
-
<ui-button data-action="reset" variant="ghost">Reset changes</ui-button>
|
|
70
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>
|
|
71
82
|
<input type="file" accept="application/json,.json" hidden data-recipe-file>
|
|
72
83
|
<ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
|
|
73
|
-
<ui-disclosure summary="Use your theme"><
|
|
84
|
+
<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>
|
|
74
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></ui-disclosure>
|
|
75
86
|
</ui-stack></aside>
|
|
76
87
|
<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>
|
|
77
|
-
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">
|
|
78
|
-
<ui-grid columns="2" min="sm"><section $each="card of $
|
|
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>
|
|
79
91
|
<noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
|
|
80
92
|
</ui-stack></section></div></ui-stack></div>
|
|
81
93
|
<style>
|
|
@@ -85,16 +97,20 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
85
97
|
.examples { min-inline-size: 0; }
|
|
86
98
|
.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; }
|
|
87
99
|
[data-refinement-view] { scroll-margin-block-start: 5rem; }
|
|
100
|
+
.palette-preview { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; }
|
|
88
101
|
.spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
|
|
89
102
|
.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); }
|
|
90
103
|
.spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
|
|
91
104
|
.spacing-samples > span:nth-child(3) { margin-inline-start: var(--ui-space-3) !important; }
|
|
92
105
|
.spacing-samples > span:nth-child(4) { margin-inline-start: var(--ui-space-4) !important; }
|
|
106
|
+
.palette-option { display: flex; align-items: center; gap: var(--ui-space-2); }
|
|
107
|
+
.palette-swatches { display: flex; gap: 0.125rem; flex: none; }
|
|
108
|
+
.palette-swatches > span { display: block; inline-size: 0.65rem; block-size: 0.65rem; border-radius: var(--ui-radius-xs); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-text) 12%, transparent); }
|
|
93
109
|
.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; }
|
|
94
110
|
.palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
|
|
95
|
-
.palette-canvas { background: var(--ui-surface); }
|
|
96
|
-
.palette-content { background: var(--ui-surface-raised); }
|
|
97
|
-
.palette-action { background: var(--ui-accent); }
|
|
98
|
-
@media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 2; position: static; max-block-size: none; } [data-theme-sampler] { order: 1; } .examples { order: 3; } }
|
|
111
|
+
.palette-canvas { background: var(--_palette-canvas, var(--ui-surface)); }
|
|
112
|
+
.palette-content { background: var(--_palette-content, var(--ui-surface-raised)); }
|
|
113
|
+
.palette-action { background: var(--_palette-action, var(--ui-accent)); }
|
|
114
|
+
@media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 2; position: static; max-block-size: none; } .palette-preview { position: static; max-block-size: none; overflow: visible; padding: 0 !important; } [data-theme-sampler] { order: 1; } .examples { order: 3; } }
|
|
99
115
|
</style></template>`;
|
|
100
116
|
}
|
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, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme, nearbyVariation } from './theme.mjs';
|
|
5
5
|
|
|
6
6
|
import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
|
|
7
7
|
|
|
@@ -21,6 +21,9 @@ export function connect(host) {
|
|
|
21
21
|
let saving = false;
|
|
22
22
|
let alive = true;
|
|
23
23
|
let selectedExample = '';
|
|
24
|
+
const catalogExamples = [...root.querySelectorAll('[data-add-example] option[data-example]')].map(option => JSON.parse(option.dataset.example));
|
|
25
|
+
const removedExamples = new Set();
|
|
26
|
+
let previewQuery = '', previewLimit = 9;
|
|
24
27
|
let refinementTrigger;
|
|
25
28
|
let previewFrame = 0, pending = false, candidate, gestureStart;
|
|
26
29
|
const abort = new AbortController();
|
|
@@ -31,6 +34,7 @@ export function connect(host) {
|
|
|
31
34
|
const editorFor = (field, value) => valueEditor(field, value, { remPixels: parseFloat(recipe.values['text-size']) });
|
|
32
35
|
let boundsBase;
|
|
33
36
|
const updateControl = (element, value) => {
|
|
37
|
+
if (element.matches('[data-choice]')) element.dataset.selectedValue = value;
|
|
34
38
|
const field = fields.get(element.dataset.token);
|
|
35
39
|
if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
|
|
36
40
|
const editor = editorFor(field, value);
|
|
@@ -71,23 +75,34 @@ export function connect(host) {
|
|
|
71
75
|
for (const family of ['material','palette']) {
|
|
72
76
|
const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
|
|
73
77
|
for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
|
|
74
|
-
for (const option of select.
|
|
78
|
+
for (const option of select.querySelectorAll('option')) if (presets[option.value]) {
|
|
79
|
+
const label = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
|
|
80
|
+
if (option.textContent !== label) option.textContent = label;
|
|
81
|
+
}
|
|
75
82
|
updateControl(select, state.id);
|
|
83
|
+
const origin = startingPoints[recipe.origin ?? startingPointFor(recipe)];
|
|
84
|
+
const muted = Boolean(origin && themeFields.filter(field => field.family === (family === 'palette' ? 'Colour' : 'Material')).every(field => origin.values[field.id] === recipe.values[field.id]));
|
|
85
|
+
componentRegistry.update(select, { muted });
|
|
76
86
|
}
|
|
77
87
|
componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
|
|
78
88
|
}
|
|
79
|
-
for (const control of root.querySelectorAll('[data-font-picker]')) componentRegistry.update(control, { value: recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
|
|
89
|
+
for (const control of root.querySelectorAll('[data-font-picker]')) 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') });
|
|
80
90
|
componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
|
|
81
91
|
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
82
92
|
const report = root.querySelector('[data-palette-adjustments]');
|
|
83
|
-
report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your
|
|
84
|
-
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('; ') + '.
|
|
93
|
+
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
|
+
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.';
|
|
85
95
|
for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
|
|
86
96
|
const cssControl = root.querySelector('[data-css]');
|
|
87
97
|
if (cssControl.getClientRects().length) updateControl(cssControl, css);
|
|
88
98
|
for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
|
|
89
|
-
const
|
|
90
|
-
root.
|
|
99
|
+
for (const button of root.querySelectorAll('[data-relationship]')) button.setAttribute('aria-pressed', String(button.dataset.relationship === recipe.values['palette-relationship']));
|
|
100
|
+
for (const sample of root.querySelectorAll('[data-relationship-preview]')) {
|
|
101
|
+
if (!sample.getClientRects().length) continue;
|
|
102
|
+
const tokens = deriveTheme({ ...recipe, values: { ...recipe.values, 'palette-relationship': sample.dataset.relationshipPreview } }, { mode: 'light' }).tokens;
|
|
103
|
+
for (const [role, token] of [['canvas','--ui-surface'],['content','--ui-surface-raised'],['action','--ui-accent']]) sample.style.setProperty('--_palette-' + role, tokens[token]);
|
|
104
|
+
}
|
|
105
|
+
for (const indicator of root.querySelectorAll('[data-relationship-selected]')) indicator.hidden = indicator.dataset.relationshipSelected !== recipe.values['palette-relationship'];
|
|
91
106
|
root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
|
|
92
107
|
? 'Light strength is zero: reflections, glow and bright edge catches are off. Depth shadows and the dark edge remain; depth still follows the source direction.'
|
|
93
108
|
: Number(recipe.values.finish) === 0 ? 'This material is matte: light spread has no reflection to shape. Raise Finish or choose Polished to see it; depth and illuminated edges remain independent.'
|
|
@@ -133,17 +148,25 @@ export function connect(host) {
|
|
|
133
148
|
gestureStart = undefined;
|
|
134
149
|
};
|
|
135
150
|
const filterExamples = () => {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
const query = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim();
|
|
152
|
+
if (query !== previewQuery) { previewQuery = query; previewLimit = 9; }
|
|
153
|
+
const words = query.split(/\s+/).filter(Boolean), seen = new Set(), identities = new Set(), matches = [];
|
|
154
|
+
const include = (example, chosen = false) => {
|
|
155
|
+
const text = [example.title, example.tag, example.description, example.exampleTitle, example.category].join(' ').toLocaleLowerCase();
|
|
156
|
+
if (removedExamples.has(example.id) || identities.has(example.id) || (!chosen && seen.has(example.tag)) || !words.every(word => text.includes(word))) return;
|
|
157
|
+
identities.add(example.id); seen.add(example.tag); matches.push(example);
|
|
158
|
+
};
|
|
159
|
+
host.state.cards.forEach(example => include(example, true));
|
|
160
|
+
catalogExamples.forEach(example => include(example));
|
|
161
|
+
host.state.previews = matches.slice(0, previewLimit);
|
|
162
|
+
const visible = Math.min(matches.length, previewLimit);
|
|
163
|
+
root.querySelector('[data-example-count]').textContent = visible ? `Showing ${visible} of ${matches.length} preview${matches.length === 1 ? '' : 's'}` : 'No components match. Try another name, tag or purpose.';
|
|
164
|
+
root.querySelector('[data-action="more-examples"]').hidden = visible >= matches.length;
|
|
165
|
+
view.requestAnimationFrame(paint);
|
|
144
166
|
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
|
|
145
167
|
};
|
|
146
168
|
const refinedRecipe = values => {
|
|
169
|
+
if (recipe.values['display-font'] === recipe.values['body-font'] && values['display-font'] === recipe.values['display-font']) values = { ...values, 'display-font': values['body-font'] };
|
|
147
170
|
const presets = { ...recipe.presets };
|
|
148
171
|
for (const family of ['material', 'palette']) {
|
|
149
172
|
const state = refinementState(recipe, family);
|
|
@@ -195,7 +218,7 @@ export function connect(host) {
|
|
|
195
218
|
case 'preview-variation': {
|
|
196
219
|
clearPreview();
|
|
197
220
|
const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
|
|
198
|
-
candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').
|
|
221
|
+
candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').dataset.selectedValue, locks);
|
|
199
222
|
theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
|
|
200
223
|
const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
|
|
201
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.';
|
|
@@ -235,10 +258,14 @@ export function connect(host) {
|
|
|
235
258
|
if (!option) break;
|
|
236
259
|
const selected = JSON.parse(option.dataset.example);
|
|
237
260
|
const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
|
|
238
|
-
|
|
261
|
+
removedExamples.delete(card.id);
|
|
262
|
+
if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [card, ...host.state.cards];
|
|
263
|
+
updateControl(root.querySelector('[data-example-query]'), ''); previewQuery = '';
|
|
264
|
+
previewLimit = Math.max(previewLimit, host.state.cards.length);
|
|
239
265
|
say(`${card.title} · ${card.exampleTitle} added to preview.`);
|
|
240
266
|
view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
|
|
241
267
|
}
|
|
268
|
+
case 'more-examples': previewLimit += 9; filterExamples(); break;
|
|
242
269
|
case 'css': {
|
|
243
270
|
const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
|
|
244
271
|
if (!response.ok) throw new Error('Could not load the shared theme recipe.');
|
|
@@ -246,7 +273,12 @@ export function connect(host) {
|
|
|
246
273
|
}
|
|
247
274
|
case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
|
|
248
275
|
case 'import': file.click(); break;
|
|
249
|
-
case 'reset':
|
|
276
|
+
case 'reset': {
|
|
277
|
+
const origin = recipe.origin ?? startingPointFor(recipe);
|
|
278
|
+
show(startingPoints[origin] ? { ...structuredClone(startingPoints[origin]), origin } : structuredClone(saved));
|
|
279
|
+
sayApplied('Restored this theme’s starting values. Undo brings your changes back.'); break;
|
|
280
|
+
}
|
|
281
|
+
case 'palette-relationship': show(refinedRecipe({ ...recipe.values, 'palette-relationship': button.dataset.relationship })); sayApplied('Color relationship applied to both modes.'); break;
|
|
250
282
|
case 'save': {
|
|
251
283
|
if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
|
|
252
284
|
const submitted = structuredClone(recipe);
|
|
@@ -266,6 +298,7 @@ export function connect(host) {
|
|
|
266
298
|
finally { if (button.dataset.action === 'save') saving = false; }
|
|
267
299
|
};
|
|
268
300
|
const change = async event => {
|
|
301
|
+
const value = event.detail?.value ?? event.target.value;
|
|
269
302
|
if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
|
|
270
303
|
input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
|
|
271
304
|
}
|
|
@@ -282,16 +315,21 @@ export function connect(host) {
|
|
|
282
315
|
}
|
|
283
316
|
if (event.target.matches('[data-refinement-preset]')) {
|
|
284
317
|
const family = event.target.dataset.refinementPreset;
|
|
285
|
-
show(applyRefinement(recipe, family,
|
|
318
|
+
show(applyRefinement(recipe, family, value));
|
|
286
319
|
sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
|
|
287
320
|
}
|
|
288
|
-
if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[
|
|
321
|
+
if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[value]), origin: value }); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
|
|
289
322
|
if (event.target === file && file.files[0]) {
|
|
290
323
|
try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
|
|
291
324
|
catch (error) { say(error.message, true); }
|
|
292
325
|
file.value = '';
|
|
293
326
|
}
|
|
294
327
|
};
|
|
328
|
+
const choose = event => {
|
|
329
|
+
if (!event.target.matches('[data-choice]') || event.detail.kind !== 'selection') return;
|
|
330
|
+
event.target.dataset.selectedValue = event.detail.value;
|
|
331
|
+
if (!event.target.matches('[data-variation-choice]')) change(event);
|
|
332
|
+
};
|
|
295
333
|
const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
|
|
296
334
|
const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
|
|
297
335
|
const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
|
|
@@ -302,17 +340,29 @@ export function connect(host) {
|
|
|
302
340
|
const ready = event => {
|
|
303
341
|
if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
|
|
304
342
|
};
|
|
305
|
-
const remove = event => {
|
|
343
|
+
const remove = event => {
|
|
344
|
+
const example = catalogExamples.find(card => card.id === event.detail.id);
|
|
345
|
+
const chosen = host.state.cards.some(card => card.id === event.detail.id);
|
|
346
|
+
host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id);
|
|
347
|
+
const siblingRemains = host.state.cards.some(card => card.tag === example?.tag);
|
|
348
|
+
for (const card of catalogExamples) if (card.tag === example?.tag && (!siblingRemains || card.id === event.detail.id)) removedExamples.add(card.id);
|
|
349
|
+
if (chosen) previewLimit = Math.max(9, previewLimit - 1);
|
|
350
|
+
view.requestAnimationFrame(filterExamples);
|
|
351
|
+
};
|
|
306
352
|
let fontRequest = 0;
|
|
307
353
|
const selectFont = async event => {
|
|
308
354
|
const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
|
|
309
355
|
flush(); clearPreview();
|
|
310
356
|
const id = control.dataset.fontPicker, family = event.detail.value;
|
|
311
357
|
const fonts = { ...recipe.fonts };
|
|
312
|
-
if (family === 'system') delete fonts[id]; else fonts[id] = family;
|
|
313
|
-
const
|
|
358
|
+
if (family === 'system' || family === 'inherit') delete fonts[id]; else fonts[id] = family;
|
|
359
|
+
const value = family === 'inherit' ? recipe.values['body-font'] : family === 'system' ? systemFont : googleFontStack(family);
|
|
360
|
+
const linked = recipe.values['display-font'] === recipe.values['body-font'];
|
|
361
|
+
if (id === 'body-font' && linked) delete fonts['display-font'];
|
|
362
|
+
const next = { ...refinedRecipe({ ...recipe.values, [id]: value }), fonts };
|
|
363
|
+
if (id === 'display-font' && family === 'inherit' && fonts['body-font']) next.fonts['display-font'] = fonts['body-font'];
|
|
314
364
|
const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
|
|
315
|
-
show(next); status.textContent = family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
|
|
365
|
+
show(next); status.textContent = family === 'inherit' ? 'Headings use the main font.' : family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
|
|
316
366
|
try {
|
|
317
367
|
await loadGoogleFonts(document, fonts);
|
|
318
368
|
if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
|
|
@@ -332,6 +382,7 @@ export function connect(host) {
|
|
|
332
382
|
root.addEventListener('input', input);
|
|
333
383
|
root.addEventListener('cancel', cancel);
|
|
334
384
|
root.addEventListener('keydown', keyboard);
|
|
385
|
+
root.addEventListener('value-change', choose);
|
|
335
386
|
root.addEventListener('value-change', selectExample);
|
|
336
387
|
root.addEventListener('value-change', selectFont);
|
|
337
388
|
root.addEventListener('blur', blur, true);
|
|
@@ -352,5 +403,5 @@ export function connect(host) {
|
|
|
352
403
|
root.querySelector('[data-action="save"]').hidden = false;
|
|
353
404
|
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
354
405
|
}
|
|
355
|
-
return () => { root.removeEventListener('toggle', updateSizes, true); flush(); clearPreview(); 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', 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(themeEvent, synchronize); };
|
|
406
|
+
return () => { root.removeEventListener('toggle', updateSizes, true); flush(); clearPreview(); 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(themeEvent, synchronize); };
|
|
356
407
|
}
|
package/docs/documentation.css
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
:root { --markupress-header-notice-display: none; }
|
|
2
|
+
:root[data-looma-documentation-theme] { --markupress-header-notice-display: inline-flex; }
|
|
1
3
|
/* Documentation presentation owns only its native preview frame and code overflow. Controls use Looma. */
|
|
2
4
|
[data-example-card] iframe { display: block; max-inline-size: 100%; border: 0; background: transparent; }
|
|
3
5
|
[data-example-card] pre { overflow: auto; }
|
package/docs/palette.mjs
CHANGED
|
@@ -51,9 +51,9 @@ export function paletteSeeds(values) {
|
|
|
51
51
|
if (tint) l = clamp(l, .08 * tint, 1 - .03 * tint);
|
|
52
52
|
if (relationship !== 'preserve') { h = brandHue + (relationship === 'neighboring' ? 30 : 180); c = Math.max(c, .018); }
|
|
53
53
|
if (warmth !== 0) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
54
|
+
const strength = Math.abs(warmth);
|
|
55
|
+
const target = fromLch([l, warmth > 0 ? .025 : .018, warmth > 0 ? 85 : 255]);
|
|
56
|
+
[l, c, h] = toLch(mixColour(fromLch([l, c, h]), target, 1 - strength));
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
return [key, fromLch([l, c * intensity, h])];
|
package/docs/site.mjs
CHANGED
|
@@ -178,7 +178,7 @@ export function documentationPlugin(site) {
|
|
|
178
178
|
export async function documentationOptions(site) {
|
|
179
179
|
const { siteOptions } = await tool('markupress', site.root);
|
|
180
180
|
const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
|
|
181
|
-
const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
|
|
181
|
+
const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', headerNotice: 'Custom theme', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
|
|
182
182
|
const savedTheme = path.join(site.generated, 'saved-theme.css');
|
|
183
183
|
await writeFile(savedTheme, site.config.themeCSSContents ?? themeCSS(site.config.recipe ?? defaultRecipe()));
|
|
184
184
|
const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
|
package/docs/theme-session.mjs
CHANGED
|
@@ -59,6 +59,7 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
|
|
|
59
59
|
if (event.newValue !== null && !next) return;
|
|
60
60
|
selected = next; draft = comparison = undefined; past.length = future.length = 0; paint();
|
|
61
61
|
});
|
|
62
|
+
view.addEventListener('markupress-header-notice-dismiss', () => session.reset());
|
|
62
63
|
paint();
|
|
63
64
|
return session;
|
|
64
65
|
}
|
package/docs/theme.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { mixColour as mix, paletteSeeds, companionSurface, toLch, fromLch } from
|
|
|
3
3
|
|
|
4
4
|
const bounds = {
|
|
5
5
|
'text-size': [14, 24], 'control-roominess': [2, 4], 'corner-roundness': [0, 32],
|
|
6
|
-
'line-weight': [.5, 3], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
|
|
6
|
+
'line-weight': [.5, 3], rhythm: [.75, 1.5], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
|
|
7
7
|
'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
|
|
8
8
|
'response-time': [0, 600],
|
|
9
9
|
};
|
|
@@ -16,12 +16,13 @@ export const themeFields = [
|
|
|
16
16
|
['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
|
|
17
17
|
['Colour', 'warmth', 'Surface warmth', null, 'fraction', '0.5', '0% cool, 50% original, 100% warm paper and ink; your brand hue stays independent.'],
|
|
18
18
|
['Colour', 'color-intensity', 'Color intensity', null, 'fraction', '0.5', '0% quiet, 50% original, 100% vivid, coordinated across surfaces, actions and status colors.'],
|
|
19
|
-
['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep
|
|
20
|
-
['Type', 'body-font', '
|
|
21
|
-
['Type', 'display-font', '
|
|
19
|
+
['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep base hues, or relate quiet surfaces to the brand through neighboring or complementary hues.'],
|
|
20
|
+
['Type', 'body-font', 'Font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
|
|
21
|
+
['Type', 'display-font', 'Heading font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
|
|
22
22
|
['Type', 'text-size', 'Base text size', '--ui-font-size', 'positive-length', '16px', 'Sets the document root in px. Type, spacing and layout use rem relationships and scale from this base once.'],
|
|
23
23
|
['Type', 'text-weight', 'Emphasis weight', '--ui-font-medium', 'weight', '500', 'Control-label emphasis; body and headings keep their roles.'],
|
|
24
24
|
['Size & spacing', 'control-roominess', 'Control roominess', '--ui-control-roominess', 'positive', '2.5', 'Shared control height as a multiple of base text size; buttons, fields and swatches stay aligned.'],
|
|
25
|
+
['Size & spacing', 'rhythm', 'Rhythm', '--ui-rhythm', 'positive', '1', 'More or less breathing room between lines and groups. Spacing fine-tunes gaps independently.'],
|
|
25
26
|
['Size & spacing', 'spacing', 'Spacing', '--ui-spacing', 'positive', '1', 'More or less breathing room throughout the interface.'],
|
|
26
27
|
['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls. At 1 the sizes are equal; higher values separate them.'],
|
|
27
28
|
['Size & spacing', 'space-curve', 'Spacing curve', '--ui-space-curve', 'nonnegative', '1', 'Spacing growth: 1 is linear, 2 is quadratic. Independent of control sizes.'],
|
|
@@ -31,7 +32,7 @@ export const themeFields = [
|
|
|
31
32
|
['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
|
|
32
33
|
['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Clear through richly frosted: the upper range adds stronger blur, saturation and glass edges. Content stays readable.'],
|
|
33
34
|
['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
|
|
34
|
-
['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating
|
|
35
|
+
['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating material around opaque content.'],
|
|
35
36
|
['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
|
|
36
37
|
['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
|
|
37
38
|
['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Reflected light beyond a surface, from a quiet halo to a vivid bloom at the upper end.'],
|
|
@@ -47,11 +48,11 @@ export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntri
|
|
|
47
48
|
const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
|
|
48
49
|
export const startingPoints = {
|
|
49
50
|
clear: defaultRecipe(),
|
|
50
|
-
bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
|
|
51
|
-
wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
|
|
52
|
-
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' }),
|
|
53
|
-
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)' }),
|
|
54
|
-
garden: starting({ action: '#256342', canvas: '#
|
|
51
|
+
bookish: starting({ rhythm: '1.05', action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
|
|
52
|
+
wiki: starting({ rhythm: '0.8', 'text-weight': '400', 'space-curve': '0.8', action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
|
|
53
|
+
editorial: starting({ rhythm: '1.3', 'text-size': '17px', spacing: '1.12', 'space-curve': '1.4', 'scale-contrast': '1.3', action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
|
|
54
|
+
night: starting({ rhythm: '0.9', 'display-font': 'ui-monospace, monospace', 'text-weight': '600', 'control-roominess': '2.375', spacing: '0.9', 'scale-contrast': '1.25', 'space-curve': '1.2', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25', 'light-x': '0.25', 'light-spread': '0.65', action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
|
|
55
|
+
garden: starting({ rhythm: '1.2', 'body-font': 'ui-rounded, system-ui, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'space-curve': '1.15', finish: '0.15', 'light-spread': '1', action: '#256342', canvas: '#f4edda', surface: '#fff8e7', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
|
|
55
56
|
};
|
|
56
57
|
export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
|
|
57
58
|
const paletteKeys = themeFields.filter(field => field.family === 'Colour').map(field => field.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadlabs/looma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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.
|
|
130
|
+
"markupress": "^0.9.1",
|
|
131
131
|
"@nextwebwg/htmlkit": "1.0.0-alpha.69",
|
|
132
132
|
"vite": "^8.3.4"
|
|
133
133
|
},
|