@threadlabs/looma 0.33.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/shared/overlay.js +8 -2
- package/components/shared/surface-material.js +21 -0
- package/components/ui-badge/ui-badge.html +11 -3
- package/components/ui-button/ui-button.html +92 -51
- package/components/ui-card/ui-card.html +50 -6
- package/components/ui-checkbox/ui-checkbox.html +4 -2
- package/components/ui-curve-editor/curve.js +10 -0
- package/components/ui-curve-editor/ui-curve-editor.html +181 -0
- package/components/ui-curve-editor/ui-curve-editor.js +51 -0
- package/components/ui-dialog/ui-dialog.html +64 -1
- package/components/ui-dialog/ui-dialog.js +9 -1
- package/components/ui-icon-button/ui-icon-button.html +97 -31
- package/components/ui-input/ui-input.html +2 -1
- package/components/ui-input-group/ui-input-group.html +4 -1
- package/components/ui-light-editor/ui-light-editor.html +45 -0
- package/components/ui-light-editor/ui-light-editor.js +53 -0
- package/components/ui-menu/ui-menu.html +62 -0
- package/components/ui-menu/ui-menu.js +1 -1
- package/components/ui-number-input/arithmetic.js +47 -0
- package/components/ui-number-input/ui-number-input.html +63 -0
- package/components/ui-number-input/ui-number-input.js +47 -0
- package/components/ui-popover/ui-popover.html +66 -1
- package/components/ui-popover/ui-popover.js +1 -1
- package/components/ui-select/ui-select.html +9 -7
- package/components/ui-slider/ui-slider.html +84 -0
- package/components/ui-slider/ui-slider.js +29 -0
- package/components/ui-switch/ui-switch.html +3 -0
- package/components/ui-tabs/ui-tabs.html +6 -1
- package/components/ui-text/ui-text.html +31 -6
- package/components/ui-textarea/ui-textarea.html +2 -1
- package/components/ui-tooltip/ui-tooltip.html +60 -0
- package/components/ui-tooltip/ui-tooltip.js +1 -1
- package/dist/index.js +47 -43
- package/docs/README.md +59 -5
- package/docs/card.html +16 -2
- package/docs/card.js +17 -2
- package/docs/catalog.json +1640 -108
- package/docs/customizer-render.mjs +50 -11
- package/docs/customizer.js +137 -24
- package/docs/documentation.css +9 -1
- package/docs/index.d.ts +8 -3
- package/docs/index.mjs +1 -1
- package/docs/preview.js +2 -0
- package/docs/render.mjs +16 -7
- package/docs/site.mjs +78 -27
- package/docs/theme-session.mjs +53 -0
- package/docs/theme.mjs +189 -18
- package/docs/value-editors.mjs +33 -0
- package/package.json +2 -2
- package/styles/ui-badge.css +11 -6
- package/styles/ui-button.css +74 -49
- package/styles/ui-card.css +51 -4
- package/styles/ui-checkbox.css +4 -2
- package/styles/ui-curve-editor.css +109 -0
- package/styles/ui-dialog.css +136 -0
- package/styles/ui-icon-button.css +85 -28
- package/styles/ui-input-group.css +4 -1
- package/styles/ui-input.css +2 -1
- package/styles/ui-light-editor.css +28 -0
- package/styles/ui-menu.css +74 -0
- package/styles/ui-number-input.css +34 -0
- package/styles/ui-popover.css +76 -0
- package/styles/ui-select.css +9 -7
- package/styles/ui-slider.css +53 -0
- package/styles/ui-switch.css +3 -0
- package/styles/ui-tabs.css +6 -1
- package/styles/ui-text.css +23 -1
- package/styles/ui-textarea.css +2 -1
- package/styles/ui-tooltip.css +72 -0
- package/theme-light.css +1 -0
- package/theme-system.css +69 -17
- package/tokens.css +12 -2
- package/vanilla/UiButton.d.ts +1 -0
- package/vanilla/UiButton.js +195 -130
- package/vanilla/UiCard.d.ts +1 -0
- package/vanilla/UiCard.js +12 -12
- package/vanilla/UiCurveEditor.d.ts +28 -0
- package/vanilla/UiCurveEditor.js +134 -0
- package/vanilla/UiIconButton.d.ts +2 -1
- package/vanilla/UiIconButton.js +58 -31
- package/vanilla/UiLightEditor.d.ts +29 -0
- package/vanilla/UiLightEditor.js +102 -0
- package/vanilla/UiNumberInput.d.ts +33 -0
- package/vanilla/UiNumberInput.js +86 -0
- package/vanilla/UiSlider.d.ts +33 -0
- package/vanilla/UiSlider.js +125 -0
- package/vanilla/UiText.d.ts +2 -2
- package/vanilla/UiText.js +45 -11
- package/vanilla/index.js +4 -0
- package/vue/UiBadge.vue +29 -4
- package/vue/UiButton.d.ts +6 -0
- package/vue/UiButton.vue +188 -60
- package/vue/UiCard.d.ts +6 -0
- package/vue/UiCard.vue +80 -6
- package/vue/UiCheckbox.vue +4 -2
- package/vue/UiCurveEditor.d.ts +53 -0
- package/vue/UiCurveEditor.js +2 -0
- package/vue/UiCurveEditor.vue +427 -0
- package/vue/UiDialog.vue +181 -1
- package/vue/UiIconButton.d.ts +8 -2
- package/vue/UiIconButton.vue +190 -45
- package/vue/UiInput.vue +2 -1
- package/vue/UiInputGroup.vue +4 -1
- package/vue/UiLightEditor.d.ts +75 -0
- package/vue/UiLightEditor.js +2 -0
- package/vue/UiLightEditor.vue +402 -0
- package/vue/UiMenu.vue +91 -0
- package/vue/UiNumberInput.d.ts +83 -0
- package/vue/UiNumberInput.js +2 -0
- package/vue/UiNumberInput.vue +399 -0
- package/vue/UiPopover.vue +94 -1
- package/vue/UiSelect.vue +16 -10
- package/vue/UiSlider.d.ts +83 -0
- package/vue/UiSlider.js +2 -0
- package/vue/UiSlider.vue +412 -0
- package/vue/UiSwitch.vue +3 -0
- package/vue/UiTabs.vue +6 -1
- package/vue/UiText.d.ts +7 -5
- package/vue/UiText.vue +104 -22
- package/vue/UiTextarea.vue +2 -1
- package/vue/UiTooltip.vue +89 -0
- package/vue/chunks/UiActionBar.js +1 -1
- package/vue/chunks/UiAffordanceScope.js +2 -2
- package/vue/chunks/UiAvatar.js +2 -2
- package/vue/chunks/UiAvatarGroup.js +2 -2
- package/vue/chunks/UiBadge.js +3 -3
- package/vue/chunks/UiBreadcrumbItem.js +2 -2
- package/vue/chunks/UiBreadcrumbs.js +2 -2
- package/vue/chunks/UiButton.js +94 -11
- package/vue/chunks/UiCallout.js +2 -2
- package/vue/chunks/UiCard.js +34 -5
- package/vue/chunks/UiCheckbox.js +3 -3
- package/vue/chunks/UiCluster.js +2 -2
- package/vue/chunks/UiColorPicker.js +2 -2
- package/vue/chunks/UiCombobox.js +2 -2
- package/vue/chunks/UiContainer.js +2 -2
- package/vue/chunks/UiContextMenu.js +2 -2
- package/vue/chunks/UiCover.js +2 -2
- package/vue/chunks/UiCurveEditor.js +413 -0
- package/vue/chunks/UiDescriptionItem.js +2 -2
- package/vue/chunks/UiDescriptionList.js +2 -2
- package/vue/chunks/UiDialog.js +4 -4
- package/vue/chunks/UiDisclosure.js +2 -2
- package/vue/chunks/UiEditable.js +2 -2
- package/vue/chunks/UiEditorBlockActions.js +2 -2
- package/vue/chunks/UiEditorChipEditor.js +2 -2
- package/vue/chunks/UiEditorCodeLanguage.js +2 -2
- package/vue/chunks/UiEditorFootnoteEditor.js +2 -2
- package/vue/chunks/UiEditorImageToolbar.js +3 -3
- package/vue/chunks/UiEditorInsertTableGrid.js +2 -2
- package/vue/chunks/UiEditorLinkCard.js +3 -3
- package/vue/chunks/UiEditorLinkForm.js +3 -3
- package/vue/chunks/UiEditorMentionMenu.js +2 -2
- package/vue/chunks/UiEditorMentionMenuItem.js +2 -2
- package/vue/chunks/UiEditorSlashMenu.js +2 -2
- package/vue/chunks/UiEditorSlashMenuGroup.js +2 -2
- package/vue/chunks/UiEditorSlashMenuItem.js +2 -2
- package/vue/chunks/UiEditorSurface.js +2 -2
- package/vue/chunks/UiEditorTableContextMenu.js +2 -2
- package/vue/chunks/UiEditorTableOverlay.js +2 -2
- package/vue/chunks/UiEditorTableToolbar.js +2 -2
- package/vue/chunks/UiEditorToolbar.js +2 -2
- package/vue/chunks/UiFloatingActionButton.js +2 -2
- package/vue/chunks/UiFormField.js +2 -2
- package/vue/chunks/UiGrid.js +2 -2
- package/vue/chunks/UiIcon.js +2 -2
- package/vue/chunks/UiIconButton.js +77 -11
- package/vue/chunks/UiImage.js +2 -2
- package/vue/chunks/UiInput.js +3 -3
- package/vue/chunks/UiInputGroup.js +3 -3
- package/vue/chunks/UiLightEditor.js +347 -0
- package/vue/chunks/UiList.js +2 -2
- package/vue/chunks/UiListItem.js +2 -2
- package/vue/chunks/UiListbox.js +2 -2
- package/vue/chunks/UiMenu.js +3 -3
- package/vue/chunks/UiMenuGroup.js +2 -2
- package/vue/chunks/UiMenuItem.js +2 -2
- package/vue/chunks/UiMeter.js +2 -2
- package/vue/chunks/UiNavItem.js +2 -2
- package/vue/chunks/UiNumberInput.js +403 -0
- package/vue/chunks/UiPageHeader.js +2 -2
- package/vue/chunks/UiPopover.js +4 -4
- package/vue/chunks/UiRadio.js +2 -2
- package/vue/chunks/UiRadioGroup.js +2 -2
- package/vue/chunks/UiReel.js +2 -2
- package/vue/chunks/UiScrollArea.js +2 -2
- package/vue/chunks/UiSearchResultRow.js +2 -2
- package/vue/chunks/UiSearchShell.js +2 -2
- package/vue/chunks/UiSection.js +2 -2
- package/vue/chunks/UiSelect.js +3 -3
- package/vue/chunks/UiSeparator.js +2 -2
- package/vue/chunks/UiSidebar.js +2 -2
- package/vue/chunks/UiSlider.js +396 -0
- package/vue/chunks/UiSpinner.js +2 -2
- package/vue/chunks/UiStack.js +2 -2
- package/vue/chunks/UiStatusMessage.js +2 -2
- package/vue/chunks/UiSwatch.js +2 -2
- package/vue/chunks/UiSwitch.js +3 -3
- package/vue/chunks/UiSwitcher.js +2 -2
- package/vue/chunks/UiTable.js +2 -2
- package/vue/chunks/UiTabs.js +3 -3
- package/vue/chunks/UiText.js +53 -12
- package/vue/chunks/UiTextarea.js +3 -3
- package/vue/chunks/UiToast.js +2 -2
- package/vue/chunks/UiToastRegion.js +2 -2
- package/vue/chunks/UiTooltip.js +3 -3
- package/vue/chunks/UiTopBar.js +1 -1
- package/vue/chunks/UiTree.js +2 -2
- package/vue/chunks/UiTreeItem.js +2 -2
- package/vue/chunks/_plugin-vue_export-helper.js +68 -2
- package/vue/chunks/props.js +24 -1
- package/vue/components.css +3391 -2139
- package/vue/editor/index.js +623 -211
- package/vue/index.d.ts +4 -0
- package/vue/index.js +6 -2
|
@@ -1,40 +1,79 @@
|
|
|
1
1
|
import { escapeHtml as escape } from './catalog.mjs';
|
|
2
2
|
import { componentPath, previewPath } from './render.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import { valueEditor } from './value-editors.mjs';
|
|
4
|
+
import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
|
|
4
5
|
|
|
5
6
|
export function customizerResource(site, input = defaultRecipe()) {
|
|
6
7
|
const recipe = validateRecipe(input);
|
|
7
8
|
const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
|
|
8
9
|
const examples = site.catalog.components.flatMap(component => component.examples.map(example => ({ id: example.id, title: component.title, description: component.description, exampleTitle: example.title, src: site.base + previewPath(example).slice(1), resizable: Boolean(example.resizable), category: component.group, tag: component.tag, packageName: component.package, nativeRoot: component.root ? `<${component.root}>` : '', href: site.base + componentPath(component).slice(1) })));
|
|
9
10
|
const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
|
|
10
|
-
const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch'].map(tag => examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
|
|
11
|
+
const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-expressive-surface'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
|
|
11
12
|
if (!initial.length && examples.length) initial.push(toCard(examples[0]));
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const renderField = field => {
|
|
14
|
+
const value = recipe.values[field.id], editor = valueEditor(field, value);
|
|
15
|
+
const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
|
|
16
|
+
let control;
|
|
17
|
+
if (field.type === 'easing') {
|
|
18
|
+
control = `<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" aria-label="${escape(field.label)}" value="${escape(value)}"></ui-input><div data-curve-for="${field.id}"${editor.kind === 'text' ? ' hidden' : ''}><ui-curve-editor ${common} data-editor-kind="curve" value="${escape(value)}"></ui-curve-editor></div>`;
|
|
19
|
+
} else if (editor.kind === 'number' || editor.kind === 'slider') {
|
|
20
|
+
const tag = editor.kind === 'slider' ? 'ui-slider' : 'ui-number-input';
|
|
21
|
+
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}>`;
|
|
22
|
+
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;
|
|
23
|
+
} else {
|
|
24
|
+
control = `<ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(value)}" aria-label="${escape(field.label)}"></ui-input></ui-cluster>`;
|
|
25
|
+
}
|
|
26
|
+
return `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span>${control}</ui-form-field>`;
|
|
27
|
+
};
|
|
28
|
+
const renderPreset = (family, location) => {
|
|
29
|
+
const presets = family === 'material' ? materialPresets : themePalettes;
|
|
30
|
+
const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
|
|
31
|
+
return `<ui-form-field><label slot="label" for="${location}-${family}">${label} preset</label><ui-select id="${location}-${family}" data-refinement-preset="${family}" aria-label="${label} preset" value="${state.id}"><option value="custom" disabled>Your ${family}</option>${Object.entries(presets).map(([id, preset]) => `<option value="${id}">${escape(preset.label)}${state.id === id && state.customized ? ' · customized' : ''}</option>`).join('')}</ui-select></ui-form-field>`;
|
|
32
|
+
};
|
|
33
|
+
const paletteDetails = `<ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="caption">Fellowship office</ui-text></ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure>`;
|
|
34
|
+
const supplements = {
|
|
35
|
+
'Colour': `${renderPreset('palette', 'overview')}<p>One coordinated palette generates readable light and dark ranges.</p><ui-button data-action="refine-palette">Refine palette</ui-button>`,
|
|
36
|
+
'Material': `${renderPreset('material', 'overview')}<p>Shared material and light shape the whole design system.</p><ui-button data-action="refine-material">Refine material & light</ui-button>`,
|
|
37
|
+
'Size & spacing': `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
38
|
+
};
|
|
39
|
+
const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id) && !['Colour','Material'].includes(family)).map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
|
|
40
|
+
const refinement = (family, content) => `<section data-refinement-view="${family}" hidden tabindex="-1" aria-label="${family === 'material' ? 'Material and light refinement' : 'Palette refinement'}"><ui-stack gap="xl"><ui-cluster justify="between"><ui-button data-action="back">Back to customization</ui-button><ui-button data-action="restore-${family}">Restore ${family} preset</ui-button></ui-cluster><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><p>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</p>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
|
|
41
|
+
const materialView = refinement('material', `<div class="refinement-layout"><ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="sm"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor><ui-card variant="elevated" treatment="expressive"><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">Second breakfast briefing</ui-text><ui-text>Sam has prepared the agenda. Frodo has the maps, and Sméagol is taking minutes.</ui-text><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="quiet">Read agenda</ui-button></ui-cluster></ui-card><ui-card variant="elevated"><ui-text variant="label">Standard elevated surface</ui-text><ui-text>Subtle lift stays independent from expressive treatment.</ui-text></ui-card></ui-stack><ui-stack><ui-text variant="label">Surface response</ui-text>${themeFields.filter(field => ['finish','edge-light','glow'].includes(field.id)).map(renderField).join('')}<ui-text variant="label">Light source</ui-text>${themeFields.filter(field => ['light-spread','light-strength'].includes(field.id)).map(renderField).join('')}<ui-disclosure summary="Depth, glass and separation"><ui-stack>${themeFields.filter(field => ['outer-depth','inner-depth','glassiness','surface-separation','translucency'].includes(field.id)).map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
|
|
42
|
+
const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}<ui-card><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">There and back by Friday</ui-text><ui-text>Choose a coordinated range, then refine action, paper and ink. Readable roles follow automatically in both modes.</ui-text><ui-cluster><ui-button preset="primary">Share route</ui-button><ui-button preset="standard">Save draft</ui-button></ui-cluster></ui-card></ui-stack><ui-stack>${themeFields.filter(field => field.family === 'Colour').map(renderField).join('')}</ui-stack></div>`);
|
|
43
|
+
const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-description="${escape(example.category + ' · ' + example.tag)}" data-example="${escape(JSON.stringify(example))}" data-search="${escape(`${example.title} ${example.tag} ${example.description} ${example.exampleTitle}`.toLocaleLowerCase())}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
|
|
44
|
+
const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text','ui-light-editor','ui-combobox'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
|
|
16
45
|
return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
|
|
17
46
|
<state name="cards" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
|
|
18
47
|
</defs>
|
|
19
48
|
<div data-looma-customizer data-recipe="${escape(JSON.stringify(recipe))}" data-base="${escape(site.base)}" data-mode="${site.config.local ? 'local' : 'public'}" data-revision="${escape(site.config.revision ?? '')}">
|
|
20
|
-
<ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation.
|
|
21
|
-
<
|
|
49
|
+
<ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Changes apply throughout your documentation and are remembered in this browser until reset. Save to repository writes the theme to your project; downloads let you use it elsewhere.</p>
|
|
50
|
+
<p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
|
|
51
|
+
${materialView}${paletteView}
|
|
52
|
+
<div class="customizer-layout" data-customizer-overview>
|
|
22
53
|
<aside><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
|
|
23
54
|
${families}
|
|
24
55
|
<ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
|
|
25
56
|
<input type="file" accept="application/json,.json" hidden data-recipe-file>
|
|
26
57
|
<ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
|
|
27
|
-
<
|
|
58
|
+
<ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
|
|
28
59
|
<ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
|
|
29
60
|
</ui-stack></aside>
|
|
30
|
-
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-
|
|
61
|
+
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-combobox id="example-choice" data-add-example label="Choose a component example" placeholder="Search buttons, fields, dialogs…" disclosure clearable>${options}</ui-combobox><ui-button data-action="add" disabled>Add to preview</ui-button>
|
|
31
62
|
<ui-grid columns="2" min="sm"><section $each="card of $cards" $key="$card.id"><looma-example-card ${cardFields.filter(name => name !== 'id').map(name => `from:${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="$card.${name}"`).join(' ')} from:identity="$card.id" loading="eager" removable></looma-example-card></section></ui-grid>
|
|
32
63
|
<noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
|
|
33
64
|
</ui-stack></section></div></ui-stack></div>
|
|
34
65
|
<style>
|
|
66
|
+
[data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
|
|
35
67
|
.customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
|
|
36
68
|
aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
|
|
37
69
|
.examples { min-inline-size: 0; }
|
|
38
|
-
|
|
70
|
+
.refinement-layout { display: grid !important; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
|
|
71
|
+
[data-refinement-view] { scroll-margin-block-start: 5rem; }
|
|
72
|
+
.palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
|
|
73
|
+
.palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
|
|
74
|
+
.palette-canvas { background: var(--ui-surface); }
|
|
75
|
+
.palette-content { background: var(--ui-surface-raised); }
|
|
76
|
+
.palette-action { background: var(--ui-accent); }
|
|
77
|
+
@media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
|
|
39
78
|
</style></template>`;
|
|
40
79
|
}
|
package/docs/customizer.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { documentationTheme, themeEvent } from './theme-session.mjs';
|
|
1
2
|
import { componentRegistry } from '@nextwebwg/html-next/runtime';
|
|
2
|
-
import { startingPoints, startingPointFor, themeCSS, validateRecipe } from './theme.mjs';
|
|
3
|
+
import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
|
|
4
|
+
|
|
5
|
+
import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
|
|
3
6
|
|
|
4
7
|
/** The editor changes public theme values; the library owns every control's layout. */
|
|
5
8
|
export function connect(host) {
|
|
@@ -8,39 +11,103 @@ export function connect(host) {
|
|
|
8
11
|
const view = document.defaultView;
|
|
9
12
|
const message = root.querySelector('[data-message]');
|
|
10
13
|
const file = root.querySelector('[data-recipe-file]');
|
|
11
|
-
|
|
12
|
-
let saved =
|
|
14
|
+
const theme = documentationTheme(view);
|
|
15
|
+
let saved = validateRecipe(JSON.parse(root.dataset.recipe));
|
|
16
|
+
let recipe = theme?.recipe ?? structuredClone(saved);
|
|
13
17
|
let revision = root.dataset.revision;
|
|
14
18
|
let capability;
|
|
15
19
|
let changed = false;
|
|
16
20
|
let saving = false;
|
|
17
21
|
let alive = true;
|
|
22
|
+
let selectedExample = '';
|
|
23
|
+
let refinementTrigger;
|
|
18
24
|
const abort = new AbortController();
|
|
19
25
|
const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
|
|
20
|
-
const
|
|
26
|
+
const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
|
|
27
|
+
const fields = new Map(themeFields.map(field => [field.id, field]));
|
|
28
|
+
const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
|
|
29
|
+
const updateControl = (element, value) => {
|
|
30
|
+
const field = fields.get(element.dataset.token);
|
|
31
|
+
if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
|
|
32
|
+
const editor = valueEditor(field, value, unitContext());
|
|
33
|
+
if (element.dataset.editorKind === 'curve') {
|
|
34
|
+
element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
|
|
35
|
+
if (editor.kind === 'curve') componentRegistry.update(element, { value });
|
|
36
|
+
} else componentRegistry.update(element, Object.fromEntries(['value', 'unit', 'min', 'max', 'step'].map(key => [key, editor[key]])));
|
|
37
|
+
};
|
|
38
|
+
const updateUnits = () => {
|
|
39
|
+
for (const control of root.querySelectorAll('[data-unit-for]')) {
|
|
40
|
+
const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
|
|
41
|
+
if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
|
|
42
|
+
componentRegistry.update(control, { value: editor.unit });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
21
45
|
const paint = () => {
|
|
22
|
-
const css = themeCSS(recipe);
|
|
46
|
+
const css = theme?.css || themeCSS(recipe);
|
|
47
|
+
for (const family of ['material','palette']) {
|
|
48
|
+
const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
|
|
49
|
+
for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
|
|
50
|
+
for (const option of select.options) if (presets[option.value]) option.textContent = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
|
|
51
|
+
updateControl(select, state.id);
|
|
52
|
+
}
|
|
53
|
+
componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
|
|
54
|
+
}
|
|
55
|
+
componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
|
|
56
|
+
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
57
|
+
const report = root.querySelector('[data-palette-adjustments]');
|
|
58
|
+
report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
59
|
+
root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
23
60
|
for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
|
|
24
61
|
updateControl(root.querySelector('[data-css]'), css);
|
|
25
62
|
};
|
|
26
|
-
const show = next => {
|
|
63
|
+
const show = (next, persist = true) => {
|
|
27
64
|
recipe = validateRecipe(next);
|
|
65
|
+
if (persist) theme?.apply(recipe);
|
|
28
66
|
for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
|
|
29
67
|
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
68
|
+
updateUnits();
|
|
30
69
|
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
31
70
|
paint();
|
|
32
71
|
};
|
|
72
|
+
const filterExamples = () => {
|
|
73
|
+
const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
74
|
+
const options = [...root.querySelectorAll('[data-add-example] option')];
|
|
75
|
+
for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
|
|
76
|
+
const matches = options.filter(option => words.every(word => option.dataset.search.includes(word))).length;
|
|
77
|
+
root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
|
|
78
|
+
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
|
|
79
|
+
};
|
|
80
|
+
const refinedRecipe = values => {
|
|
81
|
+
const presets = { ...recipe.presets };
|
|
82
|
+
for (const family of ['material', 'palette']) {
|
|
83
|
+
const state = refinementState(recipe, family);
|
|
84
|
+
if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
|
|
85
|
+
}
|
|
86
|
+
return { ...recipe, presets, values };
|
|
87
|
+
};
|
|
33
88
|
const input = event => {
|
|
89
|
+
if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
|
|
90
|
+
if (event.target.matches('[data-light-editor]')) {
|
|
91
|
+
try {
|
|
92
|
+
show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
|
|
93
|
+
sayApplied('Light position remembered throughout your design system.');
|
|
94
|
+
} catch (error) { say(error.message, true); }
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
34
97
|
const control = event.target.closest?.('[data-token]');
|
|
35
98
|
if (!control) return;
|
|
36
|
-
const
|
|
37
|
-
const
|
|
99
|
+
const field = fields.get(control.dataset.token);
|
|
100
|
+
const raw = event.detail?.value ?? control.value;
|
|
101
|
+
const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
|
|
102
|
+
const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
|
|
38
103
|
try {
|
|
39
|
-
recipe = validateRecipe(next);
|
|
104
|
+
recipe = validateRecipe(next);
|
|
105
|
+
theme?.apply(recipe); changed = true;
|
|
40
106
|
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
41
107
|
control.removeAttribute('aria-invalid');
|
|
42
|
-
for (const sibling of root.querySelectorAll('[data-token]')) if (sibling
|
|
43
|
-
|
|
108
|
+
for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
|
|
109
|
+
updateUnits();
|
|
110
|
+
paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
|
|
44
111
|
} catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
|
|
45
112
|
};
|
|
46
113
|
const download = (data, name, type) => {
|
|
@@ -53,13 +120,32 @@ export function connect(host) {
|
|
|
53
120
|
if (!button || (button.dataset.action === 'save' && saving)) return;
|
|
54
121
|
try {
|
|
55
122
|
switch (button.dataset.action) {
|
|
123
|
+
case 'refine-material': case 'refine-palette': {
|
|
124
|
+
const family = button.dataset.action.slice(7);
|
|
125
|
+
refinementTrigger = button;
|
|
126
|
+
root.querySelector('[data-customizer-overview]').hidden = true;
|
|
127
|
+
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
|
|
128
|
+
const section = root.querySelector(`[data-refinement-view="${family}"]`);
|
|
129
|
+
section.focus(); section.scrollIntoView({ block: 'start' });
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'back':
|
|
133
|
+
for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
|
|
134
|
+
root.querySelector('[data-customizer-overview]').hidden = false;
|
|
135
|
+
refinementTrigger?.focus(); break;
|
|
136
|
+
case 'restore-material': case 'restore-palette': {
|
|
137
|
+
const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
|
|
138
|
+
if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
56
141
|
case 'add': {
|
|
57
|
-
const
|
|
58
|
-
|
|
142
|
+
const option = [...root.querySelectorAll('[data-add-example] option')].find(option => option.value === selectedExample);
|
|
143
|
+
if (!option) break;
|
|
59
144
|
const selected = JSON.parse(option.dataset.example);
|
|
60
145
|
const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
|
|
61
146
|
if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
|
|
62
|
-
|
|
147
|
+
say(`${card.title} · ${card.exampleTitle} added to preview.`);
|
|
148
|
+
view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
|
|
63
149
|
}
|
|
64
150
|
case 'css': {
|
|
65
151
|
const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
|
|
@@ -68,7 +154,7 @@ export function connect(host) {
|
|
|
68
154
|
}
|
|
69
155
|
case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
|
|
70
156
|
case 'import': file.click(); break;
|
|
71
|
-
case 'reset': show(structuredClone(saved)); say('
|
|
157
|
+
case 'reset': show(theme?.reset() ?? structuredClone(saved), false); say(theme?.storageAvailable ? 'Browser customization reset to the saved theme.' : 'Changes reset for this page. Browser storage is unavailable, so a previous browser theme may return after reload.'); break;
|
|
72
158
|
case 'save': {
|
|
73
159
|
if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
|
|
74
160
|
const submitted = structuredClone(recipe);
|
|
@@ -77,7 +163,7 @@ export function connect(host) {
|
|
|
77
163
|
const response = await fetch(root.dataset.base + '_looma/theme', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Looma-Capability': capability }, body: JSON.stringify({ recipe: submitted, revision }), signal: abort.signal });
|
|
78
164
|
const result = await response.json();
|
|
79
165
|
if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
|
|
80
|
-
revision = result.revision; saved = submitted;
|
|
166
|
+
revision = result.revision; saved = submitted; theme?.baseline(saved);
|
|
81
167
|
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
82
168
|
if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
|
|
83
169
|
say('Saved to your repository. Reloading your design system…');
|
|
@@ -88,31 +174,58 @@ export function connect(host) {
|
|
|
88
174
|
finally { if (button.dataset.action === 'save') saving = false; }
|
|
89
175
|
};
|
|
90
176
|
const change = async event => {
|
|
91
|
-
if (event.target.matches('[data-
|
|
177
|
+
if (event.target.matches('[data-unit-for]')) {
|
|
178
|
+
const field = fields.get(event.target.dataset.unitFor);
|
|
179
|
+
try {
|
|
180
|
+
let current = recipe.values[field.id];
|
|
181
|
+
try { current = convertValueUnit(current, event.target.value, unitContext()); }
|
|
182
|
+
catch { current = convertValueUnit(view.getComputedStyle(document.documentElement).getPropertyValue(field.property).trim(), event.target.value, unitContext()); }
|
|
183
|
+
show({ ...recipe, values: { ...recipe.values, [field.id]: current } });
|
|
184
|
+
sayApplied(`${field.label} units updated and remembered in this browser.`);
|
|
185
|
+
} catch (error) { updateUnits(); say(error.message, true); }
|
|
186
|
+
}
|
|
187
|
+
if (event.target.matches('[data-refinement-preset]')) {
|
|
188
|
+
const family = event.target.dataset.refinementPreset;
|
|
189
|
+
show(applyRefinement(recipe, family, event.target.value));
|
|
190
|
+
sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
|
|
191
|
+
}
|
|
192
|
+
if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
|
|
92
193
|
if (event.target === file && file.files[0]) {
|
|
93
|
-
try { show(JSON.parse(await file.files[0].text()));
|
|
194
|
+
try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
|
|
94
195
|
catch (error) { say(error.message, true); }
|
|
95
196
|
file.value = '';
|
|
96
197
|
}
|
|
97
198
|
};
|
|
199
|
+
const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
|
|
200
|
+
const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
|
|
98
201
|
const ready = event => {
|
|
99
202
|
if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
|
|
100
203
|
};
|
|
101
|
-
const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
|
|
204
|
+
const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
|
|
205
|
+
const selectExample = event => {
|
|
206
|
+
if (!event.target.matches('[data-add-example]')) return;
|
|
207
|
+
selectedExample = event.detail.value ?? '';
|
|
208
|
+
filterExamples();
|
|
209
|
+
};
|
|
102
210
|
root.addEventListener('looma-example-remove', remove);
|
|
103
211
|
root.addEventListener('input', input);
|
|
212
|
+
root.addEventListener('value-change', selectExample);
|
|
213
|
+
root.addEventListener('blur', blur, true);
|
|
104
214
|
root.addEventListener('change', change);
|
|
105
215
|
root.addEventListener('click', click);
|
|
106
216
|
view.addEventListener('message', ready);
|
|
107
|
-
|
|
217
|
+
view.addEventListener(themeEvent, synchronize);
|
|
218
|
+
show(recipe, false); filterExamples();
|
|
219
|
+
if (theme?.storageAvailable === false) sayApplied('');
|
|
108
220
|
if (root.dataset.mode === 'local') {
|
|
109
221
|
fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
|
|
110
222
|
if (!response.ok) throw new Error('Local theme saving is unavailable.');
|
|
111
|
-
const
|
|
112
|
-
capability =
|
|
113
|
-
|
|
223
|
+
const local = await response.json(); if (!alive) return;
|
|
224
|
+
capability = local.capability;
|
|
225
|
+
saved = validateRecipe(local.recipe); revision = local.revision; theme?.baseline(saved);
|
|
226
|
+
if (!changed && !theme?.hasOverride) show(saved, false);
|
|
114
227
|
root.querySelector('[data-action="save"]').hidden = false;
|
|
115
228
|
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
116
229
|
}
|
|
117
|
-
return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); };
|
|
230
|
+
return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
|
|
118
231
|
}
|
package/docs/documentation.css
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
[data-example-card] iframe { display: block; max-inline-size: 100%; border: 0; background: transparent; }
|
|
3
3
|
[data-example-card] pre { overflow: auto; }
|
|
4
4
|
/* The customizer is an application page; use Markupress's public width token instead of patching its layout. */
|
|
5
|
-
:root:has([data-looma-customizer]), :root:has([data-component-
|
|
5
|
+
:root:has([data-looma-customizer]), :root:has([data-component~="looma-catalog"]) { --markupress-content-width: 110rem; }
|
|
6
6
|
body:has([data-preview]) { background: var(--ui-surface); color: var(--ui-text); font-family: var(--ui-font-sans); font-size: var(--ui-font-size); line-height: var(--ui-line-height); }
|
|
7
7
|
[data-preview] { display: grid; place-items: center; gap: var(--ui-space-4); min-block-size: 13rem; padding: 1.35rem; box-sizing: border-box; }
|
|
8
|
+
[data-preview-layout] { justify-items: stretch; }
|
|
8
9
|
body:has([data-preview]) { margin: 0; background: linear-gradient(90deg, rgb(43 37 56 / .035) 1px, transparent 1px), linear-gradient(rgb(43 37 56 / .035) 1px, transparent 1px), color-mix(in srgb, var(--ui-accent) 5%, var(--ui-surface)); background-size: 22px 22px; }
|
|
10
|
+
|
|
11
|
+
/* These headings are authored page content projected through the example card's heading slot. */
|
|
12
|
+
.looma-reference-examples { padding-block-start: var(--ui-space-5); }
|
|
13
|
+
.looma-example-heading h3 { margin: 0; font-size: var(--ui-font-size-lg); line-height: var(--ui-line-height-tight); }
|
|
14
|
+
.looma-example-heading p { margin: var(--ui-space-2) 0 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-secondary); }
|
|
15
|
+
html[data-preview-reference], html[data-preview-reference] body { background: var(--ui-surface); }
|
|
16
|
+
html[data-preview-reference] [data-preview] { min-block-size: 11rem; }
|
package/docs/index.d.ts
CHANGED
|
@@ -17,8 +17,8 @@ export interface DocumentationCatalog {
|
|
|
17
17
|
resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
|
|
18
18
|
}
|
|
19
19
|
export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
|
|
20
|
-
export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string
|
|
21
|
-
export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string }
|
|
20
|
+
export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string>; presets?: Partial<Record<'material' | 'palette', string>> }
|
|
21
|
+
export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string; min?: number; max?: number }
|
|
22
22
|
export interface DocumentationConfiguration {
|
|
23
23
|
title?: string; base?: string; origin?: string; outDir?: string; content?: string;
|
|
24
24
|
logo?: string | { light: string; dark?: string }; nav?: { label: string; href: string }[];
|
|
@@ -50,5 +50,10 @@ export function previewProjectDocumentation(input?: DocumentationInput & { port?
|
|
|
50
50
|
export const themeFields: readonly ThemeField[];
|
|
51
51
|
export const startingPoints: Record<string, ThemeRecipe>;
|
|
52
52
|
export function defaultRecipe(): ThemeRecipe;
|
|
53
|
+
export const materialPresets: Record<string, { label: string; values: Record<string, string> }>;
|
|
54
|
+
export function applyRefinement(input: ThemeRecipe, family: 'material' | 'palette', id: string): ThemeRecipe;
|
|
55
|
+
export function refinementState(input: ThemeRecipe, family: 'material' | 'palette'): { id: string; customized: boolean };
|
|
53
56
|
export function validateRecipe(input: unknown): ThemeRecipe;
|
|
54
|
-
export
|
|
57
|
+
export interface ResolvedTheme { tokens: Record<string, string>; adjustments: { id: string; requested: string; resolved: string; reason: string }[] }
|
|
58
|
+
export function deriveTheme(input: ThemeRecipe, options?: { mode?: 'light' | 'dark' }): ResolvedTheme;
|
|
59
|
+
export function themeCSS(input: ThemeRecipe, systemCSS?: string, options?: { scope?: 'portable' | 'documentation' }): string;
|
package/docs/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { readCatalog, discoverComponents, mergeCatalogs } from './catalog.mjs';
|
|
2
2
|
export { prepareDocumentation, documentationOptions, documentationPlugin, buildDocumentation } from './site.mjs';
|
|
3
|
-
export { defaultRecipe, startingPoints, themeFields, validateRecipe, themeCSS } from './theme.mjs';
|
|
3
|
+
export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS, materialPresets, applyRefinement, refinementState } from './theme.mjs';
|
|
4
4
|
export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
|
package/docs/preview.js
CHANGED
|
@@ -9,6 +9,8 @@ export function connect(host) {
|
|
|
9
9
|
const receive = event => {
|
|
10
10
|
if (event.source !== view.parent || event.data?.type !== 'looma-theme' || typeof event.data.css !== 'string') return;
|
|
11
11
|
theme.textContent = event.data.css;
|
|
12
|
+
document.documentElement.toggleAttribute('data-preview-reference', event.data.reference === true);
|
|
13
|
+
if (['light', 'dark'].includes(event.data.mode)) document.documentElement.dataset.theme = event.data.mode;
|
|
12
14
|
height();
|
|
13
15
|
};
|
|
14
16
|
view.addEventListener('message', receive);
|
package/docs/render.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { escapeHtml as escape } from './catalog.mjs';
|
|
3
|
+
import { createLoomaMarkdownIt } from '@threadlabs/looma/markdown/markdown-it';
|
|
4
|
+
|
|
5
|
+
const descriptions = createLoomaMarkdownIt().set({ html: false });
|
|
3
6
|
|
|
4
7
|
const literal = value => escape(value).replaceAll('{', '\\{');
|
|
5
8
|
const code = value => `<code>${literal(value ?? '—')}</code>`;
|
|
6
9
|
const table = (head, rows) => `<table><thead><tr>${head.map(h => `<th>${literal(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${value}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
7
10
|
export const exampleKey = id => createHash('sha256').update(id).digest('hex').slice(0, 20);
|
|
8
|
-
export const componentPath = component =>
|
|
11
|
+
export const componentPath = component => `/${component.group === 'editor' ? 'editor' : 'components'}/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
|
|
9
12
|
export const previewPath = example => `/_looma/preview/${exampleKey(example.id)}/`;
|
|
10
13
|
|
|
11
14
|
export function apiBody(api, prefix = '') {
|
|
@@ -67,8 +70,8 @@ export function exampleCodes(example, component, components) {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/** The same card invocation is embedded in reference pages and rendered by the customizer. */
|
|
70
|
-
export function cardMarkup(example, base = '/', component = {}) {
|
|
71
|
-
return `<looma-example-card identity="${escape(example.id)}" title="${escape(component.title ?? example.title)}" description="${escape(component.description ?? example.description)}" example-title="${escape(example.title)}" category="${escape(component.group ?? 'forms')}" tag="${escape(component.tag ?? '')}" package-name="${escape(component.package ?? '')}" native-root="${escape(component.root ? `<${component.root}>` : '')}" href="${component.tag ? base + componentPath(component).slice(1) : ''}" src="${base}${previewPath(example).slice(1)}"${example.resizable ? ' resizable' : ''}></looma-example-card>`;
|
|
73
|
+
export function cardMarkup(example, base = '/', component = {}, { reference = false, code: exampleCode = '' } = {}) {
|
|
74
|
+
return `<looma-example-card identity="${escape(example.id)}"${reference ? ' reference' : ''} title="${escape(component.title ?? example.title)}" description="${escape(reference ? example.description : component.description ?? example.description)}" example-title="${escape(example.title)}" category="${escape(component.group ?? 'forms')}" tag="${escape(component.tag ?? '')}" package-name="${escape(component.package ?? '')}" native-root="${escape(component.root ? `<${component.root}>` : '')}" href="${component.tag ? base + componentPath(component).slice(1) : ''}" src="${base}${previewPath(example).slice(1)}"${example.resizable ? ' resizable' : ''}>${reference ? `<div slot="heading" class="looma-example-heading"><h3 id="example-${exampleKey(example.id)}">${escape(example.title)}</h3><p>${escape(example.description)}</p></div>` : ''}${exampleCode}</looma-example-card>`;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export function referencePage(component, catalog, cardResource, base) {
|
|
@@ -76,9 +79,15 @@ export function referencePage(component, catalog, cardResource, base) {
|
|
|
76
79
|
const examples = [...component.examples, ...parts.flatMap(part => part.examples)];
|
|
77
80
|
const codes = example => {
|
|
78
81
|
const owner = catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component;
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
// Blank lines let Markupress parse real fences inside the projected HTML slots.
|
|
83
|
+
// A fence longer than any authored run keeps snippets literal, including nested fences.
|
|
84
|
+
const tabs = Object.entries(exampleCodes(example, owner, catalog.components)).map(([mode, value]) => {
|
|
85
|
+
const fence = '~'.repeat(Math.max(3, ...[...value.code.matchAll(/~+/g)].map(match => match[0].length + 1)));
|
|
86
|
+
const language = /^[\w+-]+$/.test(value.language) ? value.language : 'text';
|
|
87
|
+
return `<section aria-label="${mode === 'vue' ? 'Vue' : 'HTML Next'}"><div data-theme="dark" style="--ui-surface-sunken: #0d1117">\n\n${fence}${language}\n${value.code}\n${fence}\n\n</div></section>`;
|
|
88
|
+
}).join('\n');
|
|
89
|
+
return `<ui-tabs slot="code" label="Example syntax" group="syntax" style="--ui-tabs-panel-padding: 0px">${tabs}</ui-tabs>`;
|
|
81
90
|
};
|
|
82
|
-
const body = `<ui-stack gap="xl">${examples.map(example => `<section>${cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component
|
|
83
|
-
return `---\ntitle: ${JSON.stringify(component.title)}\ndescription: ${JSON.stringify(component.description || `${component.title} component reference.`)}\nid: ${JSON.stringify(component.id)}\n---\n\n# ${escape(component.title)}\n\n
|
|
91
|
+
const body = `<div class="looma-reference-examples"><ui-stack gap="xl">${examples.map(example => `<ui-card as="section" variant="subtle" padding="lg">${cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component, { reference: true, code: codes(example) })}</ui-card>`).join('')}</ui-stack></div>`;
|
|
92
|
+
return `---\ntitle: ${JSON.stringify(component.title)}\ndescription: ${JSON.stringify(component.description || `${component.title} component reference.`)}\nid: ${JSON.stringify(component.id)}\n---\n\n# ${escape(component.title)}\n\n<div class="component-description"><p>${descriptions.renderInline(component.description).replaceAll('{', '\\{').replaceAll('\n', ' ')}</p></div>\n\n<link rel="component" href="${cardResource}">\n<link rel="component" href="${cardResource.replace(/card\.html$/, 'reference.html')}">\n<link rel="component" href="@threadlabs/looma/components/ui-card/ui-card.html">\n<link rel="component" href="@threadlabs/looma/components/ui-tabs/ui-tabs.html">\n<link rel="component" href="@threadlabs/looma/components/ui-disclosure/ui-disclosure.html">\n<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">\n\n<looma-reference>\n\n<div class="looma-reference-tabs"><ui-tabs label="Component reference" style="--ui-tabs-panel-padding: 0px; --ui-tabs-tab-min-block-size: var(--ui-control-size-lg)"><section aria-label="Examples">${body}</section><section aria-label="API"><h2 id="api">API</h2>\n${apiBody(component)}\n${parts.map(part => `<h2 id="${part.tag}-api">${literal(part.title)} API</h2><p>${literal(part.description)}</p>${apiBody(part, part.tag + '-')}`).join('\n')}</section></ui-tabs></div>\n\n</looma-reference>\n`;
|
|
84
93
|
}
|