@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
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,20 @@ 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
|
|
99
|
+
const section = component.group === 'editor' ? ['editor'] : ['components', groupDirectories.get(component.group)];
|
|
100
|
+
const page = path.join(content, ...section, component.tag + '.md');
|
|
95
101
|
await write(page, referencePage(component, combined, relativeHref(page, card), base));
|
|
102
|
+
generatedReferences.add(`${component.group === 'editor' ? 'editor' : 'components'}/${component.tag}.md`);
|
|
96
103
|
}
|
|
97
104
|
for (const example of component.examples) {
|
|
98
105
|
const key = exampleKey(example.id);
|
|
@@ -107,7 +114,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
107
114
|
const setup = path.join(previews, key + '.js');
|
|
108
115
|
const behavior = example.behavior;
|
|
109
116
|
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`);
|
|
117
|
+
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
118
|
examples.push({ ...example, file, component: component.id, path: base + previewPath(example).slice(1) });
|
|
112
119
|
}
|
|
113
120
|
}
|
|
@@ -117,15 +124,19 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
117
124
|
}).join('\n\n');
|
|
118
125
|
await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
|
|
119
126
|
await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
|
|
127
|
+
if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
|
|
120
128
|
await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
|
|
121
129
|
if (config.content) {
|
|
122
130
|
const authored = path.resolve(root, config.content);
|
|
123
131
|
for (const source of await authoredFiles(authored)) {
|
|
124
132
|
const target = path.join(content, path.relative(authored, source));
|
|
133
|
+
const publicPath = path.relative(authored, source).split(path.sep)
|
|
134
|
+
.filter(segment => !/^\d+\.\([^)]*\)$/.test(segment)).map(segment => segment.replace(/^\d+\./, '')).join('/');
|
|
135
|
+
if (generatedReferences.has(publicPath)) throw new Error(`Authored/generated documentation collision: ${source} would replace a generated component reference. Add guidance under another path.`);
|
|
125
136
|
let exists = false;
|
|
126
137
|
try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
127
138
|
// A project's landing page and footer are branding/content, never API overrides.
|
|
128
|
-
if (exists && !['index.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
|
|
139
|
+
if (exists && !['index.md', 'editor.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
|
|
129
140
|
await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
|
|
130
141
|
}
|
|
131
142
|
}
|
|
@@ -138,12 +149,21 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
|
138
149
|
for (const component of combined.components) {
|
|
139
150
|
const target = componentPath(component);
|
|
140
151
|
redirects[`/components/${component.group}/${component.tag}/`] = target;
|
|
141
|
-
if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
|
|
142
152
|
if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
|
|
143
153
|
}
|
|
144
154
|
return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
/** Bundle validated theme restoration into HTMLKit's before-paint head hook. */
|
|
158
|
+
async function themeBootstrap(site) {
|
|
159
|
+
const { build } = await tool('vite', site.root);
|
|
160
|
+
const entry = path.join(site.generated, 'theme-bootstrap.mjs');
|
|
161
|
+
const baseline = site.config.recipe ?? { schemaVersion: 1, values: {} };
|
|
162
|
+
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`);
|
|
163
|
+
const bundled = await build({ root: site.root, configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry, name: 'LoomaDocumentationTheme', formats: ['iife'] } } });
|
|
164
|
+
return (Array.isArray(bundled) ? bundled[0] : bundled).output.find(file => file.type === 'chunk').code;
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
/** One plugin powers both the public consumer and local repositories. */
|
|
148
168
|
export function documentationPlugin(site) {
|
|
149
169
|
return {
|
|
@@ -158,13 +178,35 @@ export async function documentationOptions(site) {
|
|
|
158
178
|
const { siteOptions } = await tool('markupress', site.root);
|
|
159
179
|
const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
|
|
160
180
|
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
|
-
|
|
181
|
+
const savedTheme = path.join(site.generated, 'saved-theme.css');
|
|
182
|
+
await writeFile(savedTheme, site.config.themeCSSContents ?? themeCSS(site.config.recipe ?? defaultRecipe()));
|
|
183
|
+
const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
|
|
184
|
+
return { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) },
|
|
185
|
+
// Markupress supplies its head script in its own config hook; compose after it.
|
|
186
|
+
{ name: 'looma-browser-theme', config: async current => ({ headScript: [current.headScript, await themeBootstrap(site)].filter(Boolean).join('\n;\n') }) }] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Opaque example frames cannot fetch local fonts without host-specific CORS headers.
|
|
190
|
+
* Bundle those font files into theme CSS for both the local server and static output. */
|
|
191
|
+
async function portableThemeStyles(site) {
|
|
192
|
+
const overrides = site.config.overrides ?? [];
|
|
193
|
+
if (!overrides.length) return [];
|
|
194
|
+
const { build } = await tool('vite', site.root);
|
|
195
|
+
const entry = path.join(site.generated, 'theme-entry.css');
|
|
196
|
+
await writeFile(entry, overrides.map(file => `@import ${JSON.stringify(path.resolve(site.root, file))};`).join('\n'));
|
|
197
|
+
const bundled = await build({ root: site.root, base: './', configFile: false, logLevel: 'silent',
|
|
198
|
+
build: { write: false, minify: false, cssMinify: false,
|
|
199
|
+
assetsInlineLimit: file => /\.(woff2?|ttf|otf)$/i.test(file),
|
|
200
|
+
rollupOptions: { input: entry, output: { assetFileNames: 'theme-assets/[name]-[hash][extname]' } } } });
|
|
201
|
+
const styles = [];
|
|
202
|
+
for (const output of (Array.isArray(bundled) ? bundled[0] : bundled).output) {
|
|
203
|
+
if (output.type !== 'asset') continue;
|
|
204
|
+
const file = path.join(site.generated, output.fileName);
|
|
205
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
206
|
+
await writeFile(file, output.source);
|
|
207
|
+
if (file.endsWith('.css')) styles.push(file);
|
|
165
208
|
}
|
|
166
|
-
|
|
167
|
-
return themeStyles.length ? { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) }] } : options;
|
|
209
|
+
return styles;
|
|
168
210
|
}
|
|
169
211
|
|
|
170
212
|
/** Build through HTMLKit, then make opaque preview scripts portable to static hosts without CORS. */
|
|
@@ -179,20 +221,29 @@ export async function buildDocumentation({ root = process.cwd(), config = {}, ca
|
|
|
179
221
|
|
|
180
222
|
async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
|
|
181
223
|
const { build } = await tool('vite', site.root);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
224
|
+
// Preview entries and outputs are independent. Keep compiler memory bounded
|
|
225
|
+
// and retain the build lock until every in-flight bundle has settled.
|
|
226
|
+
const remaining = [...site.examples];
|
|
227
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(2, remaining.length) }, async () => {
|
|
228
|
+
try {
|
|
229
|
+
for (let example; (example = remaining.pop());) {
|
|
230
|
+
const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
|
|
231
|
+
let html = await readFile(htmlFile, 'utf8');
|
|
232
|
+
const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
|
|
233
|
+
if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
|
|
234
|
+
for (const [tag, url] of scripts) {
|
|
235
|
+
const input = path.join(outDir, url.slice(site.base.length));
|
|
236
|
+
const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
|
|
237
|
+
const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
|
|
238
|
+
const name = `${exampleKey(example.id)}.js`;
|
|
239
|
+
await writeOutput('_looma/runtime/' + name, code);
|
|
240
|
+
html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
|
|
241
|
+
}
|
|
242
|
+
html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
|
|
243
|
+
await writeFile(htmlFile, html);
|
|
244
|
+
}
|
|
245
|
+
} catch (error) { remaining.length = 0; throw error; }
|
|
246
|
+
}));
|
|
247
|
+
const failed = results.find(result => result.status === 'rejected');
|
|
248
|
+
if (failed) throw failed.reason;
|
|
198
249
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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 portableCSS = '';
|
|
12
|
+
let storageAvailable = true;
|
|
13
|
+
const style = view.document.createElement('style');
|
|
14
|
+
style.dataset.loomaDocumentationTheme = '';
|
|
15
|
+
view.document.head.append(style);
|
|
16
|
+
const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
|
|
17
|
+
try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
|
|
18
|
+
const paint = () => {
|
|
19
|
+
portableCSS = selected ? themeCSS(selected, systemCSS) : '';
|
|
20
|
+
view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
|
|
21
|
+
style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
|
|
22
|
+
view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: portableCSS } }));
|
|
23
|
+
};
|
|
24
|
+
const persist = () => {
|
|
25
|
+
try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
|
|
26
|
+
paint();
|
|
27
|
+
};
|
|
28
|
+
const session = {
|
|
29
|
+
get recipe() { return structuredClone(selected ?? original); },
|
|
30
|
+
get hasOverride() { return selected !== undefined; },
|
|
31
|
+
get storageAvailable() { return storageAvailable; },
|
|
32
|
+
get css() { return portableCSS; },
|
|
33
|
+
apply(recipe) { selected = validateRecipe(recipe); persist(); },
|
|
34
|
+
reset() { selected = undefined; persist(); return structuredClone(original); },
|
|
35
|
+
baseline(recipe) { original = validateRecipe(recipe); },
|
|
36
|
+
};
|
|
37
|
+
view.addEventListener('storage', event => {
|
|
38
|
+
if (event.key !== key && event.key !== null) return;
|
|
39
|
+
const next = read(event.newValue);
|
|
40
|
+
if (event.newValue !== null && !next) return;
|
|
41
|
+
selected = next; paint();
|
|
42
|
+
});
|
|
43
|
+
paint();
|
|
44
|
+
return session;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The early head script and component modules share one session across client navigation. */
|
|
48
|
+
export function documentationTheme(view) { return view[identity]; }
|
|
49
|
+
|
|
50
|
+
export function installDocumentationTheme(view, config) {
|
|
51
|
+
if (view.parent !== view) return;
|
|
52
|
+
return view[identity] ??= createThemeSession(view, config);
|
|
53
|
+
}
|
package/docs/theme.mjs
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
const bounds = {
|
|
2
|
+
'text-size': [14, 24], 'control-size': [32, 64], 'corner-roundness': [0, 32],
|
|
3
|
+
'line-weight': [.5, 3], density: [.75, 1.5], 'scale-contrast': [1, 1.5],
|
|
4
|
+
'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
|
|
5
|
+
'response-time': [0, 600],
|
|
6
|
+
};
|
|
7
|
+
|
|
1
8
|
/** The shared authoring schema; ids remain stable in saved recipes. */
|
|
2
9
|
export const themeFields = [
|
|
3
10
|
['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary commands and selected controls.'],
|
|
@@ -16,22 +23,71 @@ export const themeFields = [
|
|
|
16
23
|
['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
|
|
17
24
|
['Material', 'outer-depth', 'Outer depth', '--ui-outer-depth', 'nonnegative', '0.6', 'How strongly controls and floating surfaces rise above the page.'],
|
|
18
25
|
['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
|
|
26
|
+
['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Frosted blur, richer background colour and light-catching glass edges. Content stays readable.'],
|
|
27
|
+
['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
|
|
19
28
|
['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
|
|
20
29
|
['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
|
|
30
|
+
['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
|
|
31
|
+
['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Soft reflected light beyond a surface, derived from its tone.'],
|
|
32
|
+
['Material', 'light-x', 'Light horizontal position', '--ui-light-x', 'fraction', '0.5', 'The light source from left to right, relative to each surface.'],
|
|
33
|
+
['Material', 'light-y', 'Light vertical position', '--ui-light-y', 'fraction', '0', 'The light source from top to bottom, relative to each surface.'],
|
|
34
|
+
['Material', 'light-spread', 'Light spread', '--ui-light-spread', 'fraction', '0.5', 'A concentrated spot through broad illumination.'],
|
|
35
|
+
['Material', 'light-strength', 'Light strength', '--ui-light-strength', 'fraction', '0.65', 'Shared illumination; each material and component role determines its response.'],
|
|
21
36
|
['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
|
|
22
37
|
['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
|
|
23
|
-
].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description }));
|
|
38
|
+
].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
|
|
24
39
|
|
|
25
40
|
export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
|
|
26
41
|
const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
|
|
27
42
|
export const startingPoints = {
|
|
28
43
|
clear: defaultRecipe(),
|
|
29
|
-
|
|
44
|
+
bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
|
|
45
|
+
wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
|
|
30
46
|
editorial: starting({ action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
|
|
31
47
|
night: starting({ action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
|
|
32
48
|
garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
|
|
33
49
|
};
|
|
34
|
-
export const startingPointLabels = { clear: 'Clear',
|
|
50
|
+
export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
|
|
51
|
+
const paletteKeys = ['action', 'canvas', 'surface', 'text'];
|
|
52
|
+
// Palette choices move only the shared color seeds; size, material and motion stay independent.
|
|
53
|
+
export const themePalettes = Object.fromEntries([
|
|
54
|
+
['clear', 'Indigo & mist'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
|
|
55
|
+
['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
|
|
56
|
+
].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
|
|
57
|
+
|
|
58
|
+
const materialKeys = themeFields.filter(field => field.family === 'Material').map(field => field.id);
|
|
59
|
+
const materialDefaults = defaultRecipe().values;
|
|
60
|
+
const material = (label, overrides) => ({ label, values: Object.fromEntries(materialKeys.map(key => [key, overrides[key] ?? materialDefaults[key]])) });
|
|
61
|
+
export const materialPresets = {
|
|
62
|
+
matte: material('Matte', { 'outer-depth': '0', 'inner-depth': '0' }),
|
|
63
|
+
soft: material('Soft', { 'outer-depth': '0.6', 'inner-depth': '0.3', 'edge-light': '0.3', finish: '0.2', 'light-spread': '0.8' }),
|
|
64
|
+
polished: material('Polished', { 'outer-depth': '1', 'inner-depth': '1', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9' }),
|
|
65
|
+
glass: material('Frosted glass', startingPoints.bookish.values),
|
|
66
|
+
};
|
|
67
|
+
const refinementPresets = family => family === 'material' ? materialPresets : family === 'palette' ? themePalettes : undefined;
|
|
68
|
+
|
|
69
|
+
/** Preset provenance is optional recipe data, never a second source of theme values. */
|
|
70
|
+
export function refinementState(input, family) {
|
|
71
|
+
const recipe = validateRecipe(input), presets = refinementPresets(family);
|
|
72
|
+
if (!presets) throw new Error('Unknown refinement family.');
|
|
73
|
+
const matches = preset => Object.entries(preset.values).every(([key, value]) => recipe.values[key] === value);
|
|
74
|
+
const id = recipe.presets?.[family] ?? Object.keys(presets).find(id => matches(presets[id])) ?? 'custom';
|
|
75
|
+
return { id, customized: id !== 'custom' && !matches(presets[id]) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply or restore one family without changing the rest of the design system. */
|
|
79
|
+
export function applyRefinement(input, family, id) {
|
|
80
|
+
const recipe = validateRecipe(input), preset = refinementPresets(family)?.[id];
|
|
81
|
+
if (!preset) throw new Error('Unknown refinement preset.');
|
|
82
|
+
return validateRecipe({ ...recipe, values: { ...recipe.values, ...preset.values }, presets: { ...recipe.presets, [family]: id } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Identify a coordinated palette without coupling it to the rest of a recipe. */
|
|
86
|
+
export function paletteFor(input) {
|
|
87
|
+
const { values } = validateRecipe(input);
|
|
88
|
+
return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
36
92
|
const length = /^(?:0|[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em|ex|ch|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|cm|mm|Q|in|pt|pc))$/;
|
|
37
93
|
const font = /^(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*)(?:\s*,\s*(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*))*$/u;
|
|
@@ -39,7 +95,21 @@ const time = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:ms|s)$/;
|
|
|
39
95
|
function easing(value) {
|
|
40
96
|
if (/^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/.test(value)) return true;
|
|
41
97
|
const match = /^cubic-bezier\(\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*\)$/.exec(value);
|
|
42
|
-
return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1));
|
|
98
|
+
return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1) && [2, 4].every(i => Number(match[i]) >= -1 && Number(match[i]) <= 2));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Authoring bounds are shared by every entry path. Relative lengths keep their units;
|
|
102
|
+
// viewport/font-metric lengths are constrained at use time rather than guessed in JS.
|
|
103
|
+
|
|
104
|
+
const pixels = { px: 1, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
|
|
105
|
+
function boundedNumber(field, value) {
|
|
106
|
+
if (field.type === 'time') return parseFloat(value) * (value.endsWith('ms') ? 1 : 1000);
|
|
107
|
+
if (field.type.includes('length')) {
|
|
108
|
+
if (value === '0') return 0;
|
|
109
|
+
const unit = value.match(/[a-z]+$/i)?.[0];
|
|
110
|
+
return Object.hasOwn(pixels, unit) ? parseFloat(value) * pixels[unit] : undefined;
|
|
111
|
+
}
|
|
112
|
+
return Number(value);
|
|
43
113
|
}
|
|
44
114
|
|
|
45
115
|
/** Validate the whole recipe before previewing or writing any output. */
|
|
@@ -67,34 +137,135 @@ export function validateRecipe(input) {
|
|
|
67
137
|
}
|
|
68
138
|
}
|
|
69
139
|
if (!valid) throw new Error(`${field.label}: enter a valid ${field.type.replaceAll('-', ' ')} value.`);
|
|
140
|
+
if (Object.hasOwn(bounds, field.id)) {
|
|
141
|
+
const number = boundedNumber(field, value), [min, max] = bounds[field.id];
|
|
142
|
+
if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
|
|
143
|
+
}
|
|
70
144
|
}
|
|
71
|
-
|
|
145
|
+
if (input.presets !== undefined && (!input.presets || typeof input.presets !== 'object' || Array.isArray(input.presets) || Object.entries(input.presets).some(([family, id]) => !Object.hasOwn(refinementPresets(family) ?? {}, id)))) throw new Error('Unknown refinement preset.');
|
|
146
|
+
return { schemaVersion: 1, values, ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
|
|
72
147
|
}
|
|
73
148
|
|
|
74
149
|
/** Match presets by their complete semantic recipe, including locally saved themes. */
|
|
75
150
|
export function startingPointFor(input) {
|
|
76
|
-
const recipe = JSON.stringify(validateRecipe(input));
|
|
77
|
-
return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value) === recipe)?.[0] ?? 'custom';
|
|
151
|
+
const recipe = JSON.stringify(validateRecipe(input).values);
|
|
152
|
+
return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value.values) === recipe)?.[0] ?? 'custom';
|
|
78
153
|
}
|
|
79
154
|
|
|
80
155
|
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
|
81
156
|
const mix = (a, b, weight) => '#' + rgb(a).map((n, i) => Math.round(n * weight + rgb(b)[i] * (1 - weight)).toString(16).padStart(2, '0')).join('');
|
|
82
157
|
const luminance = hex => rgb(hex).map(n => { const s = n / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4; }).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
|
|
83
158
|
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
|
|
159
|
+
// The brightest reflection stop is itself readable. Interpolating it over a readable
|
|
160
|
+
// fill cannot wash out the label, even at maximum finish and light strength.
|
|
161
|
+
function reflection(fill, text) {
|
|
162
|
+
const labels = Array.isArray(text) ? text : [text];
|
|
163
|
+
let amount = .32;
|
|
164
|
+
while (amount > 0 && labels.some(label => contrast(mix('#ffffff', fill, amount), label) < 4.5)) amount = Math.max(0, amount - .005);
|
|
165
|
+
return mix('#ffffff', fill, amount);
|
|
166
|
+
}
|
|
84
167
|
const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
|
|
85
168
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
169
|
+
/** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
|
|
170
|
+
function readableColour(seed, surfaces, minimum, endpoint) {
|
|
171
|
+
if (surfaces.every(surface => contrast(seed, surface) >= minimum)) return seed;
|
|
172
|
+
for (let step = 1; step <= 256; step++) {
|
|
173
|
+
const next = mix(seed, endpoint, 1 - step / 256);
|
|
174
|
+
if (surfaces.every(surface => contrast(next, surface) >= minimum)) return next;
|
|
175
|
+
}
|
|
176
|
+
return endpoint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
|
|
180
|
+
export function deriveTheme(input, { mode } = {}) {
|
|
181
|
+
if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
|
|
88
182
|
const { values } = validateRecipe(input);
|
|
89
183
|
const tokens = Object.fromEntries(themeFields.map(field => [field.property, values[field.id]]));
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
184
|
+
for (const field of themeFields.filter(field => field.type.includes('length') && Object.hasOwn(bounds, field.id))) {
|
|
185
|
+
if (boundedNumber(field, values[field.id]) === undefined) {
|
|
186
|
+
const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const adjustments = [];
|
|
190
|
+
const nativeInk = foreground(values.canvas, '#ffffff', '#000000');
|
|
191
|
+
const ink = mode === 'light' ? '#000000' : mode === 'dark' ? '#ffffff' : nativeInk;
|
|
192
|
+
const paper = ink === '#000000' ? '#ffffff' : '#000000';
|
|
193
|
+
// Preserve the requested palette in its native mode. The companion keeps a restrained
|
|
194
|
+
// tint of the same surface seeds; contrast resolution below governs all usable roles.
|
|
195
|
+
const companionSurface = mix(values.text, paper, .8);
|
|
196
|
+
const surface = seed => readableColour(nativeInk === ink ? seed : mix(seed, companionSurface, .02), [ink], 7, paper);
|
|
197
|
+
tokens['--ui-surface'] = surface(values.canvas);
|
|
198
|
+
tokens['--ui-surface-raised'] = surface(values.surface);
|
|
199
|
+
tokens['--ui-surface-sunken'] = surface(mix(tokens['--ui-surface-raised'], tokens['--ui-surface'], .92));
|
|
200
|
+
const surfaces = ['--ui-surface', '--ui-surface-raised', '--ui-surface-sunken'].map(name => tokens[name]);
|
|
201
|
+
const tones = { accent: values.action, danger: '#c92d1e', success: '#007a33', warning: '#ba5b0b', info: '#0066cc' };
|
|
202
|
+
for (const [tone, seed] of Object.entries(tones)) {
|
|
203
|
+
const fill = readableColour(seed, surfaces, 5, ink);
|
|
204
|
+
tokens[`--ui-${tone}`] = fill;
|
|
205
|
+
tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
|
|
206
|
+
tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
|
|
207
|
+
tokens[`--ui-${tone}-reflection`] = reflection(fill, tokens[`--ui-on-${tone}`]);
|
|
208
|
+
tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
|
|
209
|
+
tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
|
|
210
|
+
tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
|
|
211
|
+
}
|
|
212
|
+
// Readable roles cover the quiet tinted surfaces as well as the neutral family.
|
|
213
|
+
const readingSurfaces = [...surfaces, ...Object.keys(tones).flatMap(tone => surfaces.map(background => mix(tokens[`--ui-${tone}`], background, .12)))];
|
|
214
|
+
for (const tone of Object.keys(tones)) {
|
|
215
|
+
tokens[`--ui-${tone}-subtle-text`] = readableColour(tokens[`--ui-${tone}`], readingSurfaces, 4.5, ink);
|
|
216
|
+
}
|
|
217
|
+
const textSeed = nativeInk === ink ? values.text : mix(values.text, ink, .08);
|
|
218
|
+
tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
|
|
219
|
+
tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
|
|
220
|
+
tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
|
|
221
|
+
for (const [state, amount] of [['surface', .06], ['hover', .1], ['active', .14]]) {
|
|
222
|
+
tokens[`--ui-neutral-${state}`] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], amount), [tokens['--ui-text']], 4.5, paper);
|
|
223
|
+
}
|
|
224
|
+
tokens['--ui-surface-reflection'] = reflection(tokens['--ui-surface-raised'], [tokens['--ui-text'], tokens['--ui-text-secondary'], ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-subtle-text`])]);
|
|
225
|
+
tokens['--ui-neutral-reflection'] = reflection(tokens['--ui-neutral-surface'], tokens['--ui-text']);
|
|
226
|
+
tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
|
|
227
|
+
tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
|
|
228
|
+
tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
|
|
229
|
+
tokens['--ui-control-surface'] = tokens['--ui-surface-raised'];
|
|
230
|
+
tokens['--ui-control-border'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .43), readingSurfaces, 3, ink);
|
|
231
|
+
tokens['--ui-control-border-hover'] = tokens['--ui-control-border'];
|
|
232
|
+
tokens['--ui-border-strong'] = tokens['--ui-control-border'];
|
|
233
|
+
tokens['--ui-disabled-text'] = tokens['--ui-text-secondary'];
|
|
234
|
+
tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
|
|
235
|
+
tokens['--ui-disabled-border'] = tokens['--ui-control-border'];
|
|
236
|
+
tokens['--ui-focus-ring'] = readableColour(tokens['--ui-accent'], readingSurfaces, 3, ink);
|
|
98
237
|
tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
|
|
99
|
-
|
|
238
|
+
tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
|
|
239
|
+
for (const [id, property] of [['action', '--ui-accent'], ['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
|
|
240
|
+
if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
|
|
241
|
+
}
|
|
242
|
+
return { tokens, adjustments };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
|
|
246
|
+
export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
|
|
247
|
+
if (!['portable', 'documentation'].includes(scope)) throw new Error('Unknown theme scope.');
|
|
248
|
+
// The early documentation stylesheet wins over the baseline loaded later without !important.
|
|
249
|
+
const selector = scope === 'documentation'
|
|
250
|
+
? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
|
|
251
|
+
: ':root, [data-theme], [data-contrast]';
|
|
252
|
+
const light = deriveTheme(input, { mode: 'light' }).tokens;
|
|
253
|
+
const dark = deriveTheme(input, { mode: 'dark' }).tokens;
|
|
254
|
+
const palette = [], geometry = [];
|
|
255
|
+
for (const [name, value] of Object.entries(light)) {
|
|
256
|
+
if (name === 'color-scheme') continue;
|
|
257
|
+
if (/^#[\da-f]{6}$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
|
|
258
|
+
else geometry.push(` ${name}: ${value};`);
|
|
259
|
+
}
|
|
260
|
+
const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
|
|
261
|
+
const chevron = color => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='${color.replace('#', '%23')}' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`;
|
|
262
|
+
const boundary = mode => scope === 'documentation'
|
|
263
|
+
? `:root[data-looma-documentation-theme][data-theme="${mode}"], :root[data-looma-documentation-theme] [data-theme="${mode}"]`
|
|
264
|
+
: `[data-theme="${mode}"]`;
|
|
265
|
+
const chevrons = `${geometrySelector} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n@media (prefers-color-scheme: dark) { ${geometrySelector}:not([data-theme]) { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; } }\n${boundary('light')} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n${boundary('dark')} { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; }`;
|
|
266
|
+
|
|
267
|
+
// Resolve colors at their point of use, so a nearer mode boundary wins independently
|
|
268
|
+
// of the selector specificity needed to restore an early documentation theme.
|
|
269
|
+
// Keep the first comment stable: the local writer recognizes its generated files by this marker.
|
|
270
|
+
return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
|
|
100
271
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Translate canonical recipe values for the reusable value editors; never duplicate design bounds here. */
|
|
2
|
+
const lengthUnits = { px: 1, rem: 16, em: 16, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
|
|
3
|
+
const timeUnits = { ms: 1, s: 1000 };
|
|
4
|
+
const rounded = value => String(Number(Number(value).toFixed(6)));
|
|
5
|
+
|
|
6
|
+
export function valueEditor(field, value, { remPixels = 16 } = {}) {
|
|
7
|
+
if (field.type === 'easing') return { kind: /^step-/.test(value) ? 'text' : 'curve', value };
|
|
8
|
+
if (['colour', 'font'].includes(field.type)) return { kind: 'text', value };
|
|
9
|
+
if (field.type.includes('length') || field.type === 'time') {
|
|
10
|
+
const unit = value.match(/[a-z]+$/i)?.[0] ?? 'px';
|
|
11
|
+
const units = field.type === 'time' ? timeUnits : { ...lengthUnits, rem: remPixels, em: remPixels };
|
|
12
|
+
const factor = units[unit];
|
|
13
|
+
return { kind: 'number', value: value === '0' ? '0px' : value, unit,
|
|
14
|
+
min: factor ? field.min / factor : .001, max: factor ? field.max / factor : 1000000,
|
|
15
|
+
step: (field.type === 'time' ? 10 : field.id === 'line-weight' ? .25 : 1) / (factor ?? 1),
|
|
16
|
+
units: field.type === 'time' ? ['ms', 's'] : ['px', 'rem'], convertible: Boolean(factor) };
|
|
17
|
+
}
|
|
18
|
+
const scale = field.type === 'fraction' || field.id === 'density' ? 100 : 1;
|
|
19
|
+
return { kind: field.type === 'weight' ? 'number' : 'slider', value: field.type === 'weight' ? value : Number(value) * scale,
|
|
20
|
+
min: field.min * scale, max: field.max * scale, step: field.type === 'weight' ? 50 : scale === 100 ? 1 : .01,
|
|
21
|
+
unit: scale === 100 ? '%' : '', scale };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function editorValue(field, value) {
|
|
25
|
+
return field.type === 'fraction' || field.id === 'density' ? rounded(Number(value) / 100) : String(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function convertValueUnit(value, nextUnit, { remPixels = 16 } = {}) {
|
|
29
|
+
const unit = value.match(/[a-z]+$/i)?.[0] ?? 'px';
|
|
30
|
+
const units = Object.hasOwn(timeUnits, unit) ? timeUnits : { ...lengthUnits, rem: remPixels, em: remPixels };
|
|
31
|
+
if (!Object.hasOwn(units, unit) || !Object.hasOwn(units, nextUnit)) throw new Error('Choose compatible units.');
|
|
32
|
+
return rounded(parseFloat(value) * units[unit] / units[nextUnit]) + nextUnit;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threadlabs/looma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"@tiptap/pm": ">=2 <3",
|
|
128
128
|
"@tiptap/vue-3": ">=2 <3",
|
|
129
129
|
"vue": "^3.5.43",
|
|
130
|
-
"markupress": "^0.8.
|
|
130
|
+
"markupress": "^0.8.4",
|
|
131
131
|
"@nextwebwg/htmlkit": "1.0.0-alpha.69",
|
|
132
132
|
"vite": "^8.3.4"
|
|
133
133
|
},
|
package/styles/ui-badge.css
CHANGED
|
@@ -295,10 +295,18 @@
|
|
|
295
295
|
box-shadow: none;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
/* Neutral reverses a subtle surface/ink pair rather than a strong tone. Keep its original
|
|
299
|
-
surface ink: moving it toward dark-theme primary text would reduce overlay contrast. */
|
|
300
298
|
:scope:where([data-component~="ui-badge"])[data-ui-badge-state~="variant=inverse"][data-ui-badge-state~="tone=neutral"] {
|
|
301
|
-
|
|
299
|
+
--_badge-outline-surface: var(--ui-badge-surface, var(--_badge-color-surface, color-mix(in srgb, var(--ui-surface-raised) 88%, transparent)));
|
|
300
|
+
color: var(--ui-badge-text, var(--_badge-color-text, var(--ui-text-primary)));
|
|
301
|
+
}
|
|
302
|
+
:scope:where([data-component~="ui-badge"])[data-ui-badge-state~="variant=inverse"][data-ui-badge-state~="tone=neutral"]:not([data-ui-badge-state~="shape=circle"]) {
|
|
303
|
+
border-color: var(--ui-badge-border, var(--_badge-color-border, var(--_badge-outline-surface)));
|
|
304
|
+
}
|
|
305
|
+
:scope:where([data-component~="ui-badge"])[data-ui-badge-state~="variant=inverse"][data-ui-badge-state~="tone=neutral"][data-ui-badge-state~="shape=circle"] {
|
|
306
|
+
--_badge-outline-surface: var(--ui-badge-surface, var(--_badge-color-surface, light-dark(
|
|
307
|
+
color-mix(in srgb, var(--ui-surface-raised) var(--ui-badge-circle-opacity, 66%), transparent),
|
|
308
|
+
color-mix(in srgb, var(--ui-surface-raised) var(--ui-badge-circle-opacity, 56%), transparent)
|
|
309
|
+
)));
|
|
302
310
|
}
|
|
303
311
|
|
|
304
312
|
:scope:where([data-component~="ui-badge"])[data-ui-badge-state~="variant=outline"] {
|
|
@@ -542,9 +550,6 @@
|
|
|
542
550
|
/* A count can keep its containing control's contrasting ink instead of reversing it.
|
|
543
551
|
The faint fill preserves text contrast; a quiet boundary separates the count from its control. */
|
|
544
552
|
|
|
545
|
-
/* Neutral reverses a subtle surface/ink pair rather than a strong tone. Keep its original
|
|
546
|
-
surface ink: moving it toward dark-theme primary text would reduce overlay contrast. */
|
|
547
|
-
|
|
548
553
|
/* An icon mark keeps the existing tone recipe and follows the global spacing and radius. */
|
|
549
554
|
|
|
550
555
|
:scope:where([data-component~="ui-badge"])[data-ui-badge-state~="shape=square"] .label :where([data-slotted], [data-slotted] *):is(svg) {
|