canon-ds 0.1.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/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
package/lib/editor.js
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
// Browser-side editor for the gallery. Runs against window.__CANON__ (the full
|
|
2
|
+
// system) using the same engine.js that the Node build uses, so what you see
|
|
3
|
+
// is exactly what `canon build` will emit. Saves through `canon serve`'s API.
|
|
4
|
+
/* global indexTokens, tokensCss, baseCss, componentCss, patternsCss, rePrefix, toLiteral, toCssVars, buildTokens, deepMerge, applySeedChanges, tokenDifferences, parseCssColor */
|
|
5
|
+
(() => {
|
|
6
|
+
const S = window.__CANON__;
|
|
7
|
+
const P = S.meta.prefix;
|
|
8
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
9
|
+
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
10
|
+
const el = (tag, attrs = {}, ...children) => {
|
|
11
|
+
const n = document.createElement(tag);
|
|
12
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
13
|
+
if (k === 'class') n.className = v; else if (k === 'style') n.style.cssText = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else if (v !== undefined && v !== null && v !== false) n.setAttribute(k, v === true ? '' : v);
|
|
14
|
+
}
|
|
15
|
+
for (const c of children.flat()) if (c !== null && c !== undefined && c !== false) n.append(c.nodeType ? c : document.createTextNode(String(c)));
|
|
16
|
+
return n;
|
|
17
|
+
};
|
|
18
|
+
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
|
|
19
|
+
const dirty = { meta: false, tokens: false, components: new Set(), patterns: new Set() };
|
|
20
|
+
let idx = indexTokens(S.tokens, P);
|
|
21
|
+
const history = [];
|
|
22
|
+
const serialize = () => JSON.stringify({ meta: S.meta, tokens: S.tokens, components: S.components, patterns: S.patterns });
|
|
23
|
+
let previousState = serialize();
|
|
24
|
+
const acknowledged = JSON.parse(previousState);
|
|
25
|
+
let editTarget = null, lastEditTarget = null, lastEditAt = 0;
|
|
26
|
+
let saving = false, saveAgain = false;
|
|
27
|
+
const specOf = (slug) => S.components.find((c) => c.slug === slug);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- chrome
|
|
30
|
+
const root = document.documentElement;
|
|
31
|
+
const publicCatalog = root.hasAttribute('data-public-studio');
|
|
32
|
+
if (!publicCatalog && (location.protocol === 'http:' || location.protocol === 'https:')) {
|
|
33
|
+
fetch('/api/project').then(response => response.ok ? response.json() : null).then(project => {
|
|
34
|
+
const label = $('#pv-project');
|
|
35
|
+
if (label && project?.connected) {
|
|
36
|
+
label.textContent = `Project · ${project.name}`;
|
|
37
|
+
label.title = `Changes are saved to ${project.name}`;
|
|
38
|
+
label.hidden = false;
|
|
39
|
+
}
|
|
40
|
+
}).catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
const themeButton = $('#pv-theme');
|
|
43
|
+
function updateThemeButton() {
|
|
44
|
+
const action = root.dataset.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
|
45
|
+
themeButton.setAttribute('aria-label', action); themeButton.title = action;
|
|
46
|
+
$('#pv-theme-label').textContent = action;
|
|
47
|
+
}
|
|
48
|
+
updateThemeButton();
|
|
49
|
+
themeButton.addEventListener('click', () => { root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; updateThemeButton(); if (sel) renderInspector(); });
|
|
50
|
+
const app = $('#pv-app'), panel = $('#pv-editor');
|
|
51
|
+
for (const name of ['input', 'change']) panel.addEventListener(name, (e) => { editTarget = e.target; }, true);
|
|
52
|
+
panel.addEventListener('click', () => { editTarget = null; }, true);
|
|
53
|
+
function setEditor(open) { if (open) app.setAttribute('data-editor', ''); else app.removeAttribute('data-editor'); panel.hidden = !open; if (open && !panel.dataset.ready) { renderPanel(); panel.dataset.ready = '1'; } if (!open) clearSelection(); }
|
|
54
|
+
if (!publicCatalog) $('#pv-edit').addEventListener('click', () => setEditor(!app.hasAttribute('data-editor')));
|
|
55
|
+
document.addEventListener('keydown', (e) => {
|
|
56
|
+
if (publicCatalog) return;
|
|
57
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); save(); }
|
|
58
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName)) { e.preventDefault(); undo(); }
|
|
59
|
+
if (e.key === 'Escape' && sel) clearSelection();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------- rebuild
|
|
63
|
+
function rebuildTokens() {
|
|
64
|
+
try { idx = indexTokens(S.tokens, P); } catch (e) { status(e.message, true); return; }
|
|
65
|
+
$('#cn-css-tokens').textContent = tokensCss(S, idx);
|
|
66
|
+
$('#cn-css-base').textContent = baseCss(S, idx);
|
|
67
|
+
for (const n of $$('[data-token]')) { const t = idx.get(n.dataset.token); if (t) n.textContent = t.light; }
|
|
68
|
+
for (const n of $$('[data-font-label]')) n.textContent = S.tokens.font.family.sans.split(',')[0].replace(/"/g, '');
|
|
69
|
+
status('tokens updated');
|
|
70
|
+
}
|
|
71
|
+
function rebuildComponent(slug) {
|
|
72
|
+
const spec = specOf(slug);
|
|
73
|
+
const style = $(`style[data-component="${slug}"]`);
|
|
74
|
+
try { style.textContent = componentCss(spec, idx, P); status(`${spec.name} updated`); } catch (e) { status(e.message, true); }
|
|
75
|
+
forceState();
|
|
76
|
+
}
|
|
77
|
+
function rerenderExample(slug, kind, i) {
|
|
78
|
+
const spec = specOf(slug);
|
|
79
|
+
const ex = (kind === 'recipe' ? spec.recipes : spec.examples)[i];
|
|
80
|
+
const fig = $(`.pv-section[data-slug="${slug}"] [data-${kind}="${i}"]`);
|
|
81
|
+
if (!fig) return;
|
|
82
|
+
fig.querySelector('.pv-example__stage').innerHTML = rePrefix(ex.html, P);
|
|
83
|
+
fig.querySelector('code').textContent = rePrefix(ex.html, P);
|
|
84
|
+
fig.querySelector('.pv-example__title').firstChild.textContent = ex.title;
|
|
85
|
+
}
|
|
86
|
+
function rebuildAll() { rebuildTokens(); for (const c of S.components) rebuildComponent(c.slug); $('#cn-css-patterns').textContent = patternsCss(S, idx); }
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------- status / save / undo
|
|
89
|
+
const bar = $('#pv-status');
|
|
90
|
+
let statusT;
|
|
91
|
+
function status(msg, isError) { bar.textContent = msg; bar.dataset.error = isError ? '1' : ''; clearTimeout(statusT); statusT = setTimeout(() => { bar.textContent = ''; }, 4000); }
|
|
92
|
+
async function save() {
|
|
93
|
+
if (publicCatalog) return;
|
|
94
|
+
if (saving) { saveAgain = true; return; }
|
|
95
|
+
refreshDirty();
|
|
96
|
+
// The request and its acknowledgement must describe the same snapshot, even
|
|
97
|
+
// while the user continues editing during the network request and build.
|
|
98
|
+
const payload = structuredClone({ meta: dirty.meta ? S.meta : undefined, tokens: dirty.tokens ? S.tokens : undefined, components: Object.fromEntries([...dirty.components].map((s) => [s, specOf(s)])), patterns: Object.fromEntries([...dirty.patterns].map((s) => [s, S.patterns.find((c) => c.slug === s)])) });
|
|
99
|
+
if (!payload.meta && !payload.tokens && !dirty.components.size && !dirty.patterns.size) { status('nothing to save'); return; }
|
|
100
|
+
saving = true; lastEditTarget = null; updateDirtyBadge();
|
|
101
|
+
try {
|
|
102
|
+
const r = await fetch('/api/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
|
|
103
|
+
const j = await r.json();
|
|
104
|
+
if (!r.ok || !j.ok) throw new Error(j.error || `Save failed (${r.status})`);
|
|
105
|
+
if (payload.meta) acknowledged.meta = payload.meta;
|
|
106
|
+
if (payload.tokens) acknowledged.tokens = payload.tokens;
|
|
107
|
+
for (const kind of ['components', 'patterns']) for (const [slug, value] of Object.entries(payload[kind])) {
|
|
108
|
+
const i = acknowledged[kind].findIndex((item) => item.slug === slug);
|
|
109
|
+
if (i < 0) acknowledged[kind].push(value); else acknowledged[kind][i] = value;
|
|
110
|
+
}
|
|
111
|
+
status(j.referencesUpdated ? 'saved to project' : 'design saved');
|
|
112
|
+
const hint = $('#pv-sync-hint');
|
|
113
|
+
if (hint) {
|
|
114
|
+
hint.textContent = j.referencesUpdated
|
|
115
|
+
? 'Saved to this project. Styles and agent references are up to date.'
|
|
116
|
+
: 'Design saved. Use the connection prompt to bring this design into a project.';
|
|
117
|
+
hint.hidden = false;
|
|
118
|
+
}
|
|
119
|
+
} catch (e) { saveAgain = false; exportDialog(payload, e.message); }
|
|
120
|
+
finally {
|
|
121
|
+
saving = false; refreshDirty(); updateDirtyBadge();
|
|
122
|
+
if (saveAgain) { saveAgain = false; save(); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function exportDialog(payload, why) {
|
|
126
|
+
const files = [];
|
|
127
|
+
if (payload.tokens) files.push(['design/tokens.json', payload.tokens]);
|
|
128
|
+
if (payload.meta) files.push(['design/system.json', payload.meta]);
|
|
129
|
+
for (const [s, c] of Object.entries(payload.components)) files.push([`design/components/${s}.json`, c]);
|
|
130
|
+
for (const [s, c] of Object.entries(payload.patterns)) files.push([`design/patterns/${s}.json`, c]);
|
|
131
|
+
const box = el('div', { class: 'pv-modal' }, el('div', { class: 'pv-modal__panel' },
|
|
132
|
+
el('h3', {}, 'Export changes'),
|
|
133
|
+
el('p', { class: 'pv-small pv-muted' }, `Could not save to disk (${why}). Run \`canon serve\` from the project to save directly. Otherwise paste each file into design/ and run canon build.`),
|
|
134
|
+
...files.map(([name, obj]) => el('div', { class: 'pv-export' }, el('div', { class: 'pv-export__head' }, el('code', {}, name), el('button', { class: 'pv-btn', onclick: (e) => { navigator.clipboard?.writeText(JSON.stringify(obj, null, 2)); e.target.textContent = 'copied'; } }, 'copy')), el('textarea', { readonly: true, spellcheck: 'false' }, JSON.stringify(obj, null, 2)))),
|
|
135
|
+
el('button', { class: 'pv-btn', onclick: () => box.remove() }, 'Close')));
|
|
136
|
+
document.body.append(box);
|
|
137
|
+
}
|
|
138
|
+
function undo() {
|
|
139
|
+
const snap = history.pop();
|
|
140
|
+
if (!snap) { status('nothing to undo'); return; }
|
|
141
|
+
const o = JSON.parse(snap);
|
|
142
|
+
S.tokens = o.tokens; S.components.splice(0, S.components.length, ...o.components); S.patterns.splice(0, S.patterns.length, ...o.patterns); S.meta = o.meta;
|
|
143
|
+
previousState = serialize(); lastEditTarget = null; editTarget = null;
|
|
144
|
+
clearSelection(); rebuildAll();
|
|
145
|
+
for (const c of S.components) {
|
|
146
|
+
for (let i = 0; i < c.examples.length; i++) rerenderExample(c.slug, 'example', i);
|
|
147
|
+
for (let i = 0; i < (c.recipes?.length ?? 0); i++) rerenderExample(c.slug, 'recipe', i);
|
|
148
|
+
}
|
|
149
|
+
refreshDirty(); renderPanel(); status('undone');
|
|
150
|
+
}
|
|
151
|
+
function refreshDirty() {
|
|
152
|
+
const differs = (a, b) => JSON.stringify(a) !== JSON.stringify(b);
|
|
153
|
+
dirty.meta = differs(S.meta, acknowledged.meta); dirty.tokens = differs(S.tokens, acknowledged.tokens);
|
|
154
|
+
for (const kind of ['components', 'patterns']) {
|
|
155
|
+
const saved = new Map(acknowledged[kind].map((item) => [item.slug, item]));
|
|
156
|
+
dirty[kind] = new Set(S[kind].filter((item) => differs(item, saved.get(item.slug))).map((item) => item.slug));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function updateDirtyBadge() { const n = (dirty.tokens ? 1 : 0) + (dirty.meta ? 1 : 0) + dirty.components.size + dirty.patterns.size; const b = $('#pv-save'); if (b) { b.textContent = saving ? 'Saving…' : n ? `Save (${n})` : 'Save'; b.disabled = saving; } }
|
|
160
|
+
function touch() {
|
|
161
|
+
const current = serialize(), now = Date.now();
|
|
162
|
+
if (current !== previousState) {
|
|
163
|
+
// Coalesce typing in one field, recording the state before its first edit.
|
|
164
|
+
if (!editTarget || editTarget !== lastEditTarget || now - lastEditAt > 400) {
|
|
165
|
+
history.push(previousState); if (history.length > 80) history.shift();
|
|
166
|
+
}
|
|
167
|
+
previousState = current; lastEditTarget = editTarget; lastEditAt = now;
|
|
168
|
+
}
|
|
169
|
+
refreshDirty(); updateDirtyBadge();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------- helpers
|
|
173
|
+
const setPath = (obj, path, value) => { let o = obj; for (const k of path.slice(0, -1)) o = o[k] ??= {}; o[path[path.length - 1]] = value; };
|
|
174
|
+
const lit = (value, theme) => { try { return toLiteral(rePrefix(value, P), idx, theme ?? (root.dataset.theme === 'dark' ? 'dark' : 'light')); } catch { return value; } };
|
|
175
|
+
const swatch = (value) => { const hex = parseCssColor(lit(value)); return el('span', { class: 'pv-sw', style: hex ? `background:${hex}` : 'background:transparent;border-style:dashed' }); };
|
|
176
|
+
let datalist;
|
|
177
|
+
function ensureDatalist() { if (datalist) return; datalist = el('datalist', { id: 'pv-refs' }, ...[...idx.keys()].map((k) => el('option', { value: `{${k}}` }))); document.body.append(datalist); }
|
|
178
|
+
function valueInput(value, onChange, opts = {}) {
|
|
179
|
+
ensureDatalist();
|
|
180
|
+
const wrap = el('span', { class: 'pv-val' });
|
|
181
|
+
let sw = null;
|
|
182
|
+
const input = el('input', { type: 'text', value, list: 'pv-refs', spellcheck: 'false', class: 'pv-in' });
|
|
183
|
+
const updateSwatch = () => {
|
|
184
|
+
if (opts.color ?? !!parseCssColor(lit(input.value))) {
|
|
185
|
+
const next = swatch(input.value);
|
|
186
|
+
if (sw) sw.replaceWith(next); else wrap.prepend(next);
|
|
187
|
+
sw = next;
|
|
188
|
+
} else { sw?.remove(); sw = null; }
|
|
189
|
+
};
|
|
190
|
+
input.addEventListener('input', () => { onChange(input.value); updateSwatch(); });
|
|
191
|
+
wrap.append(input); updateSwatch();
|
|
192
|
+
if (opts.color) {
|
|
193
|
+
const hex = parseCssColor(lit(value));
|
|
194
|
+
const picker = el('input', { type: 'color', value: hex ?? '#000000', class: 'pv-color', 'aria-label': opts.label ?? 'Choose color' });
|
|
195
|
+
picker.addEventListener('input', () => { input.value = picker.value.toUpperCase(); input.dispatchEvent(new Event('input')); });
|
|
196
|
+
wrap.append(picker);
|
|
197
|
+
}
|
|
198
|
+
return wrap;
|
|
199
|
+
}
|
|
200
|
+
function textArea(value, onChange, rows = 3) { const t = el('textarea', { class: 'pv-ta', rows, spellcheck: 'false' }, value); t.addEventListener('input', () => onChange(t.value)); return t; }
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- panel shell
|
|
203
|
+
function renderPanel() {
|
|
204
|
+
panel.innerHTML = '';
|
|
205
|
+
const tabs = el('div', { class: 'pv-tabs' });
|
|
206
|
+
const body = el('div', { class: 'pv-panel-body' });
|
|
207
|
+
const views = { Style: renderStyle, Inspect: renderComponentTab, Direction: renderDirection };
|
|
208
|
+
let current = panel.dataset.tab || 'Style';
|
|
209
|
+
for (const name of Object.keys(views)) {
|
|
210
|
+
const b = el('button', { class: 'pv-tab', 'data-active': name === current ? '1' : null, onclick: () => { current = name; panel.dataset.tab = name; $$('.pv-tab', tabs).forEach((x) => x.removeAttribute('data-active')); b.dataset.active = '1'; body.innerHTML = ''; body.append(views[name]()); } }, name);
|
|
211
|
+
tabs.append(b);
|
|
212
|
+
}
|
|
213
|
+
const head = el('div', { class: 'pv-panel-head' },
|
|
214
|
+
el('strong', {}, 'Editor'),
|
|
215
|
+
el('span', { style: 'flex:1' }),
|
|
216
|
+
el('button', { class: 'pv-btn', onclick: undo, title: '⌘Z' }, 'Undo'),
|
|
217
|
+
el('button', { class: 'pv-btn pv-btn--primary', id: 'pv-save', onclick: save, title: '⌘S' }, 'Save'),
|
|
218
|
+
el('button', { class: 'pv-btn', type: 'button', 'aria-label': 'Close editor', onclick: () => setEditor(false) }, '×'));
|
|
219
|
+
panel.append(head, tabs, body);
|
|
220
|
+
body.append(views[current]());
|
|
221
|
+
updateDirtyBadge();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ================================================================ Style tab: the four decisions
|
|
225
|
+
const FONTS = ['"Inter", -apple-system, "Segoe UI", Roboto, Arial, sans-serif', '"Geist", "Inter", system-ui, sans-serif', '"DM Sans", system-ui, sans-serif', '"Manrope", system-ui, sans-serif', '"IBM Plex Sans", system-ui, sans-serif', '"Space Grotesk", system-ui, sans-serif', '"Inter Tight", system-ui, sans-serif', 'system-ui, -apple-system, "Segoe UI", sans-serif'];
|
|
226
|
+
const MONOS = ['ui-monospace, "Roboto Mono", SFMono-Regular, Menlo, Monaco, Consolas, monospace', '"Geist Mono", ui-monospace, "SF Mono", Menlo, monospace', '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace', '"IBM Plex Mono", ui-monospace, Menlo, monospace', '"Roboto Mono", ui-monospace, Menlo, monospace'];
|
|
227
|
+
const fontLabel = (f) => (f.match(/"([^"]+)"/)?.[1] ?? 'System');
|
|
228
|
+
const renderSeedChanges = debounce(() => { rebuildTokens(); refreshStyleStrips(); }, 120);
|
|
229
|
+
function replaceSeeds(next) {
|
|
230
|
+
const seeds = S.meta.seeds;
|
|
231
|
+
for (const key of Object.keys(seeds)) delete seeds[key];
|
|
232
|
+
Object.assign(seeds, next);
|
|
233
|
+
}
|
|
234
|
+
function changeSeeds(changes) {
|
|
235
|
+
try {
|
|
236
|
+
// Preserve hand-edited tokens from disk as well as edits in this session.
|
|
237
|
+
const manual = tokenDifferences(S.tokens, buildTokens(S.meta.seeds)) ?? {};
|
|
238
|
+
const next = applySeedChanges(S.meta.seeds, changes, window.__CANON_PRESET_DEFAULTS__);
|
|
239
|
+
next.overrides = deepMerge(next.overrides ?? {}, manual);
|
|
240
|
+
const tokens = buildTokens(next);
|
|
241
|
+
replaceSeeds(next); S.tokens = tokens;
|
|
242
|
+
touch(); renderSeedChanges();
|
|
243
|
+
} catch (e) { status(e.message, true); }
|
|
244
|
+
}
|
|
245
|
+
function refreshStyleStrips() {
|
|
246
|
+
for (const strip of $$('[data-strip]')) { const scale = S.tokens.color.primitive[strip.dataset.strip]; if (!scale) continue; strip.innerHTML = ''; for (const [k, hex] of Object.entries(scale)) strip.append(el('span', { style: `background:${hex}`, title: `${strip.dataset.strip}.${k} ${hex}` })); }
|
|
247
|
+
const big = $('#pv-brand-big'); if (big) big.style.background = S.meta.seeds.brand;
|
|
248
|
+
}
|
|
249
|
+
function renderStyle() {
|
|
250
|
+
const seeds = S.meta.seeds;
|
|
251
|
+
const box = el('div', { class: 'pv-stack' });
|
|
252
|
+
const card = (title, hint, ...kids) => el('div', { class: 'pv-card' }, el('div', { class: 'pv-card__title' }, title), hint ? el('div', { class: 'pv-card__hint' }, hint) : null, ...kids);
|
|
253
|
+
const strip = (name) => { const st = el('div', { class: 'pv-strip', 'data-strip': name }); return st; };
|
|
254
|
+
// Brand
|
|
255
|
+
const brandHex = el('input', { type: 'text', class: 'pv-in', value: seeds.brand, spellcheck: 'false', style: 'font-family:var(--' + P + '-font-family-mono)' });
|
|
256
|
+
const brandPick = el('input', { type: 'color', class: 'pv-color', value: seeds.brand });
|
|
257
|
+
const onBrand = (v) => { if (!/^#[0-9a-fA-F]{6}$/.test(v)) return; changeSeeds({ brand: v.toUpperCase() }); brandHex.value = seeds.brand; brandPick.value = seeds.brand; };
|
|
258
|
+
brandHex.addEventListener('input', () => onBrand(brandHex.value)); brandPick.addEventListener('input', () => onBrand(brandPick.value));
|
|
259
|
+
const actionOn = el('input', { type: 'checkbox', checked: seeds.action ? true : null });
|
|
260
|
+
const actionPick = el('input', { type: 'color', class: 'pv-color', value: seeds.action ?? '#1F1F1F', disabled: seeds.action ? null : true });
|
|
261
|
+
actionOn.addEventListener('change', () => { actionPick.disabled = !actionOn.checked; changeSeeds({ action: actionOn.checked ? actionPick.value.toUpperCase() : undefined }); });
|
|
262
|
+
actionPick.addEventListener('input', () => changeSeeds({ action: actionPick.value.toUpperCase() }));
|
|
263
|
+
box.append(card('Brand color', 'One color. The whole scale, the focus ring and the accents are derived from it.',
|
|
264
|
+
el('div', { class: 'pv-swatch-big', id: 'pv-brand-big', style: `background:${seeds.brand}` }),
|
|
265
|
+
el('div', { class: 'pv-row', style: 'display:flex;gap:6px' }, brandHex, brandPick),
|
|
266
|
+
strip('brand'),
|
|
267
|
+
el('label', { class: 'pv-inline' }, el('span', {}, 'Different color for buttons'), el('span', { style: 'display:flex;gap:6px;align-items:center' }, actionOn, actionPick))));
|
|
268
|
+
// Neutrals
|
|
269
|
+
const canvas = el('input', { type: 'color', class: 'pv-color', value: seeds.canvasLight ?? '#FAFAFA' });
|
|
270
|
+
const ink = el('input', { type: 'color', class: 'pv-color', value: seeds.inkDark ?? '#171717' });
|
|
271
|
+
const hue = el('input', { type: 'range', class: 'pv-range', min: 0, max: 360, step: 1, value: seeds.neutralHue ?? 0 });
|
|
272
|
+
const chroma = el('input', { type: 'range', class: 'pv-range', min: 0, max: 0.03, step: 0.001, value: seeds.neutralChroma ?? 0 });
|
|
273
|
+
const onNeutral = () => changeSeeds({ canvasLight: canvas.value.toUpperCase(), inkDark: ink.value.toUpperCase(), neutralHue: +hue.value, neutralChroma: +chroma.value });
|
|
274
|
+
for (const i of [canvas, ink, hue, chroma]) i.addEventListener('input', onNeutral);
|
|
275
|
+
box.append(card('Neutrals', 'Page background, text and borders come from one grey ramp between these two ends.',
|
|
276
|
+
el('label', { class: 'pv-inline' }, el('span', {}, 'Lightest (page)'), canvas), el('label', { class: 'pv-inline' }, el('span', {}, 'Darkest (text)'), ink),
|
|
277
|
+
el('label', { class: 'pv-inline' }, el('span', {}, 'Tint hue'), hue), el('label', { class: 'pv-inline' }, el('span', {}, 'Tint strength'), chroma),
|
|
278
|
+
strip('neutral')));
|
|
279
|
+
// Typography
|
|
280
|
+
const sansSel = el('select', { class: 'pv-in' }, ...FONTS.map((f) => el('option', { value: f }, fontLabel(f))));
|
|
281
|
+
const curSans = S.tokens.font.family.sans; if (!FONTS.includes(curSans)) sansSel.append(el('option', { value: curSans }, fontLabel(curSans) + ' (current)')); sansSel.value = curSans;
|
|
282
|
+
const monoSel = el('select', { class: 'pv-in' }, ...MONOS.map((f) => el('option', { value: f }, fontLabel(f) === 'System' ? 'System mono' : fontLabel(f))));
|
|
283
|
+
const curMono = S.tokens.font.family.mono; if (!MONOS.includes(curMono)) monoSel.append(el('option', { value: curMono }, 'current')); monoSel.value = curMono;
|
|
284
|
+
const base = el('input', { type: 'number', class: 'pv-in', min: 12, max: 18, step: 0.5, value: seeds.baseFontSize ?? 14 });
|
|
285
|
+
sansSel.addEventListener('change', () => { changeSeeds({ fontSans: sansSel.value, fontDisplay: sansSel.value }); loadFont(fontLabel(sansSel.value)); });
|
|
286
|
+
monoSel.addEventListener('change', () => { changeSeeds({ fontMono: monoSel.value }); loadFont(fontLabel(monoSel.value)); });
|
|
287
|
+
base.addEventListener('input', () => { if (base.value && base.validity.valid) changeSeeds({ baseFontSize: +base.value }); });
|
|
288
|
+
box.append(card('Typography', 'The UI font, the code font and the base size (14 = dense product UI, 16 = editorial).',
|
|
289
|
+
el('label', { class: 'pv-inline' }, el('span', {}, 'Sans'), sansSel), el('label', { class: 'pv-inline' }, el('span', {}, 'Mono'), monoSel), el('label', { class: 'pv-inline' }, el('span', {}, 'Base size (px)'), base),
|
|
290
|
+
el('div', { class: 'pv-preview pv-specimen' }, el('div', {}, el('strong', {}, 'Beautiful analytics'), el('span', {}, 'The quick brown fox jumps over the lazy dog · 0123456789')))));
|
|
291
|
+
// Shape
|
|
292
|
+
const radius = el('input', { type: 'range', class: 'pv-range', min: 0, max: 2.5, step: 0.05, value: seeds.radiusScale ?? 1 });
|
|
293
|
+
const radiusVal = el('span', { class: 'pv-muted pv-small' }, `×${seeds.radiusScale ?? 1}`);
|
|
294
|
+
radius.addEventListener('input', () => { changeSeeds({ radiusScale: +radius.value }); radiusVal.textContent = `×${radius.value}`; });
|
|
295
|
+
box.append(card('Shape', 'Corner radius of everything, from sharp (0) to soft (2.5). Controls 8px, cards 12px, dialogs 16px at ×1.',
|
|
296
|
+
el('div', { class: 'pv-inline' }, el('span', {}, 'Roundness'), radiusVal), radius,
|
|
297
|
+
el('div', { class: 'pv-preview' }, el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-control)`, title: 'control' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-card)`, title: 'card' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-overlay)`, title: 'dialog' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-full)`, title: 'pill' }))));
|
|
298
|
+
// Density
|
|
299
|
+
const ctrl = el('input', { type: 'range', class: 'pv-range', min: 32, max: 48, step: 2, value: seeds.controlHeight ?? 40 });
|
|
300
|
+
const ctrlVal = el('span', { class: 'pv-muted pv-small' }, `${seeds.controlHeight ?? 40}px`);
|
|
301
|
+
ctrl.addEventListener('input', () => { changeSeeds({ controlHeight: +ctrl.value }); ctrlVal.textContent = `${ctrl.value}px`; });
|
|
302
|
+
box.append(card('Density', 'Height of a default button or input. 36 is compact, 40 is the system, 44 is comfortable.',
|
|
303
|
+
el('div', { class: 'pv-inline' }, el('span', {}, 'Control height'), ctrlVal), ctrl,
|
|
304
|
+
el('div', { class: 'pv-preview' }, el('button', { type: 'button', class: `${P}-button`, 'data-variant': 'primary', 'data-size': 'md' }, el('span', { class: `${P}-button__label` }, 'Button')), el('button', { type: 'button', class: `${P}-button`, 'data-variant': 'outline', 'data-size': 'md' }, el('span', { class: `${P}-button__label` }, 'Secondary')), el('div', { class: `${P}-input`, 'data-variant': 'default', 'data-size': 'md', style: 'width:140px' }, el('input', { class: `${P}-input__field`, type: 'text', placeholder: 'Input', 'aria-label': 'Input' })))));
|
|
305
|
+
// Shadows
|
|
306
|
+
const tint = el('input', { type: 'color', class: 'pv-color', value: seeds.shadowTint ?? '#000000' });
|
|
307
|
+
tint.addEventListener('input', () => changeSeeds({ shadowTint: tint.value.toUpperCase() }));
|
|
308
|
+
box.append(card('Elevation', 'Shadows are tinted with this color (black is neutral; a dark brand tone feels warmer).',
|
|
309
|
+
el('label', { class: 'pv-inline' }, el('span', {}, 'Shadow tint'), tint),
|
|
310
|
+
el('div', { class: 'pv-preview' }, el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-card);box-shadow:var(--${P}-shadow-sm);border-color:var(--${P}-color-border-subtle)`, title: 'sm' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-card);box-shadow:var(--${P}-shadow-md);border-color:var(--${P}-color-border-subtle)`, title: 'md' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-card);box-shadow:var(--${P}-shadow-lg);border-color:var(--${P}-color-border-subtle)`, title: 'lg' }), el('span', { class: 'pv-preview-box', style: `border-radius:var(--${P}-radius-card);box-shadow:var(--${P}-shadow-xl);border-color:var(--${P}-color-border-subtle)`, title: 'xl' }))));
|
|
311
|
+
// all tokens
|
|
312
|
+
const all = el('details', { class: 'pv-group' }, el('summary', {}, 'All tokens (advanced)'));
|
|
313
|
+
all.addEventListener('toggle', () => { if (all.open && !all.dataset.ready) { all.append(renderTokens()); all.dataset.ready = '1'; } });
|
|
314
|
+
box.append(all);
|
|
315
|
+
setTimeout(refreshStyleStrips, 0);
|
|
316
|
+
return box;
|
|
317
|
+
}
|
|
318
|
+
function loadFont(name) {
|
|
319
|
+
const known = { Geist: 'Geist:wght@400..700', 'Geist Mono': 'Geist+Mono:wght@400..700', Inter: 'Inter:wght@400..700', 'Inter Tight': 'Inter+Tight:wght@400..700', 'JetBrains Mono': 'JetBrains+Mono:wght@400..700', 'IBM Plex Sans': 'IBM+Plex+Sans:wght@400..700', 'IBM Plex Mono': 'IBM+Plex+Mono:wght@400..700', Manrope: 'Manrope:wght@400..700', 'DM Sans': 'DM+Sans:wght@400..700', 'Space Grotesk': 'Space+Grotesk:wght@400..700', 'Roboto Mono': 'Roboto+Mono:wght@400..700' };
|
|
320
|
+
if (!known[name] || document.querySelector(`link[data-font="${name}"]`)) return;
|
|
321
|
+
document.head.append(el('link', { rel: 'stylesheet', href: `https://fonts.googleapis.com/css2?family=${known[name]}&display=swap`, 'data-font': name }));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ================================================================ Tokens tab (advanced)
|
|
325
|
+
const debouncedTokens = debounce(rebuildTokens, 150);
|
|
326
|
+
function renderTokens() {
|
|
327
|
+
const box = el('div');
|
|
328
|
+
const seeds = S.meta.seeds;
|
|
329
|
+
const seedRow = (label, key, type = 'text', extra = {}) => {
|
|
330
|
+
const eff = key === 'fontSans' ? S.tokens.font.family.sans : key === 'fontMono' ? S.tokens.font.family.mono : key === 'action' ? 'same as brand' : key === 'shadowTint' ? '#000000' : '';
|
|
331
|
+
const input = el('input', { type, value: seeds[key] ?? '', placeholder: eff, class: type === 'color' ? 'pv-color' : 'pv-in', ...extra });
|
|
332
|
+
input.addEventListener('input', () => { if (type === 'number' && (!input.value || !input.validity.valid)) return; changeSeeds({ [key]: type === 'number' ? +input.value : input.value }); });
|
|
333
|
+
return el('label', { class: 'pv-row' }, el('span', {}, label), input);
|
|
334
|
+
};
|
|
335
|
+
box.append(el('details', { class: 'pv-group' }, el('summary', {}, 'Seeds — regenerate the whole token set'),
|
|
336
|
+
seedRow('Brand', 'brand', 'color'), seedRow('Action (optional)', 'action', 'text'), seedRow('Canvas light', 'canvasLight', 'color'), seedRow('Ink dark', 'inkDark', 'color'),
|
|
337
|
+
seedRow('Neutral hue', 'neutralHue', 'number', { step: 1, min: 0, max: 360 }), seedRow('Neutral chroma', 'neutralChroma', 'number', { step: 0.001, min: 0, max: 0.05 }),
|
|
338
|
+
seedRow('Radius scale', 'radiusScale', 'number', { step: 0.05, min: 0, max: 3 }), seedRow('Base font px', 'baseFontSize', 'number', { step: 0.5 }), seedRow('Control height md', 'controlHeight', 'number', { step: 1 }),
|
|
339
|
+
seedRow('Sans', 'fontSans'), seedRow('Mono', 'fontMono'), seedRow('Shadow tint', 'shadowTint', 'text'),
|
|
340
|
+
el('button', { class: 'pv-btn', onclick: () => { renderPanel(); } }, 'Refresh token fields'),
|
|
341
|
+
el('p', { class: 'pv-small pv-muted' }, 'Manual edits below are kept on top of regenerated values.')));
|
|
342
|
+
for (const g of ['color', 'font', 'type', 'space', 'radius', 'border', 'shadow', 'size', 'z', 'motion', 'breakpoint', 'opacity']) box.append(renderTokenGroup(g, S.tokens[g], [g]));
|
|
343
|
+
return box;
|
|
344
|
+
}
|
|
345
|
+
function renderTokenGroup(label, obj, path) {
|
|
346
|
+
const det = el('details', { class: 'pv-group' }, el('summary', {}, label));
|
|
347
|
+
if (path.length === 1 && label === 'color') { det.append(renderTokenGroup('primitive', obj.primitive, [...path, 'primitive']), renderTokenGroup('semantic', obj.semantic, [...path, 'semantic'])); return det; }
|
|
348
|
+
const isThemed = (v) => v && typeof v === 'object' && 'light' in v && 'dark' in v;
|
|
349
|
+
const isTypeStyle = (v) => v && typeof v === 'object' && 'family' in v && 'size' in v;
|
|
350
|
+
const table = el('div', { class: 'pv-tok' });
|
|
351
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
352
|
+
const p2 = [...path, k];
|
|
353
|
+
const commit = (sub, nv) => {
|
|
354
|
+
const next = applySeedChanges(S.meta.seeds, {}, window.__CANON_PRESET_DEFAULTS__);
|
|
355
|
+
setPath(next.overrides ??= {}, sub, nv); replaceSeeds(next);
|
|
356
|
+
setPath(S.tokens, sub, nv); touch(); debouncedTokens();
|
|
357
|
+
};
|
|
358
|
+
if (typeof v === 'string') table.append(el('div', { class: 'pv-tok__row' }, el('code', {}, k), valueInput(v, (nv) => commit(p2, nv), { color: path.includes('primitive') || /^#/.test(v) })));
|
|
359
|
+
else if (isThemed(v)) table.append(el('div', { class: 'pv-tok__row pv-tok__row--themed' }, el('code', { title: v.description ?? '' }, k), valueInput(v.light, (nv) => commit([...p2, 'light'], nv), { color: path[0] === 'color' }), valueInput(v.dark, (nv) => commit([...p2, 'dark'], nv), { color: path[0] === 'color' })));
|
|
360
|
+
else if (isTypeStyle(v)) {
|
|
361
|
+
const sub = el('details', { class: 'pv-group pv-group--sub' }, el('summary', {}, el('code', {}, k), ' ', el('span', { class: 'pv-muted pv-small' }, v.description ?? '')));
|
|
362
|
+
for (const prop of ['family', 'size', 'weight', 'lineHeight', 'letterSpacing']) sub.append(el('div', { class: 'pv-tok__row' }, el('code', {}, prop), valueInput(v[prop], (nv) => commit([...p2, prop], nv))));
|
|
363
|
+
const selT = el('select', { class: 'pv-in' }, el('option', { value: 'none' }, 'none'), el('option', { value: 'uppercase' }, 'uppercase'));
|
|
364
|
+
selT.value = v.transform ?? 'none';
|
|
365
|
+
selT.addEventListener('change', () => commit([...p2, 'transform'], selT.value));
|
|
366
|
+
sub.append(el('div', { class: 'pv-tok__row' }, el('code', {}, 'transform'), selT));
|
|
367
|
+
table.append(sub);
|
|
368
|
+
} else if (v && typeof v === 'object') table.append(renderTokenGroup(k, v, p2));
|
|
369
|
+
}
|
|
370
|
+
det.append(table);
|
|
371
|
+
return det;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ================================================================ Component tab: inspector
|
|
375
|
+
let sel = null; // { slug, part, ctx: {axis: value}, state: string|null, scope: string, el: HTMLElement|null }
|
|
376
|
+
const GROUPS = {
|
|
377
|
+
Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'flex-wrap', 'gap', 'row-gap', 'column-gap', 'width', 'min-width', 'max-width', 'height', 'min-height', 'max-height', 'padding', 'padding-inline', 'padding-block', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'padding-inline-start', 'padding-inline-end', 'margin', 'margin-inline', 'margin-block', 'margin-top', 'margin-bottom', 'margin-inline-start', 'margin-inline-end', 'position', 'inset', 'top', 'right', 'bottom', 'left', 'overflow', 'flex', 'flex-shrink', 'flex-grow', 'z-index', 'grid-template-columns'],
|
|
378
|
+
Typography: ['font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'text-transform', 'color', 'text-align', 'text-decoration', 'text-underline-offset', 'text-decoration-color', 'white-space', 'font-variant-numeric', 'text-overflow'],
|
|
379
|
+
'Fill & border': ['background-color', 'background', 'border', 'border-color', 'border-width', 'border-style', 'border-radius', 'border-top', 'border-bottom', 'border-inline-start', 'border-inline-end', 'border-block-start', 'border-block-end', 'outline', 'outline-offset', 'outline-color'],
|
|
380
|
+
Effects: ['box-shadow', 'opacity', 'transform', 'transition-property', 'transition-duration', 'transition-timing-function', 'animation', 'cursor', 'pointer-events', 'visibility', 'object-fit', 'aspect-ratio'],
|
|
381
|
+
};
|
|
382
|
+
const ENUMS = {
|
|
383
|
+
display: ['flex', 'inline-flex', 'block', 'inline-block', 'grid', 'inline-grid', 'none', 'contents'],
|
|
384
|
+
'flex-direction': ['row', 'column', 'row-reverse', 'column-reverse'],
|
|
385
|
+
'align-items': ['center', 'flex-start', 'flex-end', 'stretch', 'baseline'],
|
|
386
|
+
'justify-content': ['flex-start', 'center', 'flex-end', 'space-between', 'space-around'],
|
|
387
|
+
'flex-wrap': ['nowrap', 'wrap'],
|
|
388
|
+
position: ['static', 'relative', 'absolute', 'sticky', 'fixed'],
|
|
389
|
+
overflow: ['visible', 'hidden', 'auto', 'scroll', 'clip'],
|
|
390
|
+
cursor: ['pointer', 'default', 'not-allowed', 'text', 'grab', 'progress', 'wait'],
|
|
391
|
+
'text-align': ['left', 'center', 'right', 'start', 'end'],
|
|
392
|
+
'text-transform': ['none', 'uppercase', 'capitalize', 'lowercase'],
|
|
393
|
+
'text-decoration': ['none', 'underline', 'line-through'],
|
|
394
|
+
'white-space': ['normal', 'nowrap', 'pre', 'pre-wrap'],
|
|
395
|
+
'border-style': ['solid', 'dashed', 'dotted', 'none'],
|
|
396
|
+
'pointer-events': ['auto', 'none'],
|
|
397
|
+
visibility: ['visible', 'hidden'],
|
|
398
|
+
'object-fit': ['cover', 'contain', 'fill'],
|
|
399
|
+
'font-variant-numeric': ['normal', 'tabular-nums'],
|
|
400
|
+
};
|
|
401
|
+
const kindOf = (prop) => {
|
|
402
|
+
if (/color$/.test(prop) || prop === 'color' || prop === 'background' || prop === 'outline-color') return 'color';
|
|
403
|
+
if (/^(gap|row-gap|column-gap|padding|margin|inset|top|right|bottom|left)/.test(prop)) return 'space';
|
|
404
|
+
if (/^(width|height|min-width|max-width|min-height|max-height|flex-basis)$/.test(prop)) return 'size';
|
|
405
|
+
if (/radius/.test(prop)) return 'radius';
|
|
406
|
+
if (prop === 'box-shadow') return 'shadow';
|
|
407
|
+
if (prop === 'font-size') return 'font-size';
|
|
408
|
+
if (prop === 'font-weight') return 'font-weight';
|
|
409
|
+
if (prop === 'line-height') return 'line-height';
|
|
410
|
+
if (prop === 'letter-spacing') return 'letter-spacing';
|
|
411
|
+
if (prop === 'font-family') return 'font-family';
|
|
412
|
+
if (prop === 'transition-duration') return 'duration';
|
|
413
|
+
if (prop === 'transition-timing-function') return 'easing';
|
|
414
|
+
if (prop === 'opacity') return 'opacity';
|
|
415
|
+
if (prop === 'z-index') return 'z';
|
|
416
|
+
if (/^(border|border-top|border-bottom|border-inline-start|border-inline-end|border-block-start|border-block-end|outline)$/.test(prop)) return 'border';
|
|
417
|
+
if (ENUMS[prop]) return 'enum';
|
|
418
|
+
return 'any';
|
|
419
|
+
};
|
|
420
|
+
const tokensOf = (prefixes) => [...idx.values()].filter((t) => prefixes.some((p) => t.ref.startsWith(`{${p}`)));
|
|
421
|
+
const OPTIONS = {
|
|
422
|
+
color: () => [...tokensOf(['color.']).map((t) => ({ v: t.ref, label: `${t.path} · ${t.light}` })), ...tokensOf(['neutral.', 'brand.', 'action.', 'red.', 'amber.', 'green.', 'blue.', 'white', 'black']).map((t) => ({ v: t.ref, label: `${t.ref.slice(1, -1)} · ${t.light}` })), { v: 'transparent', label: 'transparent' }, { v: 'currentColor', label: 'currentColor' }, { v: 'inherit', label: 'inherit' }],
|
|
423
|
+
space: () => [...tokensOf(['space.']).map((t) => ({ v: t.ref, label: `space.${t.path} · ${t.light}` })), { v: '0', label: '0' }, { v: 'auto', label: 'auto' }],
|
|
424
|
+
size: () => [...tokensOf(['size.']).map((t) => ({ v: t.ref, label: `${t.ref.slice(1, -1)} · ${t.light}` })), ...tokensOf(['space.']).map((t) => ({ v: t.ref, label: `space.${t.path} · ${t.light}` })), { v: '100%', label: '100%' }, { v: 'auto', label: 'auto' }, { v: 'max-content', label: 'max-content' }, { v: '1em', label: '1em' }],
|
|
425
|
+
radius: () => [...tokensOf(['radius.']).map((t) => ({ v: t.ref, label: `radius.${t.path} · ${t.light}` })), { v: '0', label: '0' }],
|
|
426
|
+
shadow: () => [...tokensOf(['shadow.']).map((t) => ({ v: t.ref, label: `shadow.${t.path}` })), { v: 'none', label: 'none' }],
|
|
427
|
+
'font-size': () => [...tokensOf(['font.size.', 'font.control.']).map((t) => ({ v: t.ref, label: `${t.ref.slice(1, -1)} · ${t.light}` })), { v: '1em', label: '1em' }, { v: 'inherit', label: 'inherit' }],
|
|
428
|
+
'font-weight': () => [...tokensOf(['font.weight.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light}` })), { v: 'inherit', label: 'inherit' }],
|
|
429
|
+
'line-height': () => [...tokensOf(['font.lineHeight.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light}` })), { v: '1', label: '1' }, { v: 'inherit', label: 'inherit' }],
|
|
430
|
+
'letter-spacing': () => [...tokensOf(['font.letterSpacing.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light}` })), { v: '0', label: '0' }, { v: 'inherit', label: 'inherit' }],
|
|
431
|
+
'font-family': () => [...tokensOf(['font.family.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light.split(',')[0].replace(/"/g, '')}` })), { v: 'inherit', label: 'inherit' }],
|
|
432
|
+
duration: () => tokensOf(['motion.duration.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light}` })),
|
|
433
|
+
easing: () => tokensOf(['motion.easing.']).map((t) => ({ v: t.ref, label: `${t.path.split('.')[1]} · ${t.light}` })),
|
|
434
|
+
opacity: () => [...tokensOf(['opacity.']).map((t) => ({ v: t.ref, label: `${t.path} · ${t.light}` })), { v: '1', label: '1' }, { v: '0', label: '0' }],
|
|
435
|
+
z: () => [...tokensOf(['z.']).map((t) => ({ v: t.ref, label: `${t.path} · ${t.light}` })), { v: 'auto', label: 'auto' }],
|
|
436
|
+
border: () => [...tokensOf(['color.border-']).map((t) => ({ v: `{border.width.thin} solid ${t.ref}`, label: `1px solid ${t.path}` })), { v: '0', label: '0 (none)' }, { v: '{border.width.medium} solid {color.border-action}', label: '2px solid border-action' }],
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
function layers(spec, ctx) {
|
|
440
|
+
const L = [{ scope: 'base', label: 'Base (all)', block: () => (spec.base ??= {}) }];
|
|
441
|
+
for (const [axis, values] of Object.entries(spec.variants ?? {})) { const v = ctx[axis]; if (v && values[v] !== undefined) L.push({ scope: `variants.${axis}.${v}`, label: `${axis} = ${v}`, block: () => (values[v] ??= {}) }); }
|
|
442
|
+
(spec.compound ?? []).forEach((c, i) => { if (Object.entries(c.when).every(([a, v]) => ctx[a] === v)) L.push({ scope: `compound.${i}`, label: `when ${Object.entries(c.when).map(([a, v]) => `${a}=${v}`).join(' + ')}`, block: () => (c.block ??= {}) }); });
|
|
443
|
+
return L;
|
|
444
|
+
}
|
|
445
|
+
function effective(spec, part, ctx, state) {
|
|
446
|
+
const out = {};
|
|
447
|
+
for (const L of layers(spec, ctx)) {
|
|
448
|
+
const b = L.block();
|
|
449
|
+
for (const [k, v] of Object.entries(b?.[part] ?? {})) out[k] = { value: v, scope: L.scope, label: L.label, state: null };
|
|
450
|
+
if (state) for (const [k, v] of Object.entries(b?.['@states']?.[state]?.[part] ?? {})) out[k] = { value: v, scope: L.scope, label: L.label, state };
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
function declsAt(spec, scope, part, state, create) {
|
|
455
|
+
const L = layers(spec, sel.ctx).find((l) => l.scope === scope);
|
|
456
|
+
if (!L) return null;
|
|
457
|
+
const b = L.block();
|
|
458
|
+
if (!state) return create ? (b[part] ??= {}) : b[part];
|
|
459
|
+
if (create) return (((b['@states'] ??= {})[state] ??= {})[part] ??= {});
|
|
460
|
+
return b['@states']?.[state]?.[part];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- selection
|
|
464
|
+
const forceStyle = () => $('#pv-force') ?? document.head.appendChild(el('style', { id: 'pv-force' }));
|
|
465
|
+
const hlStyle = () => $('#pv-hl') ?? document.head.appendChild(el('style', { id: 'pv-hl' }));
|
|
466
|
+
function clearSelection() { $$('[data-pv-selected]').forEach((n) => n.removeAttribute('data-pv-selected')); sel = null; hlStyle().textContent = ''; forceStyle().textContent = ''; if (panel.dataset.tab === 'Inspect' && !panel.hidden) renderPanel(); }
|
|
467
|
+
function selectElement(node) {
|
|
468
|
+
const slugs = S.components.map((c) => c.slug).sort((a, b) => b.length - a.length);
|
|
469
|
+
let target = node, found = null;
|
|
470
|
+
while (target && target !== document.body) {
|
|
471
|
+
const classes = [...(target.classList ?? [])];
|
|
472
|
+
for (const cls of classes) {
|
|
473
|
+
if (!cls.startsWith(`${P}-`)) continue;
|
|
474
|
+
const rest = cls.slice(P.length + 1);
|
|
475
|
+
const [base, part] = rest.split('__');
|
|
476
|
+
if (slugs.includes(base)) { found = { slug: base, part: part ?? 'root', node: target }; break; }
|
|
477
|
+
}
|
|
478
|
+
if (found) break;
|
|
479
|
+
target = target.parentElement;
|
|
480
|
+
}
|
|
481
|
+
if (!found) return false;
|
|
482
|
+
const spec = specOf(found.slug);
|
|
483
|
+
const rootEl = found.part === 'root' ? found.node : found.node.closest(`.${P}-${found.slug}`) ?? found.node;
|
|
484
|
+
const ctx = {};
|
|
485
|
+
for (const [axis, pr] of Object.entries(spec.props)) ctx[axis] = rootEl.dataset[axis] ?? pr.default;
|
|
486
|
+
$$('[data-pv-selected]').forEach((n) => n.removeAttribute('data-pv-selected'));
|
|
487
|
+
rootEl.setAttribute('data-pv-selected', '');
|
|
488
|
+
sel = { slug: found.slug, part: found.part, ctx, state: null, scope: null, el: rootEl };
|
|
489
|
+
hlStyle().textContent = `[data-pv-selected] { outline: 2px solid var(--${P}-color-bg-accent) !important; outline-offset: 2px; }`;
|
|
490
|
+
setEditor(true);
|
|
491
|
+
panel.dataset.tab = 'Inspect';
|
|
492
|
+
renderPanel();
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
function selectPart(part) { if (!sel) return; sel.part = part; renderInspector(); }
|
|
496
|
+
function highlightPart(part) {
|
|
497
|
+
if (!sel) return;
|
|
498
|
+
hlStyle().textContent = `[data-pv-selected] { outline: 2px solid var(--${P}-color-bg-accent) !important; outline-offset: 2px; }` + (part && part !== 'root' ? ` [data-pv-selected] .${P}-${sel.slug}__${part} { outline: 1px dashed var(--${P}-color-bg-accent) !important; outline-offset: 1px; }` : '');
|
|
499
|
+
}
|
|
500
|
+
function forceState() {
|
|
501
|
+
const st = forceStyle();
|
|
502
|
+
if (!sel || !sel.state) { st.textContent = ''; return; }
|
|
503
|
+
const spec = specOf(sel.slug);
|
|
504
|
+
const rules = [];
|
|
505
|
+
for (const a of spec.anatomy) {
|
|
506
|
+
const eff = effective(spec, a.part, sel.ctx, sel.state);
|
|
507
|
+
const decls = Object.entries(eff).filter(([, v]) => v.state).map(([k, v]) => { try { return `${k}: ${toCssVars(rePrefix(v.value, P), idx)} !important`; } catch { return null; } }).filter(Boolean);
|
|
508
|
+
if (decls.length) rules.push(`[data-pv-selected]${a.part === 'root' ? '' : ` .${P}-${sel.slug}__${a.part}`} { ${decls.join('; ')}; }`);
|
|
509
|
+
}
|
|
510
|
+
st.textContent = rules.join('\n');
|
|
511
|
+
}
|
|
512
|
+
// click-to-select inside the gallery when the editor is open
|
|
513
|
+
$('.pv-main').addEventListener('click', (e) => {
|
|
514
|
+
if (!app.hasAttribute('data-editor')) return;
|
|
515
|
+
const stage = e.target.closest('.pv-example__stage, .pv-matrix td, .pv-frame');
|
|
516
|
+
if (!stage) return;
|
|
517
|
+
e.preventDefault(); e.stopPropagation();
|
|
518
|
+
selectElement(e.target);
|
|
519
|
+
}, true);
|
|
520
|
+
|
|
521
|
+
// ---- inspector
|
|
522
|
+
function visibleExample(slug) {
|
|
523
|
+
return $$('.pv-section[data-slug="' + slug + '"] .pv-example__stage .' + P + '-' + slug).find((node) => node.getClientRects().length);
|
|
524
|
+
}
|
|
525
|
+
function renderComponentTab() {
|
|
526
|
+
const box = el('div', { id: 'pv-inspector' });
|
|
527
|
+
box.append(renderInspectorInto());
|
|
528
|
+
return box;
|
|
529
|
+
}
|
|
530
|
+
const humanLabel = (name) => name.replace(/[-_]/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
|
|
531
|
+
const sizeLabel = (value) => ({ xs: 'Extra small', sm: 'Small', md: 'Medium', lg: 'Large', xl: 'Extra large' }[value] ?? humanLabel(value));
|
|
532
|
+
const partLabel = (part) => part.part === 'field' && part.element === 'input' ? 'Text input'
|
|
533
|
+
: ({ root: 'Container', affix: 'Prefix / suffix', tooltip: 'Help icon' }[part.part] ?? humanLabel(part.part));
|
|
534
|
+
const propertyLabel = (prop) => ({
|
|
535
|
+
'min-width': 'Minimum width', 'max-width': 'Maximum width', 'min-height': 'Minimum height', 'max-height': 'Maximum height',
|
|
536
|
+
'padding-inline': 'Horizontal padding', 'padding-block': 'Vertical padding',
|
|
537
|
+
'margin-inline': 'Horizontal margin', 'margin-block': 'Vertical margin',
|
|
538
|
+
'padding-inline-start': 'Start padding', 'padding-inline-end': 'End padding',
|
|
539
|
+
'font-family': 'Font', 'font-size': 'Text size', 'font-weight': 'Weight',
|
|
540
|
+
'line-height': 'Line height', 'letter-spacing': 'Letter spacing', 'text-transform': 'Capitalization',
|
|
541
|
+
'background-color': 'Fill', 'border-radius': 'Corner radius', 'box-shadow': 'Shadow',
|
|
542
|
+
'align-items': 'Align items', 'justify-content': 'Distribute items', 'flex-direction': 'Direction',
|
|
543
|
+
}[prop] ?? humanLabel(prop));
|
|
544
|
+
const scopeLabel = (layer) => {
|
|
545
|
+
if (layer.scope === 'base') return 'All sizes and variants';
|
|
546
|
+
if (layer.scope.startsWith('compound.')) return 'Only this combination';
|
|
547
|
+
const [, axis, value] = layer.scope.split('.');
|
|
548
|
+
return (axis === 'size' ? sizeLabel(value) : humanLabel(value)) + ' ' + axis;
|
|
549
|
+
};
|
|
550
|
+
function inspectorField(label, control, id) {
|
|
551
|
+
control.id = id;
|
|
552
|
+
return el('div', { class: 'pv-field' }, el('label', { class: 'pv-label', for: id }, label), control);
|
|
553
|
+
}
|
|
554
|
+
function renderInspector() {
|
|
555
|
+
const box = $('#pv-inspector'); if (!box) return;
|
|
556
|
+
const openGroups = Object.fromEntries($$('details[data-group]', box).map((group) => [group.dataset.group, group.open]));
|
|
557
|
+
box.innerHTML = ''; box.append(renderInspectorInto(openGroups));
|
|
558
|
+
}
|
|
559
|
+
function renderInspectorInto(openGroups = {}) {
|
|
560
|
+
const wrap = el('div', { class: 'pv-inspect-stack' });
|
|
561
|
+
const picker = el('select', { class: 'pv-in pv-in--wide' },
|
|
562
|
+
el('option', { value: '' }, 'Choose a component…'),
|
|
563
|
+
...S.components.map((c) => el('option', { value: c.slug }, c.name)));
|
|
564
|
+
picker.value = sel?.slug ?? '';
|
|
565
|
+
picker.addEventListener('change', () => {
|
|
566
|
+
const spec = specOf(picker.value);
|
|
567
|
+
if (!spec) { clearSelection(); return; }
|
|
568
|
+
const first = visibleExample(spec.slug);
|
|
569
|
+
if (first) { selectElement(first); first.scrollIntoView({ block: 'center' }); }
|
|
570
|
+
else { sel = { slug: spec.slug, part: 'root', ctx: Object.fromEntries(Object.entries(spec.props).map(([k, v]) => [k, v.default])), state: null, scope: null, el: null }; renderInspector(); }
|
|
571
|
+
});
|
|
572
|
+
const selection = el('section', { class: 'pv-inspect-section' }, inspectorField('Component', picker, 'pv-component-picker'));
|
|
573
|
+
wrap.append(selection);
|
|
574
|
+
if (!sel) {
|
|
575
|
+
selection.append(el('p', { class: 'pv-inspect-hint' }, 'Choose a component or click an example. Adjust its appearance here, then Save to update your design system.'));
|
|
576
|
+
return wrap;
|
|
577
|
+
}
|
|
578
|
+
const spec = specOf(sel.slug);
|
|
579
|
+
const commit = () => { touch('component', sel.slug); rebuildComponent(sel.slug); };
|
|
580
|
+
const parts = el('div', { class: 'pv-chips', role: 'group', 'aria-label': 'Component part' }, ...spec.anatomy.map((part) =>
|
|
581
|
+
el('button', { type: 'button', class: 'pv-chip pv-chip--btn', 'data-on': part.part === sel.part ? '1' : null, 'aria-pressed': String(part.part === sel.part),
|
|
582
|
+
onclick: () => selectPart(part.part), onmouseenter: () => highlightPart(part.part), onmouseleave: () => highlightPart(sel.part), title: part.description }, partLabel(part))));
|
|
583
|
+
selection.append(el('div', { class: 'pv-field' }, el('span', { class: 'pv-label' }, 'Part to edit'), parts));
|
|
584
|
+
selection.append(el('code', { class: 'pv-inspect-selector' }, '.' + P + '-' + sel.slug + (sel.part === 'root' ? '' : '__' + sel.part)));
|
|
585
|
+
|
|
586
|
+
const preview = el('section', { class: 'pv-inspect-section pv-inspect-preview' },
|
|
587
|
+
el('div', { class: 'pv-inspect-heading' }, 'Preview'),
|
|
588
|
+
el('p', { class: 'pv-inspect-hint' }, 'Choose the example to inspect.'));
|
|
589
|
+
const context = el('div', { class: 'pv-ctx' });
|
|
590
|
+
for (const [axis, prop] of Object.entries(spec.props)) {
|
|
591
|
+
const control = el('select', { class: 'pv-in' }, ...prop.values.map((value) => el('option', { value }, axis === 'size' ? sizeLabel(value) : humanLabel(value))));
|
|
592
|
+
control.value = sel.ctx[axis];
|
|
593
|
+
control.addEventListener('change', () => { sel.ctx[axis] = control.value; if (sel.el) sel.el.setAttribute('data-' + axis, control.value); if (sel.scope && !layers(spec, sel.ctx).some((layer) => layer.scope === sel.scope)) sel.scope = null; renderInspector(); forceState(); });
|
|
594
|
+
context.append(inspectorField(humanLabel(axis), control, 'pv-preview-' + axis));
|
|
595
|
+
}
|
|
596
|
+
if (Object.keys(spec.states).length) {
|
|
597
|
+
const states = el('select', { class: 'pv-in' }, el('option', { value: '' }, 'Default'),
|
|
598
|
+
...Object.keys(spec.states).map((state) => el('option', { value: state }, humanLabel(state))));
|
|
599
|
+
states.value = sel.state ?? '';
|
|
600
|
+
states.addEventListener('change', () => { sel.state = states.value || null; renderInspector(); forceState(); });
|
|
601
|
+
context.append(inspectorField('State', states, 'pv-preview-state'));
|
|
602
|
+
}
|
|
603
|
+
if (context.children.length) { preview.append(context); wrap.append(preview); }
|
|
604
|
+
|
|
605
|
+
const L = layers(spec, sel.ctx);
|
|
606
|
+
const eff = effective(spec, sel.part, sel.ctx, sel.state);
|
|
607
|
+
const stateful = Object.values(eff).filter((value) => sel.state ? value.state === sel.state : !value.state);
|
|
608
|
+
const owning = L.filter((layer) => Object.keys(layer.block()[sel.part] ?? {}).length || (sel.state && Object.keys(layer.block()['@states']?.[sel.state]?.[sel.part] ?? {}).length));
|
|
609
|
+
const defaultScope = sel.scope ?? stateful.at(-1)?.scope ?? owning.at(-1)?.scope ?? 'base';
|
|
610
|
+
const stateSuffix = sel.state ? ' · ' + humanLabel(sel.state) : '';
|
|
611
|
+
const scopeControl = el('select', { class: 'pv-in' }, ...L.map((layer) => el('option', { value: layer.scope }, scopeLabel(layer) + stateSuffix)));
|
|
612
|
+
if (Object.keys(sel.ctx).length > 1 && !L.some((layer) => layer.scope.startsWith('compound.'))) {
|
|
613
|
+
scopeControl.append(el('option', { value: '__compound' }, '+ Only this combination' + stateSuffix));
|
|
614
|
+
}
|
|
615
|
+
scopeControl.value = defaultScope;
|
|
616
|
+
scopeControl.addEventListener('change', () => {
|
|
617
|
+
if (scopeControl.value === '__compound') {
|
|
618
|
+
(spec.compound ??= []).push({ when: { ...sel.ctx }, block: { root: {} } });
|
|
619
|
+
sel.scope = 'compound.' + (spec.compound.length - 1); commit();
|
|
620
|
+
} else sel.scope = scopeControl.value;
|
|
621
|
+
renderInspector();
|
|
622
|
+
});
|
|
623
|
+
const scope = scopeControl.value;
|
|
624
|
+
const [, axis, value] = scope.split('.');
|
|
625
|
+
const scopeHint = scope === 'base' ? 'Applies to every ' + spec.name + ', in all sizes and variants'
|
|
626
|
+
: scope.startsWith('compound.') ? 'Applies wherever this combination is used'
|
|
627
|
+
: 'Applies to every ' + spec.name + ' with ' + axis + ' ' + (axis === 'size' ? sizeLabel(value) : humanLabel(value));
|
|
628
|
+
wrap.append(el('section', { class: 'pv-inspect-section pv-inspect-scope' },
|
|
629
|
+
inspectorField('Apply changes to', scopeControl, 'pv-edit-scope'),
|
|
630
|
+
el('p', { class: 'pv-inspect-hint' }, scopeHint + (sel.state ? ', in the ' + humanLabel(sel.state) + ' state.' : '.'))));
|
|
631
|
+
const write = (prop, value) => { const declarations = declsAt(spec, scope, sel.part, sel.state, true); declarations[prop] = value; commit(); };
|
|
632
|
+
const remove = (prop) => { const declarations = declsAt(spec, scope, sel.part, sel.state, false); if (declarations) { delete declarations[prop]; commit(); renderInspector(); } };
|
|
633
|
+
const ownDeclarations = declsAt(spec, scope, sel.part, sel.state, false) ?? {};
|
|
634
|
+
|
|
635
|
+
const groups = el('div', { class: 'pv-inspect-groups' });
|
|
636
|
+
for (const [group, props] of Object.entries(GROUPS)) {
|
|
637
|
+
const present = props.filter((prop) => eff[prop]);
|
|
638
|
+
const extra = Object.keys(eff).filter((prop) => !Object.values(GROUPS).flat().includes(prop));
|
|
639
|
+
const list = group === 'Effects' ? [...present, ...extra] : present;
|
|
640
|
+
const details = el('details', { class: 'pv-group', 'data-group': group, open: openGroups[group] ?? (group === 'Layout') },
|
|
641
|
+
el('summary', {}, group, el('span', { class: 'pv-group__count' }, String(list.length))));
|
|
642
|
+
const content = el('div', { class: 'pv-inspect-properties' });
|
|
643
|
+
if (group === 'Typography') {
|
|
644
|
+
const names = Object.keys(S.tokens.type);
|
|
645
|
+
const current = names.find((name) => (ownDeclarations['font-size'] ?? eff['font-size']?.value) === '{type.' + name + '.size}' && (ownDeclarations['font-weight'] ?? eff['font-weight']?.value) === '{type.' + name + '.weight}') ?? '';
|
|
646
|
+
const textStyle = el('select', { class: 'pv-in' }, el('option', { value: '' }, 'Choose a text style…'),
|
|
647
|
+
...names.map((name) => el('option', { value: name }, name + ' · ' + idx.get('type.' + name + '.size')?.light)));
|
|
648
|
+
textStyle.value = current;
|
|
649
|
+
textStyle.addEventListener('change', () => {
|
|
650
|
+
if (!textStyle.value) return;
|
|
651
|
+
const name = textStyle.value, declarations = declsAt(spec, scope, sel.part, sel.state, true);
|
|
652
|
+
for (const [prop, token] of Object.entries({ 'font-family': 'family', 'font-size': 'size', 'font-weight': 'weight', 'line-height': 'lineHeight', 'letter-spacing': 'letterSpacing', 'text-transform': 'transform' })) declarations[prop] = '{type.' + name + '.' + token + '}';
|
|
653
|
+
commit(); renderInspector();
|
|
654
|
+
});
|
|
655
|
+
content.append(inspectorField('Text style', textStyle, 'pv-text-style'),
|
|
656
|
+
el('p', { class: 'pv-inspect-hint' }, 'Sets the font, size, weight and text spacing together.'));
|
|
657
|
+
}
|
|
658
|
+
for (const prop of list) content.append(fieldRow(prop, eff[prop], scope, ownDeclarations[prop], write, remove));
|
|
659
|
+
const add = el('select', { class: 'pv-in pv-add-prop', 'aria-label': 'Add ' + group.toLowerCase() + ' property' },
|
|
660
|
+
el('option', { value: '' }, '+ Add property'), ...props.filter((prop) => !eff[prop]).map((prop) => el('option', { value: prop }, propertyLabel(prop))));
|
|
661
|
+
add.addEventListener('change', () => {
|
|
662
|
+
if (!add.value) return;
|
|
663
|
+
const kind = kindOf(add.value), first = OPTIONS[kind]?.()[0]?.v ?? ENUMS[add.value]?.[0] ?? '';
|
|
664
|
+
write(add.value, first); renderInspector();
|
|
665
|
+
});
|
|
666
|
+
content.append(add); details.append(content); groups.append(details);
|
|
667
|
+
}
|
|
668
|
+
const advanced = el('details', { class: 'pv-group pv-group--adv', 'data-group': 'Advanced', open: openGroups.Advanced ?? false }, el('summary', {}, 'Advanced'));
|
|
669
|
+
advanced.append(el('p', { class: 'pv-inspect-hint' }, 'Edit the component definition, examples and rules for agents.'), renderAdvanced(spec));
|
|
670
|
+
groups.append(advanced); wrap.append(groups);
|
|
671
|
+
return wrap;
|
|
672
|
+
}
|
|
673
|
+
function fieldRow(prop, cur, scope, ownValue, write, remove) {
|
|
674
|
+
const kind = kindOf(prop), label = propertyLabel(prop);
|
|
675
|
+
const inherited = ownValue === undefined;
|
|
676
|
+
const value = ownValue ?? cur.value;
|
|
677
|
+
const row = el('div', { class: 'pv-prop-row', 'data-property': prop, 'data-inherited': inherited ? '1' : null });
|
|
678
|
+
const id = 'pv-property-' + prop;
|
|
679
|
+
const source = el('span', { class: 'pv-src', hidden: !inherited, title: 'From ' + cur.label + (cur.state ? ' · ' + cur.state : '') }, 'Inherited');
|
|
680
|
+
const action = scope === 'base' && !sel.state ? 'Remove' : 'Reset';
|
|
681
|
+
const reset = el('button', { type: 'button', class: 'pv-prop-reset', hidden: inherited, 'aria-label': action + ' ' + label.toLowerCase(), title: action + ' this property in the selected scope', onclick: () => remove(prop) }, action);
|
|
682
|
+
row.append(el('div', { class: 'pv-prop-row__head' }, el('label', { class: 'pv-prop-row__name', for: id, title: prop }, label), source, reset));
|
|
683
|
+
const previewNote = el('p', { class: 'pv-property-note', hidden: true });
|
|
684
|
+
const updatePreviewNote = (written) => {
|
|
685
|
+
const current = effective(specOf(sel.slug), sel.part, sel.ctx, sel.state)[prop];
|
|
686
|
+
previewNote.hidden = written === undefined || !current || current.value === written;
|
|
687
|
+
if (!previewNote.hidden) {
|
|
688
|
+
const layer = layers(specOf(sel.slug), sel.ctx).find((entry) => entry.scope === current.scope);
|
|
689
|
+
previewNote.textContent = 'Preview uses ' + lit(current.value) + ' from ' + scopeLabel(layer) + (current.state ? ' · ' + humanLabel(current.state) : '') + '.';
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const onChange = (next) => { write(prop, next); source.hidden = true; reset.hidden = false; row.removeAttribute('data-inherited'); updatePreviewNote(next); };
|
|
693
|
+
const controls = el('div', { class: 'pv-prop-row__control' });
|
|
694
|
+
if (kind === 'enum') {
|
|
695
|
+
const control = el('select', { id, class: 'pv-in' }, ...ENUMS[prop].map((value) => el('option', { value }, humanLabel(value))),
|
|
696
|
+
...(ENUMS[prop].includes(value) ? [] : [el('option', { value }, value)]));
|
|
697
|
+
control.value = value; control.addEventListener('change', () => onChange(control.value)); controls.append(control);
|
|
698
|
+
} else if (kind === 'any') {
|
|
699
|
+
const control = valueInput(value, onChange, { color: false });
|
|
700
|
+
control.querySelector('input').id = id; controls.append(control);
|
|
701
|
+
} else {
|
|
702
|
+
const opts = OPTIONS[kind]();
|
|
703
|
+
if (kind === 'size') opts.unshift({ v: '0', label: '0' });
|
|
704
|
+
const token = idx.get(value.slice(1, -1));
|
|
705
|
+
if (token && !opts.some((option) => option.v === value)) opts.unshift({ v: value, label: value.slice(1, -1) + ' · ' + lit(value) });
|
|
706
|
+
const has = opts.some((option) => option.v === value);
|
|
707
|
+
const select = el('select', { id, class: 'pv-in' }, ...opts.map((option) => el('option', { value: option.v }, option.label)), el('option', { value: '__custom' }, 'Custom value…'));
|
|
708
|
+
select.value = has ? value : '__custom';
|
|
709
|
+
const custom = valueInput(value, onChange, { color: kind === 'color', label: 'Custom ' + label.toLowerCase() + ' color' });
|
|
710
|
+
custom.querySelector('input').setAttribute('aria-label', 'Custom ' + label.toLowerCase());
|
|
711
|
+
const customRow = el('div', { class: 'pv-custom-value', hidden: has }, custom);
|
|
712
|
+
let preview = kind === 'color' ? swatch(value) : null;
|
|
713
|
+
const selected = el('div', { class: 'pv-val' }, preview, select);
|
|
714
|
+
select.addEventListener('change', () => {
|
|
715
|
+
customRow.hidden = select.value !== '__custom';
|
|
716
|
+
if (select.value === '__custom') { custom.querySelector('input').focus(); return; }
|
|
717
|
+
custom.querySelector('input').value = select.value;
|
|
718
|
+
onChange(select.value);
|
|
719
|
+
if (preview) { const next = swatch(select.value); preview.replaceWith(next); preview = next; }
|
|
720
|
+
});
|
|
721
|
+
controls.append(selected, customRow);
|
|
722
|
+
}
|
|
723
|
+
updatePreviewNote(ownValue); row.append(controls, previewNote);
|
|
724
|
+
return row;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---- advanced (data editing)
|
|
728
|
+
function renderAdvanced(spec) {
|
|
729
|
+
const box = el('div');
|
|
730
|
+
const commit = () => { touch('component', spec.slug); rebuildComponent(spec.slug); };
|
|
731
|
+
box.append(el('label', { class: 'pv-field' }, 'Description', textArea(spec.description, (v) => { spec.description = v; commit(); }, 2)));
|
|
732
|
+
box.append(el('label', { class: 'pv-field' }, 'When to use', textArea(spec.usage, (v) => { spec.usage = v; commit(); }, 3)));
|
|
733
|
+
const props = el('details', { class: 'pv-group' }, el('summary', {}, 'Props (data attributes)'));
|
|
734
|
+
for (const [k, pr] of Object.entries(spec.props)) {
|
|
735
|
+
const chips = el('div', { class: 'pv-chips' });
|
|
736
|
+
const renderChips = () => {
|
|
737
|
+
chips.innerHTML = '';
|
|
738
|
+
for (const v of pr.values) chips.append(el('span', { class: 'pv-chip', 'data-default': v === pr.default ? '1' : null }, v, el('button', { class: 'pv-x', title: 'remove value', onclick: () => { if (pr.values.length <= 1) return; pr.values = pr.values.filter((x) => x !== v); if (pr.default === v) pr.default = pr.values[0]; delete spec.variants?.[k]?.[v]; commit(); renderInspector(); } }, '×')));
|
|
739
|
+
const add = el('input', { type: 'text', placeholder: '+ value ⏎', class: 'pv-in pv-in--sm' });
|
|
740
|
+
add.addEventListener('keydown', (e) => { if (e.key === 'Enter' && add.value.trim()) { const v = add.value.trim(); if (!pr.values.includes(v)) { pr.values.push(v); ((spec.variants ??= {})[k] ??= {})[v] = { root: {} }; commit(); renderInspector(); } } });
|
|
741
|
+
chips.append(add);
|
|
742
|
+
};
|
|
743
|
+
renderChips();
|
|
744
|
+
const def = el('select', { class: 'pv-in pv-in--sm' }, ...pr.values.map((v) => el('option', { value: v }, v)));
|
|
745
|
+
def.value = pr.default;
|
|
746
|
+
def.addEventListener('change', () => { pr.default = def.value; commit(); renderChips(); });
|
|
747
|
+
props.append(el('div', { class: 'pv-prop' }, el('div', { class: 'pv-prop__head' }, el('code', {}, `data-${k}`), el('span', { class: 'pv-small pv-muted' }, 'default'), def), chips, textArea(pr.description, (v) => { pr.description = v; commit(); }, 2)));
|
|
748
|
+
}
|
|
749
|
+
box.append(props);
|
|
750
|
+
const states = el('details', { class: 'pv-group' }, el('summary', {}, 'States'));
|
|
751
|
+
for (const [k, st] of Object.entries(spec.states)) states.append(el('div', { class: 'pv-prop' }, el('div', { class: 'pv-prop__head' }, el('code', {}, k)), el('label', { class: 'pv-field' }, 'selector', el('input', { type: 'text', value: st.selector, class: 'pv-in', spellcheck: 'false', oninput: (e) => { st.selector = e.target.value; commit(); } })), textArea(st.description, (v) => { st.description = v; commit(); }, 2)));
|
|
752
|
+
box.append(states);
|
|
753
|
+
box.append(el('details', { class: 'pv-group' }, el('summary', {}, 'Extra CSS'), textArea(spec.extraCss ?? '', (v) => { spec.extraCss = v; commit(); }, 8)));
|
|
754
|
+
const exs = el('details', { class: 'pv-group' }, el('summary', {}, `Examples (${spec.examples.length})`));
|
|
755
|
+
spec.examples.forEach((ex, i) => exs.append(el('div', { class: 'pv-prop' }, el('input', { type: 'text', value: ex.title, class: 'pv-in', oninput: (e) => { ex.title = e.target.value; touch('component', spec.slug); rerenderExample(spec.slug, 'example', i); } }), textArea(ex.html, (v) => { ex.html = v; touch('component', spec.slug); rerenderExample(spec.slug, 'example', i); }, 6))));
|
|
756
|
+
exs.append(el('button', { class: 'pv-add', onclick: () => { spec.examples.push({ title: 'New example', html: spec.examples[0].html }); commit(); status('added — Save to render new examples'); } }, '+ example'));
|
|
757
|
+
box.append(exs);
|
|
758
|
+
if (spec.recipes?.length) {
|
|
759
|
+
const rec = el('details', { class: 'pv-group' }, el('summary', {}, `Recipes (${spec.recipes.length})`));
|
|
760
|
+
spec.recipes.forEach((ex, i) => rec.append(el('div', { class: 'pv-prop' }, el('input', { type: 'text', value: ex.title, class: 'pv-in', oninput: (e) => { ex.title = e.target.value; touch('component', spec.slug); rerenderExample(spec.slug, 'recipe', i); } }), textArea(ex.html, (v) => { ex.html = v; touch('component', spec.slug); rerenderExample(spec.slug, 'recipe', i); }, 6))));
|
|
761
|
+
box.append(rec);
|
|
762
|
+
}
|
|
763
|
+
box.append(el('details', { class: 'pv-group' }, el('summary', {}, 'Rules (one per line)'), textArea(spec.rules.join('\n'), (v) => { spec.rules = v.split('\n').filter(Boolean); commit(); }, 8)));
|
|
764
|
+
box.append(el('details', { class: 'pv-group' }, el('summary', {}, 'Accessibility (one per line)'), textArea(spec.a11y.join('\n'), (v) => { spec.a11y = v.split('\n').filter(Boolean); commit(); }, 5)));
|
|
765
|
+
const an = el('details', { class: 'pv-group' }, el('summary', {}, 'Anatomy'));
|
|
766
|
+
for (const a of spec.anatomy) an.append(el('div', { class: 'pv-prop' }, el('div', { class: 'pv-prop__head' }, el('code', {}, a.part), el('input', { type: 'text', value: a.element, class: 'pv-in pv-in--sm', oninput: (e) => { a.element = e.target.value; commit(); } })), textArea(a.description, (v) => { a.description = v; commit(); }, 2)));
|
|
767
|
+
const newPart = el('input', { type: 'text', placeholder: '+ part name ⏎', class: 'pv-in' });
|
|
768
|
+
newPart.addEventListener('keydown', (e) => { if (e.key === 'Enter' && newPart.value.trim()) { spec.anatomy.push({ part: newPart.value.trim(), element: 'div', description: '' }); commit(); renderInspector(); } });
|
|
769
|
+
an.append(newPart);
|
|
770
|
+
box.append(an);
|
|
771
|
+
return box;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ================================================================ Direction tab
|
|
775
|
+
function renderDirection() {
|
|
776
|
+
const box = el('div');
|
|
777
|
+
const d = S.meta.direction;
|
|
778
|
+
box.append(el('label', { class: 'pv-field' }, 'Name', el('input', { type: 'text', value: S.meta.name, class: 'pv-in', oninput: (e) => { S.meta.name = e.target.value; touch('meta'); } })));
|
|
779
|
+
box.append(el('label', { class: 'pv-field' }, 'Summary', textArea(d.summary, (v) => { d.summary = v; touch('meta'); }, 4)));
|
|
780
|
+
box.append(el('label', { class: 'pv-field' }, 'Principles (one per line)', textArea(d.principles.join('\n'), (v) => { d.principles = v.split('\n').filter(Boolean); touch('meta'); }, 8)));
|
|
781
|
+
box.append(el('label', { class: 'pv-field' }, 'Never (one per line)', textArea(d.never.join('\n'), (v) => { d.never = v.split('\n').filter(Boolean); touch('meta'); }, 10)));
|
|
782
|
+
box.append(el('label', { class: 'pv-field' }, 'Icons note', textArea(S.meta.icons.note, (v) => { S.meta.icons.note = v; touch('meta'); }, 3)));
|
|
783
|
+
box.append(el('p', { class: 'pv-small pv-muted' }, 'Direction text feeds DESIGN.md §1 and every agent file. Save to regenerate them.'));
|
|
784
|
+
return box;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// section title → open the component in the inspector
|
|
788
|
+
// Product examples keep their own links and forms in exported HTML, but they
|
|
789
|
+
// must not navigate or submit the Studio document during a demonstration.
|
|
790
|
+
const inExample = (node) => node.closest('.pv-example__stage, .pv-matrix td, .pv-frame');
|
|
791
|
+
$('.pv-main').addEventListener('click', (event) => {
|
|
792
|
+
if (!inExample(event.target) || !event.target.closest('a[href]')) return;
|
|
793
|
+
event.preventDefault();
|
|
794
|
+
if (!app.hasAttribute('data-editor')) status('Example link · you are still in the Studio');
|
|
795
|
+
});
|
|
796
|
+
$('.pv-main').addEventListener('submit', (event) => {
|
|
797
|
+
if (!inExample(event.target)) return;
|
|
798
|
+
event.preventDefault(); status('Example form · no data was submitted');
|
|
799
|
+
});
|
|
800
|
+
for (const sec of publicCatalog ? [] : $$('.pv-section[data-slug]')) {
|
|
801
|
+
sec.querySelector('.pv-h2')?.addEventListener('click', () => {
|
|
802
|
+
const first = visibleExample(sec.dataset.slug);
|
|
803
|
+
if (first) selectElement(first); else { setEditor(true); panel.dataset.tab = 'Inspect'; renderPanel(); }
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
const search = $('#pv-search');
|
|
807
|
+
const navToggle = $('#pv-nav-toggle');
|
|
808
|
+
if (navToggle) {
|
|
809
|
+
const collapse = () => { $('.pv-nav').removeAttribute('data-expanded'); navToggle.setAttribute('aria-expanded', 'false'); };
|
|
810
|
+
navToggle.addEventListener('click', () => { const open = $('.pv-nav').toggleAttribute('data-expanded'); navToggle.setAttribute('aria-expanded', String(open)); });
|
|
811
|
+
for (const link of $$('.pv-nav a')) link.addEventListener('click', collapse);
|
|
812
|
+
$('.pv-nav').addEventListener('keydown', (e) => { if (e.key === 'Escape') { collapse(); navToggle.focus(); } });
|
|
813
|
+
}
|
|
814
|
+
const catalogLinks = $$('.pv-nav-group a[href^="#"]');
|
|
815
|
+
let groupsBeforeSearch = null;
|
|
816
|
+
if (search) search.addEventListener('input', () => {
|
|
817
|
+
const query = search.value.trim().toLowerCase();
|
|
818
|
+
const groups = $$('.pv-nav-group');
|
|
819
|
+
if (query && !groupsBeforeSearch) groupsBeforeSearch = new Map(groups.map((group) => [group, group.open]));
|
|
820
|
+
for (const link of catalogLinks) link.hidden = !!query && !link.textContent.toLowerCase().includes(query);
|
|
821
|
+
for (const group of groups) {
|
|
822
|
+
group.hidden = !$$('a', group).some((link) => !link.hidden);
|
|
823
|
+
if (query) group.open = !group.hidden;
|
|
824
|
+
else if (groupsBeforeSearch) group.open = groupsBeforeSearch.get(group) ?? false;
|
|
825
|
+
}
|
|
826
|
+
if (!query) groupsBeforeSearch = null;
|
|
827
|
+
for (const heading of $$('.pv-nav__group')) {
|
|
828
|
+
let sibling = heading.nextElementSibling, any = false;
|
|
829
|
+
while (sibling && !sibling.classList.contains('pv-nav__group')) { if (sibling.classList.contains('pv-nav-group') && !sibling.hidden) any = true; sibling = sibling.nextElementSibling; }
|
|
830
|
+
heading.hidden = !!query && !any;
|
|
831
|
+
}
|
|
832
|
+
$('#pv-nav-empty').hidden = catalogLinks.some((link) => !link.hidden);
|
|
833
|
+
});
|
|
834
|
+
const crumb = $('#pv-crumb');
|
|
835
|
+
function activeSection(id, reveal = false) {
|
|
836
|
+
const link = catalogLinks.find((item) => item.getAttribute('href') === '#' + id);
|
|
837
|
+
if (!link) return;
|
|
838
|
+
for (const item of catalogLinks) { if (item === link) item.setAttribute('aria-current', 'location'); else item.removeAttribute('aria-current'); }
|
|
839
|
+
const heading = document.getElementById(id)?.querySelector('.pv-h2');
|
|
840
|
+
if (crumb) crumb.textContent = heading?.firstChild?.textContent.trim() || link.textContent;
|
|
841
|
+
if (reveal) {
|
|
842
|
+
link.closest('.pv-nav-group').open = true;
|
|
843
|
+
if (matchMedia('(min-width: 901px)').matches) {
|
|
844
|
+
const nav = $('.pv-nav'), bounds = nav.getBoundingClientRect(), item = link.getBoundingClientRect();
|
|
845
|
+
if (item.top < bounds.top || item.bottom > bounds.bottom) nav.scrollTop += item.top - bounds.top - nav.clientHeight / 2;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const followHash = () => activeSection(location.hash.slice(1) || 'f-overview', true);
|
|
850
|
+
addEventListener('hashchange', followHash); followHash();
|
|
851
|
+
if (crumb && 'IntersectionObserver' in window) {
|
|
852
|
+
const io = new IntersectionObserver((entries) => { for (const entry of entries) if (entry.isIntersecting) activeSection(entry.target.id); }, { rootMargin: '-10% 0px -80% 0px' });
|
|
853
|
+
for (const sec of $$('.pv-section')) io.observe(sec);
|
|
854
|
+
}
|
|
855
|
+
// Product breakpoints belong to the preview canvas. The engine already emits
|
|
856
|
+
// matching container queries; browser-width media rules would make a desktop
|
|
857
|
+
// canvas switch to mobile when the Studio window itself becomes narrow.
|
|
858
|
+
const previewStyleSources = new WeakMap();
|
|
859
|
+
function preparePreviewStyle(node) {
|
|
860
|
+
if (!node.sheet) return;
|
|
861
|
+
const previous = previewStyleSources.get(node);
|
|
862
|
+
const firstRule = node.sheet.cssRules[0];
|
|
863
|
+
if (previous?.source === node.textContent && previous.firstRule === firstRule) return;
|
|
864
|
+
previewStyleSources.set(node, { source: node.textContent, firstRule });
|
|
865
|
+
const visit = (rules) => {
|
|
866
|
+
for (const rule of rules) {
|
|
867
|
+
if (rule.type === CSSRule.MEDIA_RULE && /^\((?:max|min)-width:\s*[^)]+\)$/.test(rule.conditionText)) rule.media.mediaText = 'not all';
|
|
868
|
+
if (rule.style) for (const property of [...rule.style]) {
|
|
869
|
+
const value = rule.style.getPropertyValue(property);
|
|
870
|
+
if (!/\d(?:[dsl])?v[wh]\b/.test(value)) continue;
|
|
871
|
+
const bounded = value.replace(/(-?(?:\d+(?:\.\d+)?|\.\d+))(?:[dsl])?v([wh])\b/g, (_, amount, axis) => `calc(var(--pv-viewport-${axis === 'w' ? 'width' : 'height'}, 100v${axis}) * ${Number(amount) / 100})`);
|
|
872
|
+
rule.style.setProperty(property, bounded, rule.style.getPropertyPriority(property));
|
|
873
|
+
}
|
|
874
|
+
if (rule.cssRules) visit(rule.cssRules);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
visit(node.sheet.cssRules);
|
|
878
|
+
}
|
|
879
|
+
const previewStyleObserver = new MutationObserver((records) => {
|
|
880
|
+
for (const node of new Set(records.map((record) => record.target.nodeType === Node.TEXT_NODE ? record.target.parentElement : record.target))) preparePreviewStyle(node);
|
|
881
|
+
});
|
|
882
|
+
for (const node of $$('#cn-css-base, #cn-css-patterns, style[data-component]')) {
|
|
883
|
+
preparePreviewStyle(node);
|
|
884
|
+
previewStyleObserver.observe(node, { childList: true, characterData: true, subtree: true });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function fitPreview(canvas) {
|
|
888
|
+
const mode = canvas.closest('[data-example-set]').dataset.previewZoom || 'fit';
|
|
889
|
+
canvas.dataset.previewZoom = mode;
|
|
890
|
+
if (!canvas.clientWidth) return;
|
|
891
|
+
const frame = $('.pv-frame', canvas);
|
|
892
|
+
const bounds = $('.pv-canvas__bounds', canvas);
|
|
893
|
+
const padding = getComputedStyle(canvas);
|
|
894
|
+
const available = canvas.clientWidth - parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight);
|
|
895
|
+
const width = frame.offsetWidth, height = frame.offsetHeight;
|
|
896
|
+
const fitScale = Math.min(1, Math.max(0, available) / width);
|
|
897
|
+
const scale = mode === 'actual' ? 1 : fitScale;
|
|
898
|
+
frame.style.setProperty('--pv-preview-scale', String(scale));
|
|
899
|
+
bounds.style.width = `${width * scale}px`;
|
|
900
|
+
bounds.style.height = `${height * scale}px`;
|
|
901
|
+
canvas.style.justifyContent = mode === 'actual' && width > available ? 'flex-start' : 'center';
|
|
902
|
+
if (mode === 'actual') canvas.style.height = `${height * fitScale + parseFloat(padding.paddingTop) + parseFloat(padding.paddingBottom)}px`;
|
|
903
|
+
else canvas.style.removeProperty('height');
|
|
904
|
+
$('[data-preview-metrics]', canvas.closest('.pv-pattern, .pv-example')).textContent = `${width} × ${height} · ${mode === 'actual' ? '100%' : `Fit ${Math.round(scale * 100)}%`}`;
|
|
905
|
+
}
|
|
906
|
+
const previewCanvases = $$('[data-preview-canvas]');
|
|
907
|
+
if ('ResizeObserver' in window) {
|
|
908
|
+
const observer = new ResizeObserver((entries) => { for (const entry of entries) fitPreview(entry.target); });
|
|
909
|
+
for (const canvas of previewCanvases) observer.observe(canvas);
|
|
910
|
+
}
|
|
911
|
+
for (const canvas of previewCanvases) fitPreview(canvas);
|
|
912
|
+
window.addEventListener('resize', () => { for (const canvas of previewCanvases) fitPreview(canvas); });
|
|
913
|
+
for (const picker of $$('[data-example-picker]')) picker.addEventListener('change', () => {
|
|
914
|
+
const examples = document.getElementById(picker.getAttribute('aria-controls'));
|
|
915
|
+
if (examples.querySelector('[data-pv-selected]')) clearSelection();
|
|
916
|
+
for (const option of $$(':scope > [data-example-option]', examples)) option.hidden = option.dataset.exampleOption !== picker.value;
|
|
917
|
+
for (const canvas of $$('[data-preview-canvas]', examples)) fitPreview(canvas);
|
|
918
|
+
});
|
|
919
|
+
for (const button of $$('[data-viewport]')) button.addEventListener('click', () => {
|
|
920
|
+
const examples = button.closest('[data-example-set]');
|
|
921
|
+
for (const frame of $$('.pv-frame[data-vp]', examples)) frame.dataset.vp = button.dataset.viewport;
|
|
922
|
+
for (const control of $$('[data-viewport]', examples)) control.setAttribute('aria-pressed', String(control.dataset.viewport === button.dataset.viewport));
|
|
923
|
+
for (const canvas of $$('[data-preview-canvas]', examples)) fitPreview(canvas);
|
|
924
|
+
});
|
|
925
|
+
for (const button of $$('button[data-preview-zoom]')) button.addEventListener('click', () => {
|
|
926
|
+
const examples = button.closest('[data-example-set]');
|
|
927
|
+
const mode = button.dataset.previewZoom;
|
|
928
|
+
examples.dataset.previewZoom = mode;
|
|
929
|
+
for (const control of $$('button[data-preview-zoom]', examples)) {
|
|
930
|
+
const label = mode === 'actual' ? 'Fit preview to available width' : 'Preview at 100%';
|
|
931
|
+
control.dataset.previewZoom = mode === 'actual' ? 'fit' : 'actual';
|
|
932
|
+
control.textContent = mode === 'actual' ? 'Fit' : '100%';
|
|
933
|
+
control.setAttribute('aria-label', label); control.title = label;
|
|
934
|
+
}
|
|
935
|
+
for (const canvas of $$('[data-preview-canvas]', examples)) {
|
|
936
|
+
fitPreview(canvas);
|
|
937
|
+
if (mode === 'fit') { canvas.scrollLeft = 0; canvas.scrollTop = 0; }
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
})();
|