@threadlabs/looma 0.33.1 → 0.34.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-button/ui-button.html +63 -47
- package/components/ui-card/ui-card.html +44 -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 +62 -1
- package/components/ui-dialog/ui-dialog.js +9 -1
- package/components/ui-icon-button/ui-icon-button.html +75 -23
- package/components/ui-menu/ui-menu.html +60 -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 +62 -0
- package/components/ui-number-input/ui-number-input.js +44 -0
- package/components/ui-popover/ui-popover.html +64 -1
- package/components/ui-popover/ui-popover.js +1 -1
- package/components/ui-slider/ui-slider.html +84 -0
- package/components/ui-slider/ui-slider.js +29 -0
- package/components/ui-tabs/ui-tabs.html +6 -1
- package/components/ui-text/ui-text.html +31 -6
- package/components/ui-tooltip/ui-tooltip.html +60 -0
- package/components/ui-tooltip/ui-tooltip.js +1 -1
- package/dist/index.js +46 -43
- package/docs/README.md +34 -4
- package/docs/card.html +16 -2
- package/docs/card.js +17 -2
- package/docs/catalog.json +1154 -144
- package/docs/customizer-render.mjs +35 -9
- package/docs/customizer.js +83 -20
- package/docs/documentation.css +9 -1
- package/docs/index.d.ts +4 -2
- package/docs/index.mjs +1 -1
- package/docs/preview.js +2 -0
- package/docs/render.mjs +15 -6
- package/docs/site.mjs +50 -9
- package/docs/theme-session.mjs +51 -0
- package/docs/theme.mjs +134 -15
- package/docs/value-editors.mjs +33 -0
- package/package.json +2 -2
- package/styles/ui-button.css +45 -45
- package/styles/ui-card.css +47 -0
- package/styles/ui-curve-editor.css +109 -0
- package/styles/ui-dialog.css +132 -0
- package/styles/ui-icon-button.css +63 -20
- package/styles/ui-menu.css +72 -0
- package/styles/ui-number-input.css +34 -0
- package/styles/ui-popover.css +74 -0
- package/styles/ui-slider.css +53 -0
- package/styles/ui-tabs.css +6 -1
- package/styles/ui-text.css +23 -1
- package/styles/ui-tooltip.css +72 -0
- package/theme-system.css +26 -10
- package/tokens.css +8 -2
- package/vanilla/UiButton.d.ts +1 -0
- package/vanilla/UiButton.js +195 -130
- package/vanilla/UiCard.d.ts +1 -1
- package/vanilla/UiCard.js +1 -1
- 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/UiNumberInput.d.ts +33 -0
- package/vanilla/UiNumberInput.js +79 -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 +3 -0
- package/vue/UiButton.d.ts +6 -0
- package/vue/UiButton.vue +155 -56
- package/vue/UiCard.d.ts +2 -2
- package/vue/UiCard.vue +55 -5
- package/vue/UiCurveEditor.d.ts +53 -0
- package/vue/UiCurveEditor.js +2 -0
- package/vue/UiCurveEditor.vue +427 -0
- package/vue/UiDialog.vue +177 -1
- package/vue/UiIconButton.d.ts +8 -2
- package/vue/UiIconButton.vue +164 -34
- package/vue/UiMenu.vue +89 -0
- package/vue/UiNumberInput.d.ts +83 -0
- package/vue/UiNumberInput.js +2 -0
- package/vue/UiNumberInput.vue +370 -0
- package/vue/UiPopover.vue +92 -1
- package/vue/UiSlider.d.ts +83 -0
- package/vue/UiSlider.js +2 -0
- package/vue/UiSlider.vue +412 -0
- package/vue/UiTabs.vue +6 -1
- package/vue/UiText.d.ts +7 -5
- package/vue/UiText.vue +104 -22
- 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 +2 -2
- 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 +6 -5
- package/vue/chunks/UiCheckbox.js +2 -2
- 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 +2 -2
- package/vue/chunks/UiInputGroup.js +2 -2
- 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 +390 -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 +2 -2
- 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 +2 -2
- 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 +2 -2
- 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 +2215 -1203
- package/vue/editor/index.js +623 -211
- package/vue/index.d.ts +3 -0
- package/vue/index.js +5 -2
|
@@ -1,33 +1,54 @@
|
|
|
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, paletteFor } 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-glass'))) ?? 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 supplements = {
|
|
29
|
+
'Colour': `<ui-form-field><label slot="label" for="theme-palette">Coordinated palette</label><span slot="help">Choose related action, paper and ink colors together. Size, material and motion stay yours. Both light and dark ranges are generated.</span><ui-select id="theme-palette" size="sm" data-palette value="${paletteFor(recipe)}"><option value="custom" disabled>Your palette</option>${Object.entries(themePalettes).map(([id, palette]) => `<option value="${id}">${escape(palette.label)}</option>`).join('')}</ui-select></ui-form-field><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><ui-disclosure summary="Palette seeds"><ui-stack>${themeFields.filter(item => item.family === 'Colour').map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
30
|
+
'Material': `<ui-disclosure summary="Material details"><ui-stack><p>Glassiness and surface separation coordinate the material. These controls preserve older recipes and offer finer adjustments.</p>${themeFields.filter(item => ['translucency', 'edge-light'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
31
|
+
'Size & spacing': `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
|
|
32
|
+
};
|
|
33
|
+
const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve', 'translucency', 'edge-light'].includes(item.id) && family !== 'Colour').map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
|
|
34
|
+
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-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('');
|
|
35
|
+
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'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
|
|
16
36
|
return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
|
|
17
37
|
<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
38
|
</defs>
|
|
19
39
|
<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.
|
|
40
|
+
<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>
|
|
21
41
|
<div class="customizer-layout">
|
|
22
42
|
<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
43
|
${families}
|
|
24
44
|
<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
45
|
<input type="file" accept="application/json,.json" hidden data-recipe-file>
|
|
26
46
|
<ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
|
|
27
|
-
<
|
|
47
|
+
<ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
|
|
48
|
+
<p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
|
|
28
49
|
<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
50
|
</ui-stack></aside>
|
|
30
|
-
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
|
|
51
|
+
<section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
|
|
31
52
|
<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
53
|
<noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
|
|
33
54
|
</ui-stack></section></div></ui-stack></div>
|
|
@@ -35,6 +56,11 @@ export function customizerResource(site, input = defaultRecipe()) {
|
|
|
35
56
|
.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
57
|
aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
|
|
37
58
|
.examples { min-inline-size: 0; }
|
|
59
|
+
.palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
|
|
60
|
+
.palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
|
|
61
|
+
.palette-canvas { background: var(--ui-surface); }
|
|
62
|
+
.palette-content { background: var(--ui-surface-raised); }
|
|
63
|
+
.palette-action { background: var(--ui-accent); }
|
|
38
64
|
@media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
|
|
39
65
|
</style></template>`;
|
|
40
66
|
}
|
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, paletteFor, 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,8 +11,9 @@ 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;
|
|
@@ -17,30 +21,72 @@ export function connect(host) {
|
|
|
17
21
|
let alive = true;
|
|
18
22
|
const abort = new AbortController();
|
|
19
23
|
const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
|
|
20
|
-
const
|
|
24
|
+
const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
|
|
25
|
+
const fields = new Map(themeFields.map(field => [field.id, field]));
|
|
26
|
+
const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
|
|
27
|
+
const updateControl = (element, value) => {
|
|
28
|
+
const field = fields.get(element.dataset.token);
|
|
29
|
+
if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
|
|
30
|
+
const editor = valueEditor(field, value, unitContext());
|
|
31
|
+
if (element.dataset.editorKind === 'curve') {
|
|
32
|
+
element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
|
|
33
|
+
if (editor.kind === 'curve') componentRegistry.update(element, { value });
|
|
34
|
+
} else componentRegistry.update(element, Object.fromEntries(['value', 'unit', 'min', 'max', 'step'].map(key => [key, editor[key]])));
|
|
35
|
+
};
|
|
36
|
+
const updateUnits = () => {
|
|
37
|
+
for (const control of root.querySelectorAll('[data-unit-for]')) {
|
|
38
|
+
const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
|
|
39
|
+
if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
|
|
40
|
+
componentRegistry.update(control, { value: editor.unit });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
21
43
|
const paint = () => {
|
|
22
44
|
const css = themeCSS(recipe);
|
|
23
|
-
|
|
45
|
+
updateControl(root.querySelector('[data-palette]'), paletteFor(recipe));
|
|
46
|
+
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
47
|
+
const report = root.querySelector('[data-palette-adjustments]');
|
|
48
|
+
report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
49
|
+
root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
|
|
50
|
+
for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css: theme?.css ?? css } }));
|
|
24
51
|
updateControl(root.querySelector('[data-css]'), css);
|
|
25
52
|
};
|
|
26
|
-
const show = next => {
|
|
53
|
+
const show = (next, persist = true) => {
|
|
27
54
|
recipe = validateRecipe(next);
|
|
55
|
+
if (persist) theme?.apply(recipe);
|
|
28
56
|
for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
|
|
29
57
|
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
58
|
+
updateUnits();
|
|
30
59
|
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
31
60
|
paint();
|
|
32
61
|
};
|
|
62
|
+
const filterExamples = () => {
|
|
63
|
+
const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
64
|
+
const select = root.querySelector('[data-add-example]');
|
|
65
|
+
const options = [...select.options];
|
|
66
|
+
for (const option of options) option.hidden = !words.every(word => option.dataset.search.includes(word));
|
|
67
|
+
for (const group of select.querySelectorAll('optgroup')) group.hidden = [...group.children].every(option => option.hidden);
|
|
68
|
+
if (select.selectedOptions[0]?.hidden) select.value = options.find(option => !option.hidden)?.value ?? '';
|
|
69
|
+
for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
|
|
70
|
+
const matches = options.filter(option => !option.hidden).length;
|
|
71
|
+
root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
|
|
72
|
+
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: matches === 0 });
|
|
73
|
+
};
|
|
33
74
|
const input = event => {
|
|
75
|
+
if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
|
|
34
76
|
const control = event.target.closest?.('[data-token]');
|
|
35
77
|
if (!control) return;
|
|
36
|
-
const
|
|
78
|
+
const field = fields.get(control.dataset.token);
|
|
79
|
+
const raw = event.detail?.value ?? control.value;
|
|
80
|
+
const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
|
|
37
81
|
const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
|
|
38
82
|
try {
|
|
39
|
-
recipe = validateRecipe(next);
|
|
83
|
+
recipe = validateRecipe(next);
|
|
84
|
+
theme?.apply(recipe); changed = true;
|
|
40
85
|
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
41
86
|
control.removeAttribute('aria-invalid');
|
|
42
|
-
for (const sibling of root.querySelectorAll('[data-token]')) if (sibling
|
|
43
|
-
|
|
87
|
+
for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
|
|
88
|
+
updateUnits();
|
|
89
|
+
paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
|
|
44
90
|
} catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
|
|
45
91
|
};
|
|
46
92
|
const download = (data, name, type) => {
|
|
@@ -59,7 +105,7 @@ export function connect(host) {
|
|
|
59
105
|
const selected = JSON.parse(option.dataset.example);
|
|
60
106
|
const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
|
|
61
107
|
if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
|
|
62
|
-
view.requestAnimationFrame(paint); break;
|
|
108
|
+
view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
|
|
63
109
|
}
|
|
64
110
|
case 'css': {
|
|
65
111
|
const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
|
|
@@ -68,7 +114,7 @@ export function connect(host) {
|
|
|
68
114
|
}
|
|
69
115
|
case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
|
|
70
116
|
case 'import': file.click(); break;
|
|
71
|
-
case 'reset': show(structuredClone(saved)); say('
|
|
117
|
+
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
118
|
case 'save': {
|
|
73
119
|
if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
|
|
74
120
|
const submitted = structuredClone(recipe);
|
|
@@ -77,7 +123,7 @@ export function connect(host) {
|
|
|
77
123
|
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
124
|
const result = await response.json();
|
|
79
125
|
if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
|
|
80
|
-
revision = result.revision; saved = submitted;
|
|
126
|
+
revision = result.revision; saved = submitted; theme?.baseline(saved);
|
|
81
127
|
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
82
128
|
if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
|
|
83
129
|
say('Saved to your repository. Reloading your design system…');
|
|
@@ -88,31 +134,48 @@ export function connect(host) {
|
|
|
88
134
|
finally { if (button.dataset.action === 'save') saving = false; }
|
|
89
135
|
};
|
|
90
136
|
const change = async event => {
|
|
91
|
-
if (event.target.matches('[data-
|
|
137
|
+
if (event.target.matches('[data-unit-for]')) {
|
|
138
|
+
const field = fields.get(event.target.dataset.unitFor);
|
|
139
|
+
try {
|
|
140
|
+
let current = recipe.values[field.id];
|
|
141
|
+
try { current = convertValueUnit(current, event.target.value, unitContext()); }
|
|
142
|
+
catch { current = convertValueUnit(view.getComputedStyle(document.documentElement).getPropertyValue(field.property).trim(), event.target.value, unitContext()); }
|
|
143
|
+
show({ ...recipe, values: { ...recipe.values, [field.id]: current } });
|
|
144
|
+
sayApplied(`${field.label} units updated and remembered in this browser.`);
|
|
145
|
+
} catch (error) { updateUnits(); say(error.message, true); }
|
|
146
|
+
}
|
|
147
|
+
if (event.target.matches('[data-palette]')) { show({ ...recipe, values: { ...recipe.values, ...themePalettes[event.target.value].values } }); sayApplied('Coordinated palette applied. Both color modes are remembered with your existing size, material and motion.'); }
|
|
148
|
+
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
149
|
if (event.target === file && file.files[0]) {
|
|
93
|
-
try { show(JSON.parse(await file.files[0].text()));
|
|
150
|
+
try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
|
|
94
151
|
catch (error) { say(error.message, true); }
|
|
95
152
|
file.value = '';
|
|
96
153
|
}
|
|
97
154
|
};
|
|
155
|
+
const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
|
|
156
|
+
const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
|
|
98
157
|
const ready = event => {
|
|
99
158
|
if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
|
|
100
159
|
};
|
|
101
160
|
const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
|
|
102
161
|
root.addEventListener('looma-example-remove', remove);
|
|
103
162
|
root.addEventListener('input', input);
|
|
163
|
+
root.addEventListener('blur', blur, true);
|
|
104
164
|
root.addEventListener('change', change);
|
|
105
165
|
root.addEventListener('click', click);
|
|
106
166
|
view.addEventListener('message', ready);
|
|
107
|
-
|
|
167
|
+
view.addEventListener(themeEvent, synchronize);
|
|
168
|
+
show(recipe, false); filterExamples();
|
|
169
|
+
if (theme?.storageAvailable === false) sayApplied('');
|
|
108
170
|
if (root.dataset.mode === 'local') {
|
|
109
171
|
fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
|
|
110
172
|
if (!response.ok) throw new Error('Local theme saving is unavailable.');
|
|
111
|
-
const
|
|
112
|
-
capability =
|
|
113
|
-
|
|
173
|
+
const local = await response.json(); if (!alive) return;
|
|
174
|
+
capability = local.capability;
|
|
175
|
+
saved = validateRecipe(local.recipe); revision = local.revision; theme?.baseline(saved);
|
|
176
|
+
if (!changed && !theme?.hasOverride) show(saved, false);
|
|
114
177
|
root.querySelector('[data-action="save"]').hidden = false;
|
|
115
178
|
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
116
179
|
}
|
|
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); };
|
|
180
|
+
return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
|
|
118
181
|
}
|
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
|
@@ -18,7 +18,7 @@ export interface DocumentationCatalog {
|
|
|
18
18
|
}
|
|
19
19
|
export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
|
|
20
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 }
|
|
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 }[];
|
|
@@ -51,4 +51,6 @@ export const themeFields: readonly ThemeField[];
|
|
|
51
51
|
export const startingPoints: Record<string, ThemeRecipe>;
|
|
52
52
|
export function defaultRecipe(): ThemeRecipe;
|
|
53
53
|
export function validateRecipe(input: unknown): ThemeRecipe;
|
|
54
|
-
export
|
|
54
|
+
export interface ResolvedTheme { tokens: Record<string, string>; adjustments: { id: string; requested: string; resolved: string; reason: string }[] }
|
|
55
|
+
export function deriveTheme(input: ThemeRecipe, options?: { mode?: 'light' | 'dark' }): ResolvedTheme;
|
|
56
|
+
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 } 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,5 +1,8 @@
|
|
|
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>`;
|
|
@@ -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
|
}
|
package/docs/site.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
|
|
7
7
|
import { cardMarkup, componentPath, exampleKey, previewPath, referencePage } from './render.mjs';
|
|
8
|
+
import { defaultRecipe, themeCSS } from './theme.mjs';
|
|
8
9
|
import { customizerResource } from './customizer-render.mjs';
|
|
9
10
|
import { catalogResource } from './catalog-view.mjs';
|
|
10
11
|
import { cacheDirectory, rejectSymlink, withBuildLock } from './workspace.mjs';
|
|
@@ -85,14 +86,19 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
85
86
|
await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
|
|
86
87
|
await cp(own('preview.js'), path.join(generated, 'preview.js'));
|
|
87
88
|
for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
|
|
88
|
-
for (const file of ['customizer.js', 'theme.mjs']) await cp(own(file), path.join(generated, file));
|
|
89
|
+
for (const file of ['customizer.js', 'theme.mjs', 'theme-session.mjs', 'value-editors.mjs']) await cp(own(file), path.join(generated, file));
|
|
89
90
|
const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
|
|
90
91
|
const examples = [];
|
|
92
|
+
// HTMLKit route groups retain their labels/order in navigation without changing public URLs.
|
|
93
|
+
const groupDirectories = new Map(combined.groups.map((group, index) => [group.id,
|
|
94
|
+
`${String(index + 1).padStart(2, '0')}.(${group.label.replace(/[\\/()]/g, '-')})`]));
|
|
95
|
+
const generatedReferences = new Set();
|
|
91
96
|
const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
|
|
92
97
|
for (const component of combined.components) {
|
|
93
98
|
if (!component.navigationParent) {
|
|
94
|
-
const page = path.join(content, 'components', component.tag + '.md');
|
|
99
|
+
const page = path.join(content, 'components', groupDirectories.get(component.group), component.tag + '.md');
|
|
95
100
|
await write(page, referencePage(component, combined, relativeHref(page, card), base));
|
|
101
|
+
generatedReferences.add(`components/${component.tag}.md`);
|
|
96
102
|
}
|
|
97
103
|
for (const example of component.examples) {
|
|
98
104
|
const key = exampleKey(example.id);
|
|
@@ -107,7 +113,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
107
113
|
const setup = path.join(previews, key + '.js');
|
|
108
114
|
const behavior = example.behavior;
|
|
109
115
|
await write(setup, `import { connect as connectPreview } from '../preview.js';\n${behavior ? `import setupExample from ${JSON.stringify(behavior)};\n` : ''}export function connect(host) { const stopPreview = connectPreview(host); ${behavior ? 'const stopExample = setupExample(host.element);' : ''} return () => { ${behavior ? 'stopExample?.();' : ''} stopPreview(); }; }\n`);
|
|
110
|
-
await write(file, ` ${links}\n<template component="looma-preview-${key}" module="./${key}.js"><defs><meta name="hk:navigation" content="hidden"><title>${escapeHtml(example.title)}</title></defs><div data-preview="${escapeHtml(example.id)}">${example.markup}${anchors}</div></template>\n`);
|
|
116
|
+
await write(file, ` ${links}\n<template component="looma-preview-${key}" module="./${key}.js"><defs><meta name="hk:navigation" content="hidden"><title>${escapeHtml(example.title)}</title></defs><div data-preview="${escapeHtml(example.id)}"${component.group === 'layout' ? ' data-preview-layout' : ''}>${example.markup}${anchors}</div></template>\n`);
|
|
111
117
|
examples.push({ ...example, file, component: component.id, path: base + previewPath(example).slice(1) });
|
|
112
118
|
}
|
|
113
119
|
}
|
|
@@ -122,6 +128,9 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
122
128
|
const authored = path.resolve(root, config.content);
|
|
123
129
|
for (const source of await authoredFiles(authored)) {
|
|
124
130
|
const target = path.join(content, path.relative(authored, source));
|
|
131
|
+
const publicPath = path.relative(authored, source).split(path.sep)
|
|
132
|
+
.filter(segment => !/^\d+\.\([^)]*\)$/.test(segment)).map(segment => segment.replace(/^\d+\./, '')).join('/');
|
|
133
|
+
if (generatedReferences.has(publicPath)) throw new Error(`Authored/generated documentation collision: ${source} would replace a generated component reference. Add guidance under another path.`);
|
|
125
134
|
let exists = false;
|
|
126
135
|
try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
127
136
|
// A project's landing page and footer are branding/content, never API overrides.
|
|
@@ -144,6 +153,16 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
144
153
|
return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
/** Bundle validated theme restoration into HTMLKit's before-paint head hook. */
|
|
157
|
+
async function themeBootstrap(site) {
|
|
158
|
+
const { build } = await tool('vite', site.root);
|
|
159
|
+
const entry = path.join(site.generated, 'theme-bootstrap.mjs');
|
|
160
|
+
const baseline = site.config.recipe ?? { schemaVersion: 1, values: {} };
|
|
161
|
+
await writeFile(entry, `import { installDocumentationTheme } from './theme-session.mjs';\nimport systemCSS from '@threadlabs/looma/theme-system.css?inline';\ninstallDocumentationTheme(window, { base: ${JSON.stringify(site.base)}, baseline: ${JSON.stringify(baseline)}, systemCSS });\n`);
|
|
162
|
+
const bundled = await build({ root: site.root, configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry, name: 'LoomaDocumentationTheme', formats: ['iife'] } } });
|
|
163
|
+
return (Array.isArray(bundled) ? bundled[0] : bundled).output.find(file => file.type === 'chunk').code;
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
/** One plugin powers both the public consumer and local repositories. */
|
|
148
167
|
export function documentationPlugin(site) {
|
|
149
168
|
return {
|
|
@@ -158,13 +177,35 @@ export async function documentationOptions(site) {
|
|
|
158
177
|
const { siteOptions } = await tool('markupress', site.root);
|
|
159
178
|
const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
|
|
160
179
|
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 });
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
const savedTheme = path.join(site.generated, 'saved-theme.css');
|
|
181
|
+
await writeFile(savedTheme, site.config.themeCSSContents ?? themeCSS(site.config.recipe ?? defaultRecipe()));
|
|
182
|
+
const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
|
|
183
|
+
return { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) },
|
|
184
|
+
// Markupress supplies its head script in its own config hook; compose after it.
|
|
185
|
+
{ name: 'looma-browser-theme', config: async current => ({ headScript: [current.headScript, await themeBootstrap(site)].filter(Boolean).join('\n;\n') }) }] };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Opaque example frames cannot fetch local fonts without host-specific CORS headers.
|
|
189
|
+
* Bundle those font files into theme CSS for both the local server and static output. */
|
|
190
|
+
async function portableThemeStyles(site) {
|
|
191
|
+
const overrides = site.config.overrides ?? [];
|
|
192
|
+
if (!overrides.length) return [];
|
|
193
|
+
const { build } = await tool('vite', site.root);
|
|
194
|
+
const entry = path.join(site.generated, 'theme-entry.css');
|
|
195
|
+
await writeFile(entry, overrides.map(file => `@import ${JSON.stringify(path.resolve(site.root, file))};`).join('\n'));
|
|
196
|
+
const bundled = await build({ root: site.root, base: './', configFile: false, logLevel: 'silent',
|
|
197
|
+
build: { write: false, minify: false, cssMinify: false,
|
|
198
|
+
assetsInlineLimit: file => /\.(woff2?|ttf|otf)$/i.test(file),
|
|
199
|
+
rollupOptions: { input: entry, output: { assetFileNames: 'theme-assets/[name]-[hash][extname]' } } } });
|
|
200
|
+
const styles = [];
|
|
201
|
+
for (const output of (Array.isArray(bundled) ? bundled[0] : bundled).output) {
|
|
202
|
+
if (output.type !== 'asset') continue;
|
|
203
|
+
const file = path.join(site.generated, output.fileName);
|
|
204
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
205
|
+
await writeFile(file, output.source);
|
|
206
|
+
if (file.endsWith('.css')) styles.push(file);
|
|
165
207
|
}
|
|
166
|
-
|
|
167
|
-
return themeStyles.length ? { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) }] } : options;
|
|
208
|
+
return styles;
|
|
168
209
|
}
|
|
169
210
|
|
|
170
211
|
/** Build through HTMLKit, then make opaque preview scripts portable to static hosts without CORS. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { themeCSS, validateRecipe } from './theme.mjs';
|
|
2
|
+
|
|
3
|
+
const identity = Symbol.for('looma.documentation.theme');
|
|
4
|
+
export const themeEvent = 'looma-documentation-theme';
|
|
5
|
+
|
|
6
|
+
/** Browser choices belong to one documentation base; repository writes stay explicit. */
|
|
7
|
+
export function createThemeSession(view, { base, baseline, systemCSS }) {
|
|
8
|
+
const key = `looma:documentation-theme:v1:${base}`;
|
|
9
|
+
let original = validateRecipe(baseline);
|
|
10
|
+
let selected;
|
|
11
|
+
let storageAvailable = true;
|
|
12
|
+
const style = view.document.createElement('style');
|
|
13
|
+
style.dataset.loomaDocumentationTheme = '';
|
|
14
|
+
view.document.head.append(style);
|
|
15
|
+
const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
|
|
16
|
+
try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
|
|
17
|
+
const paint = () => {
|
|
18
|
+
view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
|
|
19
|
+
style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
|
|
20
|
+
view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: selected ? themeCSS(selected, systemCSS) : '' } }));
|
|
21
|
+
};
|
|
22
|
+
const persist = () => {
|
|
23
|
+
try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
|
|
24
|
+
paint();
|
|
25
|
+
};
|
|
26
|
+
const session = {
|
|
27
|
+
get recipe() { return structuredClone(selected ?? original); },
|
|
28
|
+
get hasOverride() { return selected !== undefined; },
|
|
29
|
+
get storageAvailable() { return storageAvailable; },
|
|
30
|
+
get css() { return selected ? themeCSS(selected, systemCSS) : ''; },
|
|
31
|
+
apply(recipe) { selected = validateRecipe(recipe); persist(); },
|
|
32
|
+
reset() { selected = undefined; persist(); return structuredClone(original); },
|
|
33
|
+
baseline(recipe) { original = validateRecipe(recipe); },
|
|
34
|
+
};
|
|
35
|
+
view.addEventListener('storage', event => {
|
|
36
|
+
if (event.key !== key && event.key !== null) return;
|
|
37
|
+
const next = read(event.newValue);
|
|
38
|
+
if (event.newValue !== null && !next) return;
|
|
39
|
+
selected = next; paint();
|
|
40
|
+
});
|
|
41
|
+
paint();
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The early head script and component modules share one session across client navigation. */
|
|
46
|
+
export function documentationTheme(view) { return view[identity]; }
|
|
47
|
+
|
|
48
|
+
export function installDocumentationTheme(view, config) {
|
|
49
|
+
if (view.parent !== view) return;
|
|
50
|
+
return view[identity] ??= createThemeSession(view, config);
|
|
51
|
+
}
|