@motion-proto/live-tokens 0.1.0 → 0.3.1
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/README.md +160 -21
- package/dist-plugin/index.cjs +823 -336
- package/dist-plugin/index.d.cts +9 -7
- package/dist-plugin/index.d.ts +9 -7
- package/dist-plugin/index.js +822 -335
- package/package.json +51 -23
- package/src/assets/newspaper.webp +0 -0
- package/src/assets/offering.webp +0 -0
- package/src/component-editor/BadgeEditor.svelte +170 -0
- package/src/component-editor/CalloutEditor.svelte +103 -0
- package/src/component-editor/CardEditor.svelte +184 -0
- package/src/component-editor/CollapsibleSectionEditor.svelte +167 -0
- package/src/component-editor/CornerBadgeEditor.svelte +207 -0
- package/src/component-editor/DialogEditor.svelte +172 -0
- package/src/component-editor/ImageEditor.svelte +72 -0
- package/src/component-editor/InlineEditActionsEditor.svelte +83 -0
- package/src/component-editor/NotificationEditor.svelte +160 -0
- package/src/component-editor/ProgressBarEditor.svelte +124 -0
- package/src/component-editor/RadioButtonEditor.svelte +140 -0
- package/src/component-editor/SectionDividerEditor.svelte +263 -0
- package/src/component-editor/SegmentedControlEditor.svelte +154 -0
- package/src/component-editor/StandardButtonsEditor.svelte +178 -0
- package/src/component-editor/TabBarEditor.svelte +137 -0
- package/src/component-editor/TableEditor.svelte +128 -0
- package/src/component-editor/TooltipEditor.svelte +122 -0
- package/src/component-editor/editorTokens.test.ts +93 -0
- package/src/component-editor/groupKeySlots.test.ts +67 -0
- package/src/component-editor/groupKeySnapshot.test.ts +52 -0
- package/src/component-editor/index.ts +5 -0
- package/src/component-editor/registry.ts +246 -0
- package/src/component-editor/scaffolding/AngleDial.svelte +185 -0
- package/src/component-editor/scaffolding/ComponentEditorBase.svelte +96 -0
- package/src/component-editor/scaffolding/ComponentFileManager.svelte +682 -0
- package/src/component-editor/scaffolding/ComponentFileMenu.svelte +312 -0
- package/src/component-editor/scaffolding/ComponentsTab.svelte +69 -0
- package/src/component-editor/scaffolding/CopyFromMenu.svelte +246 -0
- package/src/component-editor/scaffolding/DemoHeader.svelte +21 -0
- package/src/component-editor/scaffolding/DividerEditor.svelte +81 -0
- package/src/component-editor/scaffolding/FieldsetWrapper.svelte +46 -0
- package/src/component-editor/scaffolding/GradientCard.svelte +291 -0
- package/src/component-editor/scaffolding/LinkageChart.svelte +297 -0
- package/src/component-editor/scaffolding/LinkedBlock.svelte +418 -0
- package/src/component-editor/scaffolding/NonStylableConfig.svelte +57 -0
- package/src/component-editor/scaffolding/SaveAsDialog.svelte +177 -0
- package/src/component-editor/scaffolding/ShadowBackdrop.svelte +25 -0
- package/src/component-editor/scaffolding/ShadowBackdropControls.svelte +56 -0
- package/src/component-editor/scaffolding/StateBlock.svelte +115 -0
- package/src/component-editor/scaffolding/TokenLayout.svelte +511 -0
- package/src/component-editor/scaffolding/TypeEditor.svelte +82 -0
- package/src/component-editor/scaffolding/VariantGroup.svelte +277 -0
- package/src/component-editor/scaffolding/buildTypeGroupTokens.ts +97 -0
- package/src/component-editor/scaffolding/componentSectionType.ts +8 -0
- package/src/component-editor/scaffolding/componentSources.ts +9 -0
- package/src/component-editor/scaffolding/defaultSections.ts +16 -0
- package/src/component-editor/scaffolding/editorContext.ts +44 -0
- package/src/component-editor/scaffolding/linkedBlock.ts +226 -0
- package/src/component-editor/scaffolding/siblings.ts +33 -0
- package/src/component-editor/scaffolding/types.ts +39 -0
- package/src/components/Badge.svelte +231 -42
- package/src/components/Button.svelte +324 -124
- package/src/components/Callout.svelte +145 -0
- package/src/components/Card.svelte +123 -25
- package/src/components/CollapsibleSection.svelte +213 -35
- package/src/components/CornerBadge.svelte +224 -0
- package/src/components/Dialog.svelte +137 -114
- package/src/components/Image.svelte +43 -0
- package/src/components/InlineEditActions.svelte +74 -14
- package/src/components/Notification.svelte +184 -163
- package/src/components/ProgressBar.svelte +216 -22
- package/src/components/RadioButton.svelte +110 -40
- package/src/components/SectionDivider.svelte +428 -74
- package/src/components/SegmentedControl.svelte +203 -0
- package/src/components/TabBar.svelte +146 -21
- package/src/components/Table.svelte +102 -0
- package/src/components/Tooltip.svelte +45 -19
- package/src/components/types.ts +51 -0
- package/src/data/google-fonts.json +75 -0
- package/src/lib/ColumnsOverlay.svelte +20 -7
- package/src/lib/LiveEditorOverlay.svelte +265 -82
- package/src/lib/columnsOverlay.ts +21 -17
- package/src/lib/componentConfig.test.ts +204 -0
- package/src/lib/componentConfigKeys.ts +19 -0
- package/src/lib/componentConfigService.ts +88 -0
- package/src/lib/copyPopover.ts +30 -0
- package/src/lib/cssVarSync.ts +59 -7
- package/src/lib/editorConfigStore.ts +0 -10
- package/src/lib/editorCore.ts +402 -0
- package/src/lib/editorKeybindings.ts +52 -0
- package/src/lib/editorPersistence.ts +106 -0
- package/src/lib/editorRenderer.ts +74 -0
- package/src/lib/editorStore.test.ts +328 -0
- package/src/lib/editorStore.ts +412 -0
- package/src/lib/editorTypes.ts +100 -0
- package/src/lib/editorViewStore.ts +55 -0
- package/src/lib/files/versionedFileResource.ts +140 -0
- package/src/lib/fontLoader.ts +130 -0
- package/src/lib/fontMigration.ts +140 -0
- package/src/lib/fontParse.ts +168 -0
- package/src/lib/index.ts +48 -31
- package/src/lib/lazyConfig.test.ts +54 -0
- package/src/lib/migrations/2026-04-24-component-prefix-and-suffix-renames.ts +64 -0
- package/src/lib/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +71 -0
- package/src/lib/migrations/2026-04-27-segmentedcontrol-disabled-flatten.ts +43 -0
- package/src/lib/migrations/2026-05-08-collapsiblesection-frame-and-cleanup.ts +68 -0
- package/src/lib/migrations/2026-05-08-collapsiblesection-variant-namespace.ts +35 -0
- package/src/lib/migrations/2026-05-10-sectiondivider-gradient-stops.ts +50 -0
- package/src/lib/migrations/2026-05-13-primary-to-brand.ts +90 -0
- package/src/lib/migrations/index.ts +93 -0
- package/src/lib/migrations/migrations.test.ts +341 -0
- package/src/lib/navLinkTypes.ts +1 -0
- package/src/lib/overlayState.ts +3 -0
- package/src/lib/paletteDerivation.ts +300 -0
- package/src/lib/parentRouteStore.ts +42 -0
- package/src/lib/parsers/globalRootBlock.ts +32 -0
- package/src/lib/presetService.ts +94 -0
- package/src/lib/router.ts +49 -0
- package/src/lib/scrollSection.ts +45 -0
- package/src/lib/slices/columns.ts +59 -0
- package/src/lib/slices/components.ts +362 -0
- package/src/lib/slices/domainVars.ts +15 -0
- package/src/lib/slices/fonts.ts +30 -0
- package/src/lib/slices/gradients.ts +153 -0
- package/src/lib/slices/overlays.ts +132 -0
- package/src/lib/slices/palettes.ts +26 -0
- package/src/lib/slices/shadows.ts +123 -0
- package/src/lib/storage.ts +88 -0
- package/src/lib/themeInit.ts +74 -0
- package/src/lib/themeService.ts +101 -0
- package/src/lib/themeTypes.ts +146 -0
- package/src/lib/tokenRegistry.ts +148 -0
- package/src/pages/ComponentEditorPage.svelte +384 -0
- package/src/pages/ComponentEditorPage.svelte.d.ts +2 -0
- package/src/pages/Editor.svelte +98 -0
- package/src/pages/Editor.svelte.d.ts +2 -0
- package/src/pages/EditorShell.svelte +348 -0
- package/src/styles/_padding.scss +34 -0
- package/src/styles/fonts/Fraunces/Fraunces-italic-latin-ext.woff2 +0 -0
- package/src/styles/fonts/Fraunces/Fraunces-italic-latin.woff2 +0 -0
- package/src/styles/fonts/Fraunces/Fraunces-roman-latin-ext.woff2 +0 -0
- package/src/styles/fonts/Fraunces/Fraunces-roman-latin.woff2 +0 -0
- package/src/styles/fonts/Manrope/Manrope-latin-ext.woff2 +0 -0
- package/src/styles/fonts/Manrope/Manrope-latin.woff2 +0 -0
- package/src/styles/fonts.css +22 -10
- package/src/styles/form-controls.css +14 -16
- package/src/styles/tokens.css +1322 -0
- package/src/styles/ui-editor.css +126 -0
- package/src/{showcase → ui}/BezierCurveEditor.svelte +14 -14
- package/src/{showcase → ui}/ColorEditPanel.svelte +42 -36
- package/src/ui/EditorViewSwitcher.svelte +180 -0
- package/src/ui/FontStackEditor.svelte +360 -0
- package/src/ui/GradientEditor.svelte +461 -0
- package/src/ui/GradientStopPicker.svelte +74 -0
- package/src/ui/PaletteEditor.svelte +1590 -0
- package/src/ui/PaletteEditor.test.ts +108 -0
- package/src/ui/PresetFileManager.svelte +567 -0
- package/src/ui/ProjectFontsSection.svelte +645 -0
- package/src/{showcase → ui}/SurfacesTab.svelte +39 -41
- package/src/{showcase → ui}/TextTab.svelte +27 -29
- package/src/{showcase/TokenFileManager.svelte → ui/ThemeFileManager.svelte} +196 -112
- package/src/ui/Toggle.svelte +108 -0
- package/src/ui/UICopyPopover.svelte +78 -0
- package/src/{showcase/EditorDialog.svelte → ui/UIDialog.svelte} +66 -25
- package/src/ui/UIFontFamilySelector.svelte +309 -0
- package/src/ui/UIFontSizeSelector.svelte +165 -0
- package/src/ui/UIFontWeightSelector.svelte +52 -0
- package/src/ui/UILineHeightSelector.svelte +47 -0
- package/src/ui/UILinkToggle.svelte +60 -0
- package/src/ui/UIOptionItem.svelte +74 -0
- package/src/ui/UIOptionList.svelte +27 -0
- package/src/ui/UIPaddingSelector.svelte +661 -0
- package/src/ui/UIPaletteSelector.svelte +1084 -0
- package/src/ui/UIRadio.svelte +72 -0
- package/src/ui/UIRadioGroup.svelte +59 -0
- package/src/ui/UIRelinkConfirmPopover.svelte +235 -0
- package/src/ui/UITokenSelector.svelte +509 -0
- package/src/ui/UIVariantSelector.svelte +145 -0
- package/src/ui/VariablesTab.svelte +252 -0
- package/src/ui/index.ts +31 -0
- package/src/ui/keepInViewport.ts +84 -0
- package/src/ui/palette/GradientStopEditor.svelte +482 -0
- package/src/ui/palette/OverridesPanel.svelte +526 -0
- package/src/ui/palette/PaletteBase.svelte +165 -0
- package/src/ui/palette/ScaleCurveEditor.svelte +38 -0
- package/src/ui/palette/paletteEditorState.ts +89 -0
- package/src/ui/sections/ColumnsSection.svelte +273 -0
- package/src/ui/sections/GradientsSection.svelte +147 -0
- package/src/ui/sections/OverlaysSection.svelte +670 -0
- package/src/ui/sections/ShadowsSection.svelte +1250 -0
- package/src/ui/sections/TokenScaleTable.svelte +332 -0
- package/src/ui/sections/tokenScales.ts +81 -0
- package/src/ui/variantScales.ts +108 -0
- package/src/components/DetailNav.svelte +0 -78
- package/src/components/Toggle.svelte +0 -86
- package/src/lib/pageSource.ts +0 -6
- package/src/lib/tokenInit.ts +0 -29
- package/src/lib/tokenService.ts +0 -144
- package/src/lib/tokenTypes.ts +0 -45
- package/src/pages/Admin.svelte +0 -100
- package/src/pages/ShowcasePage.svelte +0 -146
- package/src/showcase/BackupBrowser.svelte +0 -617
- package/src/showcase/ComponentsTab.svelte +0 -107
- package/src/showcase/PaletteEditor.svelte +0 -2579
- package/src/showcase/PaletteSelector.svelte +0 -627
- package/src/showcase/TokenMap.svelte +0 -54
- package/src/showcase/VariablesTab.svelte +0 -2657
- package/src/showcase/VisualsTab.svelte +0 -233
- package/src/showcase/demos/BadgeDemo.svelte +0 -58
- package/src/showcase/demos/CardDemo.svelte +0 -52
- package/src/showcase/demos/ChoiceButtonsDemo.svelte +0 -194
- package/src/showcase/demos/CollapsibleSectionDemo.svelte +0 -56
- package/src/showcase/demos/DialogDemo.svelte +0 -42
- package/src/showcase/demos/InlineEditActionsDemo.svelte +0 -27
- package/src/showcase/demos/NotificationDemo.svelte +0 -149
- package/src/showcase/demos/ProgressBarDemo.svelte +0 -56
- package/src/showcase/demos/RadioButtonDemo.svelte +0 -58
- package/src/showcase/demos/SectionDividerDemo.svelte +0 -79
- package/src/showcase/demos/StandardButtonsDemo.svelte +0 -457
- package/src/showcase/demos/TabBarDemo.svelte +0 -60
- package/src/showcase/demos/TooltipDemo.svelte +0 -54
- package/src/showcase/editor.css +0 -93
- package/src/showcase/index.ts +0 -17
- package/src/styles/fonts/Domine/Domine-VariableFont_wght.ttf +0 -0
- package/src/styles/fonts/Domine/OFL.txt +0 -97
- package/src/styles/fonts/Domine/README.txt +0 -66
- /package/src/{showcase → ui}/curveEngine.ts +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { writable, type Readable } from 'svelte/store';
|
|
2
|
+
import { route } from './router';
|
|
3
|
+
|
|
4
|
+
// Parent-window route, viewable from inside the editor iframe. Mirrors the
|
|
5
|
+
// host page's route via postMessage from LiveEditorOverlay. When not in an
|
|
6
|
+
// iframe, falls through to the local route store.
|
|
7
|
+
|
|
8
|
+
const MSG_TYPE = 'lt:parent-route';
|
|
9
|
+
|
|
10
|
+
function buildStore(): Readable<string> {
|
|
11
|
+
if (typeof window === 'undefined') return route;
|
|
12
|
+
if (window.parent === window) return route;
|
|
13
|
+
|
|
14
|
+
const inner = writable<string>('/');
|
|
15
|
+
|
|
16
|
+
// Best-effort initial read (same-origin in dev).
|
|
17
|
+
try {
|
|
18
|
+
inner.set(window.parent.location.pathname || '/');
|
|
19
|
+
} catch {
|
|
20
|
+
// cross-origin or sandboxed — wait for the first postMessage instead
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
window.addEventListener('message', (e: MessageEvent) => {
|
|
24
|
+
const data = e.data;
|
|
25
|
+
if (data && typeof data === 'object' && data.type === MSG_TYPE && typeof data.path === 'string') {
|
|
26
|
+
inner.set(data.path);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return inner;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const parentRoute: Readable<string> = buildStore();
|
|
34
|
+
|
|
35
|
+
export function postParentRoute(target: Window | null | undefined, path: string): void {
|
|
36
|
+
if (!target) return;
|
|
37
|
+
try {
|
|
38
|
+
target.postMessage({ type: MSG_TYPE, path }, '*');
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore — iframe may not be ready yet
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parser for `:global(:root) { ... }` declaration blocks in Svelte
|
|
3
|
+
* component sources.
|
|
4
|
+
*
|
|
5
|
+
* Both the browser-side `tokenRegistry` (Vite `?raw` import-time scrape) and
|
|
6
|
+
* the Node-side `themeFileApi` plugin (filesystem read on dev/HMR) need to
|
|
7
|
+
* recover Layer-2 component-token declarations from `.svelte` files. They had
|
|
8
|
+
* been carrying separate copies of the same regex; this is the canonical
|
|
9
|
+
* implementation that both targets import.
|
|
10
|
+
*
|
|
11
|
+
* Assumes no nested braces inside the block — Layer-2 token blocks are flat
|
|
12
|
+
* declaration lists (`--name: value;`), not nested rulesets. If a future token
|
|
13
|
+
* block ever needs `@media`/`@supports` nesting, this regex changes once here.
|
|
14
|
+
*
|
|
15
|
+
* Pure (no DOM, no fs) so the tsup ESM+CJS build of the vite plugin can import
|
|
16
|
+
* it safely.
|
|
17
|
+
*
|
|
18
|
+
* Consumer-facing implication: components must keep their `:global(:root)`
|
|
19
|
+
* blocks as flat literal declarations — no `@each` / SCSS loops — otherwise
|
|
20
|
+
* this parser sees zero tokens and the editor's alias picker / file-manager
|
|
21
|
+
* UI is empty for that component. See `src/components/Notification.svelte:86–92`
|
|
22
|
+
* for the documented reason a four-variant block stays expanded.
|
|
23
|
+
*/
|
|
24
|
+
export function extractGlobalRootBody(source: string): string {
|
|
25
|
+
const re = /:global\(:root\)\s*\{([^}]*)\}/g;
|
|
26
|
+
const bodies: string[] = [];
|
|
27
|
+
let m: RegExpExecArray | null;
|
|
28
|
+
while ((m = re.exec(source)) !== null) {
|
|
29
|
+
bodies.push(m[1]);
|
|
30
|
+
}
|
|
31
|
+
return bodies.join('\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Preset, PresetMeta, Theme, ComponentConfig } from './themeTypes';
|
|
2
|
+
import { versionedFileResource } from './files/versionedFileResource';
|
|
3
|
+
import { listComponents } from './componentConfigService';
|
|
4
|
+
import { getActiveTheme } from './themeService';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* REST client for preset (bundle) manifest files. Each preset file references
|
|
8
|
+
* a theme file basename + a per-component config file basename. Loading a
|
|
9
|
+
* preset flips the corresponding `_active.json` pointers via `applyPreset`,
|
|
10
|
+
* leaving the underlying theme + component-config files as the source of
|
|
11
|
+
* truth.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the lifecycle of `themeService.ts` and `componentConfigService.ts`
|
|
14
|
+
* but adds one custom route — `PUT /api/presets/:name/apply` — that the
|
|
15
|
+
* server uses to atomically validate every reference and flip every pointer
|
|
16
|
+
* in one shot.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const presetsResource = versionedFileResource<Preset, PresetMeta, never>({
|
|
20
|
+
baseUrl: '/api/presets',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const listPresets = async (): Promise<PresetMeta[]> => {
|
|
24
|
+
const data = await presetsResource.list();
|
|
25
|
+
return data.files;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const loadPreset = (fileName: string): Promise<Preset> =>
|
|
29
|
+
presetsResource.load(fileName);
|
|
30
|
+
export const savePreset = (fileName: string, data: Preset): Promise<void> =>
|
|
31
|
+
presetsResource.save(fileName, data);
|
|
32
|
+
export const deletePreset = (fileName: string): Promise<void> =>
|
|
33
|
+
presetsResource.remove(fileName);
|
|
34
|
+
export const getActivePreset = (): Promise<Preset | null> => presetsResource.getActive();
|
|
35
|
+
export const setActivePreset = (fileName: string): Promise<void> =>
|
|
36
|
+
presetsResource.setActive(fileName);
|
|
37
|
+
|
|
38
|
+
export interface ApplyPresetResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
preset: Preset;
|
|
41
|
+
theme: Theme;
|
|
42
|
+
componentConfigs: Record<string, ComponentConfig>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Server-side atomic apply: validate every referenced file exists, flip the
|
|
47
|
+
* theme + each component's `_active.json` pointer, and return the resolved
|
|
48
|
+
* theme + component configs in one payload.
|
|
49
|
+
*
|
|
50
|
+
* The client typically follows this with a full page reload — loading a
|
|
51
|
+
* preset is a "blow up the world" action and preserving editor session
|
|
52
|
+
* state across that boundary is low value.
|
|
53
|
+
*/
|
|
54
|
+
export async function applyPreset(fileName: string): Promise<ApplyPresetResult> {
|
|
55
|
+
const res = await fetch(`/api/presets/${encodeURIComponent(fileName)}/apply`, {
|
|
56
|
+
method: 'PUT',
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const err = await res.json().catch(() => ({ error: 'Apply failed' }));
|
|
60
|
+
throw new Error(err.error || 'Apply failed');
|
|
61
|
+
}
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a manifest from the *currently active files on disk* (not in-memory
|
|
67
|
+
* editor state) and persist it. Dirty editor state isn't a file yet, so it's
|
|
68
|
+
* not part of any preset until the user saves it. Callers should warn the
|
|
69
|
+
* user via the UI if `$dirty` is true before invoking this.
|
|
70
|
+
*/
|
|
71
|
+
export async function captureCurrentAsPreset(
|
|
72
|
+
fileName: string,
|
|
73
|
+
displayName: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const activeTheme = await getActiveTheme();
|
|
76
|
+
if (!activeTheme || !activeTheme._fileName) {
|
|
77
|
+
throw new Error('No active theme on disk to capture');
|
|
78
|
+
}
|
|
79
|
+
const components = await listComponents();
|
|
80
|
+
const componentConfigs: Record<string, string> = {};
|
|
81
|
+
for (const c of components) {
|
|
82
|
+
componentConfigs[c.name] = c.activeFile || 'default';
|
|
83
|
+
}
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
const manifest: Preset = {
|
|
86
|
+
name: displayName,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
theme: activeTheme._fileName,
|
|
90
|
+
componentConfigs,
|
|
91
|
+
};
|
|
92
|
+
await savePreset(fileName, manifest);
|
|
93
|
+
await setActivePreset(fileName);
|
|
94
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
import { storageKey } from './editorConfig';
|
|
3
|
+
|
|
4
|
+
function prevKey(): string {
|
|
5
|
+
return storageKey('prev-route');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function rememberPrev(current: string) {
|
|
9
|
+
if (current === '/editor') return;
|
|
10
|
+
try { sessionStorage.setItem(prevKey(), current); } catch { /* ignore */ }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const route = writable<string>('/');
|
|
14
|
+
|
|
15
|
+
let initialised = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Idempotent host hook — call once during boot to seed the route store from
|
|
19
|
+
* the current location and wire popstate handling. Module import no longer
|
|
20
|
+
* touches `window`, so SSR / test harnesses can import without crashing.
|
|
21
|
+
*/
|
|
22
|
+
export function init(): void {
|
|
23
|
+
if (initialised) return;
|
|
24
|
+
initialised = true;
|
|
25
|
+
if (typeof window === 'undefined') return;
|
|
26
|
+
const initial = window.location.pathname || '/';
|
|
27
|
+
rememberPrev(initial);
|
|
28
|
+
route.set(initial);
|
|
29
|
+
window.addEventListener('popstate', () => {
|
|
30
|
+
route.set(window.location.pathname || '/');
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Push a new history entry and update the route store. Produces exactly one
|
|
36
|
+
* `route` store update per call — the popstate listener installed in `init()`
|
|
37
|
+
* fires only on browser back/forward, not on synthetic dispatch.
|
|
38
|
+
*/
|
|
39
|
+
export function navigate(path: string) {
|
|
40
|
+
const [pathname] = path.split('#');
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
rememberPrev(window.location.pathname || '/');
|
|
43
|
+
history.pushState(null, '', path);
|
|
44
|
+
if (!path.includes('#')) {
|
|
45
|
+
window.scrollTo(0, 0);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
route.set(pathname);
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const PRELUDE_PX = 128;
|
|
2
|
+
const DURATION_MS = 400;
|
|
3
|
+
const SPEED_PX_PER_MS = PRELUDE_PX / DURATION_MS;
|
|
4
|
+
|
|
5
|
+
function easeOutCubic(t: number): number {
|
|
6
|
+
return 1 - Math.pow(1 - t, 3);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findScrollParent(el: HTMLElement): HTMLElement {
|
|
10
|
+
let node: HTMLElement | null = el.parentElement;
|
|
11
|
+
while (node) {
|
|
12
|
+
const style = getComputedStyle(node);
|
|
13
|
+
if (/(auto|scroll|overlay)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) {
|
|
14
|
+
return node;
|
|
15
|
+
}
|
|
16
|
+
node = node.parentElement;
|
|
17
|
+
}
|
|
18
|
+
return document.scrollingElement as HTMLElement ?? document.documentElement;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function scrollSectionIntoView(target: HTMLElement, scroller?: HTMLElement) {
|
|
22
|
+
const scrollEl = scroller ?? findScrollParent(target);
|
|
23
|
+
const max = scrollEl.scrollHeight - scrollEl.clientHeight;
|
|
24
|
+
const targetTop = Math.max(0, Math.min(max, target.offsetTop));
|
|
25
|
+
const start = scrollEl.scrollTop;
|
|
26
|
+
const delta = targetTop - start;
|
|
27
|
+
if (delta === 0) return;
|
|
28
|
+
|
|
29
|
+
const direction = delta > 0 ? 1 : -1;
|
|
30
|
+
const distance = Math.abs(delta);
|
|
31
|
+
const animatedDistance = Math.min(distance, PRELUDE_PX);
|
|
32
|
+
const duration = animatedDistance / SPEED_PX_PER_MS;
|
|
33
|
+
|
|
34
|
+
const animFrom = targetTop - animatedDistance * direction;
|
|
35
|
+
scrollEl.scrollTop = animFrom;
|
|
36
|
+
|
|
37
|
+
const t0 = performance.now();
|
|
38
|
+
function step(now: number) {
|
|
39
|
+
const t = Math.min(1, (now - t0) / duration);
|
|
40
|
+
scrollEl.scrollTop = animFrom + animatedDistance * direction * easeOutCubic(t);
|
|
41
|
+
if (t < 1) requestAnimationFrame(step);
|
|
42
|
+
else scrollEl.scrollTop = targetTop;
|
|
43
|
+
}
|
|
44
|
+
requestAnimationFrame(step);
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Columns slice — fixed-shape (count/maxWidth/gutter/margin) state, derived to
|
|
3
|
+
* four CSS vars. Values default to a 12-column 1440px grid; while state matches
|
|
4
|
+
* the default we leave tokens.css in charge so the `clamp()` in
|
|
5
|
+
* `--columns-gutter` survives until the editor overrides it.
|
|
6
|
+
*/
|
|
7
|
+
import type { ColumnsState } from '../editorTypes';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_COLUMNS: ColumnsState = { count: 12, maxWidth: 1440, gutter: 16, margin: 0 };
|
|
10
|
+
|
|
11
|
+
export const COLUMN_VAR_NAMES = ['--columns-count', '--columns-max-width', '--columns-gutter', '--columns-margin'] as const;
|
|
12
|
+
|
|
13
|
+
export function columnsToVars(c: ColumnsState): Record<string, string> {
|
|
14
|
+
return {
|
|
15
|
+
'--columns-count': String(c.count),
|
|
16
|
+
'--columns-max-width': `${c.maxWidth}px`,
|
|
17
|
+
'--columns-gutter': `${c.gutter}px`,
|
|
18
|
+
'--columns-margin': `${c.margin}px`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Only emit column CSS vars once the user has actually modified columns.
|
|
24
|
+
* While columns match the default, we leave tokens.css in charge — which
|
|
25
|
+
* preserves the `clamp()` in `--columns-gutter` until the editor overrides it.
|
|
26
|
+
*/
|
|
27
|
+
export function columnsEqualsDefault(c: ColumnsState): boolean {
|
|
28
|
+
return c.count === DEFAULT_COLUMNS.count
|
|
29
|
+
&& c.maxWidth === DEFAULT_COLUMNS.maxWidth
|
|
30
|
+
&& c.gutter === DEFAULT_COLUMNS.gutter
|
|
31
|
+
&& c.margin === DEFAULT_COLUMNS.margin;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseColumnVars(vars: Record<string, string>): Partial<ColumnsState> {
|
|
35
|
+
const out: Partial<ColumnsState> = {};
|
|
36
|
+
const count = parseInt(vars['--columns-count'] ?? '', 10);
|
|
37
|
+
if (Number.isFinite(count) && count > 0) out.count = count;
|
|
38
|
+
const maxWidth = parseFloat(vars['--columns-max-width'] ?? '');
|
|
39
|
+
if (Number.isFinite(maxWidth)) out.maxWidth = Math.round(maxWidth);
|
|
40
|
+
const gutter = parseFloat(vars['--columns-gutter'] ?? '');
|
|
41
|
+
if (Number.isFinite(gutter)) out.gutter = Math.round(gutter);
|
|
42
|
+
const margin = parseFloat(vars['--columns-margin'] ?? '');
|
|
43
|
+
if (Number.isFinite(margin)) out.margin = Math.round(margin);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loader: route the relevant entries from a freshly-loaded theme's vars bag
|
|
49
|
+
* into `next.columns` and remove them from the bag so derivation stays
|
|
50
|
+
* single-source. Mutates `next` and `rawVars` in place.
|
|
51
|
+
*/
|
|
52
|
+
export function loadColumnsFromVars(
|
|
53
|
+
next: import('../editorTypes').EditorState,
|
|
54
|
+
rawVars: Record<string, string>,
|
|
55
|
+
): void {
|
|
56
|
+
const overrides = parseColumnVars(rawVars);
|
|
57
|
+
next.columns = { ...DEFAULT_COLUMNS, ...overrides };
|
|
58
|
+
for (const name of COLUMN_VAR_NAMES) delete rawVars[name];
|
|
59
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Components slice — per-component `{ activeFile, aliases, config, unlinked? }`.
|
|
3
|
+
*
|
|
4
|
+
* `aliases` are the typed component-token → semantic-token map (each entry is
|
|
5
|
+
* a `CssVarRef` discriminated union: `{ kind: 'token', name }` or
|
|
6
|
+
* `{ kind: 'literal', value }`). The renderer emits each alias entry as
|
|
7
|
+
* `var(<name>)` for tokens or as the raw literal for literals.
|
|
8
|
+
*
|
|
9
|
+
* `config` carries literal-valued knobs that don't follow the alias →
|
|
10
|
+
* `var(...)` shape (e.g. `--dialog-confirm-variant: 'primary'`). The set of
|
|
11
|
+
* keys routed to config lives in `componentConfigKeys`.
|
|
12
|
+
*
|
|
13
|
+
* Themes and components are orthogonal: `loadFromFile` preserves
|
|
14
|
+
* `state.components`.
|
|
15
|
+
*
|
|
16
|
+
* Sharing semantics: tokens with the same groupKey (registered explicitly
|
|
17
|
+
* via `registerComponentSchema`) are siblings. Each individual sibling can opt out of the group: `unlinked`
|
|
18
|
+
* lists the specific variable names that have detached, leaving the rest
|
|
19
|
+
* of the group intact. `setComponentAliasLinked` writes the alias to every
|
|
20
|
+
* sibling that is *currently linked* (i.e. not in `unlinked`) plus the
|
|
21
|
+
* target itself, re-joining it to the group; `unlinkComponentProperty`
|
|
22
|
+
* detaches just that one variable. The `unlinked` list persists across
|
|
23
|
+
* theme loads.
|
|
24
|
+
*
|
|
25
|
+
* Dirty tracking: `savedComponents` is the on-disk snapshot baseline (one
|
|
26
|
+
* stringified `{aliases, config}` bag per component). `componentDirty`
|
|
27
|
+
* re-evaluates on any state change or baseline bump. The baseline is set
|
|
28
|
+
* explicitly by the load path (`setSavedComponentBaseline`) — `loadComponentActive`
|
|
29
|
+
* and `seedComponentsFromApi` in editorStore call into this from their migration
|
|
30
|
+
* pipeline.
|
|
31
|
+
*/
|
|
32
|
+
import { writable, derived, get, type Readable } from 'svelte/store';
|
|
33
|
+
import type { CssVarRef, EditorState } from '../editorTypes';
|
|
34
|
+
import { store, mutate } from '../editorCore';
|
|
35
|
+
|
|
36
|
+
const EMPTY_COMPONENT_BASELINE = JSON.stringify({ aliases: {}, config: {} });
|
|
37
|
+
|
|
38
|
+
export function componentBaseline(slice: { aliases: Record<string, CssVarRef>; config: Record<string, unknown> }): string {
|
|
39
|
+
return JSON.stringify({ aliases: slice.aliases, config: slice.config });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function componentsToVars(components: EditorState['components']): Record<string, string> {
|
|
43
|
+
const out: Record<string, string> = {};
|
|
44
|
+
for (const slice of Object.values(components)) {
|
|
45
|
+
for (const [varName, ref] of Object.entries(slice.aliases)) {
|
|
46
|
+
out[varName] = ref.kind === 'token' ? `var(${ref.name})` : ref.value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getComponentOwnedVarNames(state: EditorState): string[] {
|
|
53
|
+
const names: string[] = [];
|
|
54
|
+
for (const slice of Object.values(state.components)) {
|
|
55
|
+
for (const name of Object.keys(slice.aliases)) names.push(name);
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loader: themes and components are orthogonal — component aliases live
|
|
62
|
+
* in their own files, not the theme JSON. Preserve the current slice
|
|
63
|
+
* across theme loads and strip any component-owned vars that may have
|
|
64
|
+
* leaked into the theme's cssVariables bag. Mutates `next` and `rawVars`
|
|
65
|
+
* in place.
|
|
66
|
+
*/
|
|
67
|
+
export function loadComponentsFromVars(
|
|
68
|
+
next: EditorState,
|
|
69
|
+
rawVars: Record<string, string>,
|
|
70
|
+
): void {
|
|
71
|
+
next.components = structuredClone(get(store).components);
|
|
72
|
+
for (const name of getComponentOwnedVarNames(next)) delete rawVars[name];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Module-private baseline for per-component dirty detection. Parallels
|
|
76
|
+
// `savedAtIndex` + `historyTick` for the global flag; `componentSavedTick`
|
|
77
|
+
// drives re-derivation when the baseline changes.
|
|
78
|
+
const savedComponents: Record<string, string> = {};
|
|
79
|
+
const componentSavedTick = writable(0);
|
|
80
|
+
function bumpComponentSavedTick(): void { componentSavedTick.update((n) => n + 1); }
|
|
81
|
+
|
|
82
|
+
export const componentDirty: Readable<Record<string, boolean>> = derived(
|
|
83
|
+
[store, componentSavedTick],
|
|
84
|
+
([$state]) => {
|
|
85
|
+
const out: Record<string, boolean> = {};
|
|
86
|
+
for (const [comp, slice] of Object.entries($state.components)) {
|
|
87
|
+
out[comp] = componentBaseline(slice) !== (savedComponents[comp] ?? EMPTY_COMPONENT_BASELINE);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export function setComponentAlias(component: string, varName: string, ref: CssVarRef): void {
|
|
94
|
+
mutate(`set alias ${component}/${varName}`, (s) => {
|
|
95
|
+
const existing = s.components[component];
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.aliases[varName] = ref;
|
|
98
|
+
} else {
|
|
99
|
+
s.components[component] = { activeFile: 'default', aliases: { [varName]: ref }, config: {} };
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function clearComponentAlias(component: string, varName: string): void {
|
|
105
|
+
mutate(`clear alias ${component}/${varName}`, (s) => {
|
|
106
|
+
const slice = s.components[component];
|
|
107
|
+
if (!slice) return;
|
|
108
|
+
delete slice.aliases[varName];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function setComponentConfig(component: string, key: string, value: unknown): void {
|
|
113
|
+
mutate(`set config ${component}/${key}`, (s) => {
|
|
114
|
+
const existing = s.components[component];
|
|
115
|
+
if (existing) {
|
|
116
|
+
existing.config[key] = value;
|
|
117
|
+
} else {
|
|
118
|
+
s.components[component] = { activeFile: 'default', aliases: {}, config: { [key]: value } };
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function clearComponentConfig(component: string, key: string): void {
|
|
124
|
+
mutate(`clear config ${component}/${key}`, (s) => {
|
|
125
|
+
const slice = s.components[component];
|
|
126
|
+
if (!slice) return;
|
|
127
|
+
delete slice.config[key];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function componentVarPrefix(component: string): string {
|
|
132
|
+
return `--${component}-`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Per-component groupKey schema registered by editor modules. Maps each
|
|
137
|
+
* declared variable to its groupKey (the explicit sibling-set identifier).
|
|
138
|
+
* Tokens with the same groupKey are siblings; tokens not in the schema fall
|
|
139
|
+
* back to last-dash property inference so unmigrated editors keep working.
|
|
140
|
+
*/
|
|
141
|
+
const componentSchemas: Record<string, Map<string, string>> = {};
|
|
142
|
+
/** Inverse of `componentSchemas`: groupKey → declared variables sharing it.
|
|
143
|
+
* This is the linkage topology declared by the editor, independent of which
|
|
144
|
+
* aliases the user happens to have saved. */
|
|
145
|
+
const componentSchemaSiblings: Record<string, Map<string, string[]>> = {};
|
|
146
|
+
|
|
147
|
+
const TYPOGRAPHY_PROP_SUFFIXES = ['font-family', 'font-size', 'font-weight', 'line-height'] as const;
|
|
148
|
+
|
|
149
|
+
/** Pull the slot identifier off a typography variable name, e.g.
|
|
150
|
+
* `--card-primary-title-font-family` → `'title'`. Returns null for
|
|
151
|
+
* non-typography vars, where the slot concept doesn't apply. */
|
|
152
|
+
function typographySlotOf(varName: string): string | null {
|
|
153
|
+
for (const suffix of TYPOGRAPHY_PROP_SUFFIXES) {
|
|
154
|
+
if (!varName.endsWith('-' + suffix)) continue;
|
|
155
|
+
const head = varName.slice(0, -(suffix.length + 1));
|
|
156
|
+
const lastDash = head.lastIndexOf('-');
|
|
157
|
+
if (lastDash < 0) return null;
|
|
158
|
+
return head.slice(lastDash + 1);
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Register a component's token → groupKey mapping. Editors call this at
|
|
165
|
+
* module load (top of `<script>`) so sibling lookups can prefer explicit
|
|
166
|
+
* groupKeys over name-derived inference. Re-registration overwrites prior
|
|
167
|
+
* entries for the same component.
|
|
168
|
+
*
|
|
169
|
+
* Warns when a single groupKey covers typography variables whose name-derived
|
|
170
|
+
* slots differ — e.g. `groupKey: 'font-family'` covering both
|
|
171
|
+
* `--card-primary-title-font-family` and `--card-primary-body-font-family`.
|
|
172
|
+
* Slot prefixes (`title-font-family` vs `body-font-family`) are required so
|
|
173
|
+
* each slot is independently linkable. See `src/styles/CONVENTIONS.md`.
|
|
174
|
+
*/
|
|
175
|
+
export function registerComponentSchema(
|
|
176
|
+
component: string,
|
|
177
|
+
tokens: ReadonlyArray<{ variable: string; groupKey?: string }>,
|
|
178
|
+
): void {
|
|
179
|
+
const map = new Map<string, string>();
|
|
180
|
+
const siblings = new Map<string, string[]>();
|
|
181
|
+
for (const t of tokens) {
|
|
182
|
+
if (!t.groupKey) continue;
|
|
183
|
+
map.set(t.variable, t.groupKey);
|
|
184
|
+
const list = siblings.get(t.groupKey) ?? [];
|
|
185
|
+
list.push(t.variable);
|
|
186
|
+
siblings.set(t.groupKey, list);
|
|
187
|
+
}
|
|
188
|
+
componentSchemas[component] = map;
|
|
189
|
+
componentSchemaSiblings[component] = siblings;
|
|
190
|
+
|
|
191
|
+
for (const [groupKey, vars] of siblings) {
|
|
192
|
+
const slots = new Set<string>();
|
|
193
|
+
for (const v of vars) {
|
|
194
|
+
const slot = typographySlotOf(v);
|
|
195
|
+
if (slot) slots.add(slot);
|
|
196
|
+
}
|
|
197
|
+
if (slots.size > 1) {
|
|
198
|
+
const slotList = [...slots];
|
|
199
|
+
const examples = slotList.map((s) => `"${s}-${groupKey}"`).join(', ');
|
|
200
|
+
console.warn(
|
|
201
|
+
`[registerComponentSchema] component "${component}" groupKey "${groupKey}" links typography variables with distinct slots: ${slotList.join(', ')}. ` +
|
|
202
|
+
`Use slot-prefixed groupKeys (e.g. ${examples}) so each slot is independently linkable.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve a variable's groupKey from the component's registered schema.
|
|
210
|
+
* Returns null when the variable has no declared groupKey (i.e. it isn't a
|
|
211
|
+
* member of any sibling group).
|
|
212
|
+
*/
|
|
213
|
+
function getGroupKey(component: string, varName: string): string | null {
|
|
214
|
+
return componentSchemas[component]?.get(varName) ?? null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Variables that share `varName`'s groupKey, i.e. are declared as one
|
|
219
|
+
* linkage group. The editor's registered schema is the source of truth — the
|
|
220
|
+
* declared topology is reported regardless of which aliases happen to be
|
|
221
|
+
* persisted in the slice, so link UI reflects the editor's intent rather than
|
|
222
|
+
* leaking through the on-disk state. Falls back to a slice scan only when the
|
|
223
|
+
* variable isn't declared (legacy/inferred groupKey).
|
|
224
|
+
*/
|
|
225
|
+
export function getComponentPropertySiblings(component: string, varName: string): string[] {
|
|
226
|
+
const groupKey = getGroupKey(component, varName);
|
|
227
|
+
if (!groupKey) return [];
|
|
228
|
+
const declared = componentSchemaSiblings[component]?.get(groupKey);
|
|
229
|
+
if (declared && declared.length > 0) return declared.slice();
|
|
230
|
+
const slice = get(store).components[component];
|
|
231
|
+
if (!slice) return [];
|
|
232
|
+
const siblings: string[] = [];
|
|
233
|
+
for (const v of Object.keys(slice.aliases)) {
|
|
234
|
+
if (getGroupKey(component, v) === groupKey) siblings.push(v);
|
|
235
|
+
}
|
|
236
|
+
return siblings;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cssVarRefEqual(a: CssVarRef | undefined, b: CssVarRef | undefined): boolean {
|
|
240
|
+
if (!a || !b) return a === b;
|
|
241
|
+
if (a.kind !== b.kind) return false;
|
|
242
|
+
return a.kind === 'token'
|
|
243
|
+
? a.name === (b as { kind: 'token'; name: string }).name
|
|
244
|
+
: a.value === (b as { kind: 'literal'; value: string }).value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** True iff `varName` is not individually opted out, has ≥2 declared siblings,
|
|
248
|
+
* and the linked siblings agree — either all sharing the same explicit alias,
|
|
249
|
+
* or all having no override (linked at the upstream default). */
|
|
250
|
+
export function isComponentPropertyLinked(component: string, varName: string): boolean {
|
|
251
|
+
const slice = get(store).components[component];
|
|
252
|
+
if (slice?.unlinked?.includes(varName)) return false;
|
|
253
|
+
const siblings = getComponentPropertySiblings(component, varName);
|
|
254
|
+
if (siblings.length < 2) return false;
|
|
255
|
+
const unlinkedList = slice?.unlinked ?? [];
|
|
256
|
+
const linkedSiblings = siblings.filter((v) => !unlinkedList.includes(v));
|
|
257
|
+
if (linkedSiblings.length < 2) return false;
|
|
258
|
+
const aliases = slice?.aliases ?? {};
|
|
259
|
+
const first = aliases[linkedSiblings[0]];
|
|
260
|
+
return linkedSiblings.every((v) => cssVarRefEqual(aliases[v], first));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Write `ref` to `varName` and every sibling currently linked (not in `unlinked`),
|
|
264
|
+
* and remove `varName` from the unlinked list so it rejoins the group. */
|
|
265
|
+
export function setComponentAliasLinked(component: string, varName: string, ref: CssVarRef): void {
|
|
266
|
+
const groupKey = getGroupKey(component, varName);
|
|
267
|
+
const siblings = getComponentPropertySiblings(component, varName);
|
|
268
|
+
if (!groupKey || siblings.length === 0) {
|
|
269
|
+
setComponentAlias(component, varName, ref);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
mutate(`link ${component}/${groupKey}`, (s) => {
|
|
273
|
+
const slice = s.components[component] ?? (s.components[component] = { activeFile: 'default', aliases: {}, config: {} });
|
|
274
|
+
const unlinked = (slice.unlinked ?? []).filter((p) => p !== varName);
|
|
275
|
+
slice.aliases[varName] = ref;
|
|
276
|
+
for (const v of siblings) {
|
|
277
|
+
if (v === varName) continue;
|
|
278
|
+
if (!unlinked.includes(v)) slice.aliases[v] = ref;
|
|
279
|
+
}
|
|
280
|
+
if (unlinked.length === 0) delete slice.unlinked;
|
|
281
|
+
else slice.unlinked = unlinked;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Clear `varName` and every sibling currently linked (not in `unlinked`). */
|
|
286
|
+
export function clearComponentAliasLinked(component: string, varName: string): void {
|
|
287
|
+
const groupKey = getGroupKey(component, varName);
|
|
288
|
+
const siblings = getComponentPropertySiblings(component, varName);
|
|
289
|
+
if (!groupKey || siblings.length === 0) {
|
|
290
|
+
clearComponentAlias(component, varName);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
mutate(`clear link ${component}/${groupKey}`, (s) => {
|
|
294
|
+
const slice = s.components[component];
|
|
295
|
+
if (!slice) return;
|
|
296
|
+
const unlinked = slice.unlinked ?? [];
|
|
297
|
+
for (const v of siblings) {
|
|
298
|
+
if (v === varName || !unlinked.includes(v)) delete slice.aliases[v];
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Detach a single property from its sibling group. Other siblings stay linked
|
|
304
|
+
* to each other; only `varName` becomes independently editable. */
|
|
305
|
+
export function unlinkComponentProperty(component: string, varName: string): void {
|
|
306
|
+
const groupKey = getGroupKey(component, varName);
|
|
307
|
+
if (!groupKey) return;
|
|
308
|
+
const siblings = getComponentPropertySiblings(component, varName);
|
|
309
|
+
if (siblings.length < 2) return;
|
|
310
|
+
mutate(`unlink ${component}/${varName}`, (s) => {
|
|
311
|
+
const slice = s.components[component];
|
|
312
|
+
if (!slice) return;
|
|
313
|
+
const unlinked = slice.unlinked ?? [];
|
|
314
|
+
if (!unlinked.includes(varName)) {
|
|
315
|
+
slice.unlinked = [...unlinked, varName];
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Rejoin `varName` to its sibling group as pure metadata: drop it from the
|
|
321
|
+
* `unlinked` list without writing any alias. Use when the group has no
|
|
322
|
+
* overrides yet (linked at the upstream default) and the user just wants to
|
|
323
|
+
* re-engage membership — `setComponentAliasLinked` requires a ref to write,
|
|
324
|
+
* which there is none of in that state. */
|
|
325
|
+
export function relinkComponentProperty(component: string, varName: string): void {
|
|
326
|
+
const slice = get(store).components[component];
|
|
327
|
+
if (!slice?.unlinked?.includes(varName)) return;
|
|
328
|
+
mutate(`relink ${component}/${varName}`, (s) => {
|
|
329
|
+
const next = s.components[component];
|
|
330
|
+
if (!next?.unlinked) return;
|
|
331
|
+
const remaining = next.unlinked.filter((v) => v !== varName);
|
|
332
|
+
if (remaining.length === 0) delete next.unlinked;
|
|
333
|
+
else next.unlinked = remaining;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function markComponentSaved(component: string): void {
|
|
338
|
+
const slice = get(store).components[component];
|
|
339
|
+
if (!slice) return;
|
|
340
|
+
savedComponents[component] = componentBaseline(slice);
|
|
341
|
+
bumpComponentSavedTick();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Set the on-disk baseline for a component without touching the store.
|
|
346
|
+
* Called by `loadComponentActive` / `seedComponentsFromApi` after their
|
|
347
|
+
* migration step so the post-load state reads clean.
|
|
348
|
+
*/
|
|
349
|
+
export function setSavedComponentBaseline(component: string, baseline: string): void {
|
|
350
|
+
savedComponents[component] = baseline;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Notify subscribers that the dirty baseline changed. */
|
|
354
|
+
export function notifyComponentSavedChanged(): void {
|
|
355
|
+
bumpComponentSavedTick();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Test-only: clear the baseline and the dirty signal. */
|
|
359
|
+
export function __resetComponentsForTests(): void {
|
|
360
|
+
for (const k of Object.keys(savedComponents)) delete savedComponents[k];
|
|
361
|
+
bumpComponentSavedTick();
|
|
362
|
+
}
|