@motion-proto/live-tokens 0.3.2 → 0.3.7
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 +1 -1
- package/package.json +4 -2
- package/src/component-editor/scaffolding/GradientCard.svelte +6 -6
- package/src/component-editor/editorTokens.test.ts +0 -93
- package/src/component-editor/groupKeySlots.test.ts +0 -67
- package/src/component-editor/groupKeySnapshot.test.ts +0 -52
- package/src/lib/componentConfig.test.ts +0 -204
- package/src/lib/editorStore.test.ts +0 -328
- package/src/lib/lazyConfig.test.ts +0 -54
- package/src/lib/migrations/migrations.test.ts +0 -341
- package/src/ui/PaletteEditor.test.ts +0 -108
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motion-proto/live-tokens",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design token editor with live CSS variable editing. Svelte 4 + Vite.",
|
|
6
6
|
"keywords": [
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"src/styles/tokens.css",
|
|
36
36
|
"src/data",
|
|
37
37
|
"src/assets",
|
|
38
|
-
"dist-plugin"
|
|
38
|
+
"dist-plugin",
|
|
39
|
+
"!**/*.test.ts",
|
|
40
|
+
"!**/*.spec.ts"
|
|
39
41
|
],
|
|
40
42
|
"exports": {
|
|
41
43
|
".": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* UIPaletteSelector. Reads resolve through `tokenRegistry$` so values
|
|
12
12
|
* authored as `var(--gradient-angle-diagonal)` show their resolved degrees.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { setComponentAlias } from '../../lib/editorStore';
|
|
15
15
|
import { tokenRegistry$ } from '../../lib/tokenRegistry';
|
|
16
16
|
import UIPaletteSelector from '../../ui/UIPaletteSelector.svelte';
|
|
17
17
|
import AngleDial from './AngleDial.svelte';
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
parseNumberFromCss(resolveLiteralWith($tokenRegistry$, stopPositionVar(3)), '%') ?? 100,
|
|
60
60
|
] as [number, number, number];
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}) as [string, string, string];
|
|
62
|
+
// Reference the per-stop CSS var directly so the cascade fills in the
|
|
63
|
+
// component's CSS defaults when the user hasn't overridden a stop. Reading
|
|
64
|
+
// `aliases[...]` alone would miss defaults (no override → `#888`) even
|
|
65
|
+
// though the component is rendering the color via its own `:root` block.
|
|
66
|
+
$: stopColors = ([1, 2, 3] as StopIndex[]).map((i) => `var(${stopColorVar(i)})`) as [string, string, string];
|
|
67
67
|
|
|
68
68
|
// Build the live gradient string from current positions + colors so the
|
|
69
69
|
// ribbon reflects edits even mid-drag (before the component re-renders via
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
3
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
-
import { join, dirname } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { buildTokenRegistry, extractGlobalRootBody } from '../lib/tokenRegistry';
|
|
7
|
-
import { componentRegistryEntries } from './registry';
|
|
8
|
-
|
|
9
|
-
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const TOKENS_CSS = join(TEST_DIR, '..', 'styles', 'tokens.css');
|
|
11
|
-
const COMPONENTS_DIR = join(TEST_DIR, '..', 'components');
|
|
12
|
-
|
|
13
|
-
// Vitest's CSS plugin swallows `?raw` imports for .css files, so we build the
|
|
14
|
-
// registry from an fs-loaded snapshot rather than going through the default
|
|
15
|
-
// module export. Layer-2 tokens now live in each component's <style> block
|
|
16
|
-
// under `:global(:root)`; merge those bodies with tokens.css to match the
|
|
17
|
-
// runtime registry.
|
|
18
|
-
const tokensSource = readFileSync(TOKENS_CSS, 'utf8');
|
|
19
|
-
const componentTokenCss = readdirSync(COMPONENTS_DIR)
|
|
20
|
-
.filter((f) => f.endsWith('.svelte'))
|
|
21
|
-
.map((f) => extractGlobalRootBody(readFileSync(join(COMPONENTS_DIR, f), 'utf8')))
|
|
22
|
-
.join('\n');
|
|
23
|
-
const registry = buildTokenRegistry(tokensSource + '\n' + componentTokenCss);
|
|
24
|
-
|
|
25
|
-
/** Test whether a variable name fits a layer-1 design-token naming pattern. */
|
|
26
|
-
function isLayer1TokenName(name: string): boolean {
|
|
27
|
-
// Color ramps: --color-<palette>-<step> OR semantic color primitives: --color-<name>
|
|
28
|
-
if (/^--color-[a-z]+(-\d{3})?$/.test(name)) return true;
|
|
29
|
-
if (/^--surface-[a-z]+(-[a-z]+)?$/.test(name)) return true;
|
|
30
|
-
if (/^--border-[a-z]+(-[a-z]+)?$/.test(name)) return true;
|
|
31
|
-
if (/^--text-[a-z]+(-[a-z]+)?$/.test(name)) return true;
|
|
32
|
-
if (/^--(radius|space|font|line-height|shadow|ring|transition|overlay|hover|page-bg|border-width|gradient|icon-size|blur|dot-size)(-[a-z0-9]+)*$/.test(name)) return true;
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Drives both describe blocks: one entry per (component, variable) pair pulled
|
|
37
|
-
// from each editor's exported `allTokens`. This is the authoritative surface —
|
|
38
|
-
// includes template-literal-built tokens (Button, SegmentedControl, etc.) and
|
|
39
|
-
// typography colorVariables that the previous source-regex approach silently
|
|
40
|
-
// missed. See registry.ts for how each editor exports its full token list via
|
|
41
|
-
// `<script context="module">`.
|
|
42
|
-
const editorTokenCases: Array<{ editor: string; variable: string }> = [];
|
|
43
|
-
for (const entry of componentRegistryEntries) {
|
|
44
|
-
for (const t of entry.schema) {
|
|
45
|
-
// `hidden: true` tokens are optional override slots (e.g. split-padding's
|
|
46
|
-
// per-side overrides used as `var(--x, fallback)` by the themed-padding
|
|
47
|
-
// mixin) — they don't need a default declaration in tokens.css.
|
|
48
|
-
if ((t as { hidden?: boolean }).hidden) continue;
|
|
49
|
-
editorTokenCases.push({ editor: entry.id, variable: t.variable });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe('design-token architecture', () => {
|
|
54
|
-
it('has editor schemas to inspect', () => {
|
|
55
|
-
expect(editorTokenCases.length).toBeGreaterThan(0);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('every editor-exposed variable resolves to a design token', () => {
|
|
59
|
-
for (const { editor, variable } of editorTokenCases) {
|
|
60
|
-
it(`${editor}: ${variable} resolves via alias chain to a layer-1 token`, () => {
|
|
61
|
-
const chain = registry.resolveAliasChain(variable);
|
|
62
|
-
const terminal = chain[chain.length - 1];
|
|
63
|
-
expect(
|
|
64
|
-
isLayer1TokenName(terminal),
|
|
65
|
-
`${variable} → [${chain.join(' → ')}]; terminal "${terminal}" is not a design-token name. ` +
|
|
66
|
-
`Either rename the target or add a var() alias declaration in tokens.css.`,
|
|
67
|
-
).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Every variable an editor binds must be declared in tokens.css (or a
|
|
73
|
-
// component <style> :global(:root) block) as a var() alias — not a literal,
|
|
74
|
-
// not a raw primitive. Component properties bind to component-scoped Layer-2
|
|
75
|
-
// aliases so editing one component never rebinds a shared primitive used
|
|
76
|
-
// elsewhere.
|
|
77
|
-
describe('every editor follows the component-token pattern', () => {
|
|
78
|
-
for (const { editor, variable } of editorTokenCases) {
|
|
79
|
-
it(`${editor}: ${variable} is a layer-2 component token (declared as var() alias)`, () => {
|
|
80
|
-
const declared = registry.getDeclaredValue(variable);
|
|
81
|
-
expect(
|
|
82
|
-
declared,
|
|
83
|
-
`${variable} is referenced by ${editor} but not declared in tokens.css`,
|
|
84
|
-
).not.toBeNull();
|
|
85
|
-
expect(
|
|
86
|
-
declared!.startsWith('var('),
|
|
87
|
-
`${variable} should be a component-scoped alias declared as var(--primitive); got "${declared}". ` +
|
|
88
|
-
`Component properties must not rebind shared primitives directly.`,
|
|
89
|
-
).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
//
|
|
3
|
-
// Slot-collision invariant for editor schemas.
|
|
4
|
-
//
|
|
5
|
-
// A groupKey declares a sibling set: tokens that share a groupKey are linked
|
|
6
|
-
// peers (`getComponentPropertySiblings`, `isComponentPropertyLinked`,
|
|
7
|
-
// `setComponentAliasLinked`). The codebase convention (src/styles/CONVENTIONS.md)
|
|
8
|
-
// requires shared groupKeys to cross only a VARIANT axis — never a slot/part
|
|
9
|
-
// axis. Crossing slots produces "phantom siblings": the schema reports header
|
|
10
|
-
// and cell as linked peers, but the linked block can't surface them because
|
|
11
|
-
// the editor's `linkableContexts` (correctly) treats them as independent.
|
|
12
|
-
//
|
|
13
|
-
// `registerComponentSchema` already warns at runtime when a typography
|
|
14
|
-
// groupKey covers multiple slots (e.g. one groupKey holding both
|
|
15
|
-
// `--card-...-title-font-family` and `--card-...-body-font-family`). This
|
|
16
|
-
// test asserts the invariant instead of relying on a console warning that
|
|
17
|
-
// nothing fails on.
|
|
18
|
-
//
|
|
19
|
-
// Caught the TableEditor regression where `header` and `cell` typography
|
|
20
|
-
// shared `family`/`size`/`weight`/`height` groupKeys (and `surface`/`padding`
|
|
21
|
-
// shared groupKeys across header/stripe and header/cell respectively). See
|
|
22
|
-
// the commit that introduced this test for context.
|
|
23
|
-
|
|
24
|
-
import { describe, it, expect } from 'vitest';
|
|
25
|
-
import { componentRegistryEntries } from './registry';
|
|
26
|
-
|
|
27
|
-
const TYPOGRAPHY_PROP_SUFFIXES = ['font-family', 'font-size', 'font-weight', 'line-height'] as const;
|
|
28
|
-
|
|
29
|
-
/** Mirror of `typographySlotOf` in `lib/slices/components.ts`. */
|
|
30
|
-
function typographySlotOf(varName: string): string | null {
|
|
31
|
-
for (const suffix of TYPOGRAPHY_PROP_SUFFIXES) {
|
|
32
|
-
if (!varName.endsWith('-' + suffix)) continue;
|
|
33
|
-
const head = varName.slice(0, -(suffix.length + 1));
|
|
34
|
-
const lastDash = head.lastIndexOf('-');
|
|
35
|
-
if (lastDash < 0) return null;
|
|
36
|
-
return head.slice(lastDash + 1);
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('editor groupKey slot invariant', () => {
|
|
42
|
-
for (const entry of componentRegistryEntries) {
|
|
43
|
-
it(`${entry.id}: no typography groupKey straddles multiple slots`, () => {
|
|
44
|
-
const byKey = new Map<string, string[]>();
|
|
45
|
-
for (const t of entry.schema as Array<{ variable: string; groupKey?: string }>) {
|
|
46
|
-
if (!t.groupKey) continue;
|
|
47
|
-
const list = byKey.get(t.groupKey) ?? [];
|
|
48
|
-
list.push(t.variable);
|
|
49
|
-
byKey.set(t.groupKey, list);
|
|
50
|
-
}
|
|
51
|
-
const violations: string[] = [];
|
|
52
|
-
for (const [gk, vars] of byKey) {
|
|
53
|
-
const slots = new Set<string>();
|
|
54
|
-
for (const v of vars) {
|
|
55
|
-
const slot = typographySlotOf(v);
|
|
56
|
-
if (slot) slots.add(slot);
|
|
57
|
-
}
|
|
58
|
-
if (slots.size > 1) {
|
|
59
|
-
violations.push(
|
|
60
|
-
`groupKey "${gk}" spans typography slots {${[...slots].join(', ')}}: ${vars.join(', ')}`,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
expect(violations, violations.join('\n')).toEqual([]);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
});
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Locks the per-component sibling-group topology in place during the migration
|
|
3
|
-
* away from `getGroupKey`'s last-dash fallback.
|
|
4
|
-
*
|
|
5
|
-
* Workflow:
|
|
6
|
-
* 1. Snapshot current behavior: `UPDATE_SNAPSHOT=1 pnpm test groupKeySnapshot`
|
|
7
|
-
* writes `temp/groupkey-snapshot.json` (groupKey → variables, per component).
|
|
8
|
-
* 2. Migrate: add explicit `groupKey` to every token that previously relied on
|
|
9
|
-
* the legacy fallback; delete the fallback branch in `getGroupKey`.
|
|
10
|
-
* 3. Re-run this test (no env var) — must produce the same JSON.
|
|
11
|
-
*
|
|
12
|
-
* Once the migration is complete and verified, delete this file.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, it, expect } from 'vitest';
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import { componentRegistryEntries } from './registry';
|
|
18
|
-
|
|
19
|
-
const SNAPSHOT_PATH = path.resolve(__dirname, '../../temp/groupkey-snapshot.json');
|
|
20
|
-
|
|
21
|
-
/** Mirrors the *post-migration* getGroupKey logic — schema only, no fallback. */
|
|
22
|
-
function effectiveGroupKey(_component: string, _variable: string, explicit: string | undefined): string | null {
|
|
23
|
-
return explicit ?? null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function computeSnapshot(): Record<string, Record<string, string[]>> {
|
|
27
|
-
const out: Record<string, Record<string, string[]>> = {};
|
|
28
|
-
for (const entry of componentRegistryEntries) {
|
|
29
|
-
const groups: Record<string, string[]> = {};
|
|
30
|
-
for (const t of entry.schema as Array<{ variable: string; groupKey?: string }>) {
|
|
31
|
-
const gk = effectiveGroupKey(entry.id, t.variable, t.groupKey);
|
|
32
|
-
if (!gk) continue;
|
|
33
|
-
(groups[gk] ||= []).push(t.variable);
|
|
34
|
-
}
|
|
35
|
-
for (const k of Object.keys(groups)) groups[k].sort();
|
|
36
|
-
out[entry.id] = Object.fromEntries(Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)));
|
|
37
|
-
}
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('groupKey topology snapshot', () => {
|
|
42
|
-
it('matches the committed snapshot', () => {
|
|
43
|
-
const computed = computeSnapshot();
|
|
44
|
-
if (process.env.UPDATE_SNAPSHOT === '1') {
|
|
45
|
-
fs.mkdirSync(path.dirname(SNAPSHOT_PATH), { recursive: true });
|
|
46
|
-
fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(computed, null, 2) + '\n');
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const expected = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, 'utf-8'));
|
|
50
|
-
expect(computed).toEqual(expected);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { get } from 'svelte/store';
|
|
4
|
-
import {
|
|
5
|
-
editorState,
|
|
6
|
-
componentDirty,
|
|
7
|
-
setComponentAlias,
|
|
8
|
-
clearComponentAlias,
|
|
9
|
-
setComponentConfig,
|
|
10
|
-
clearComponentConfig,
|
|
11
|
-
loadComponentActive,
|
|
12
|
-
markComponentSaved,
|
|
13
|
-
seedComponentsFromApi,
|
|
14
|
-
undo,
|
|
15
|
-
redo,
|
|
16
|
-
__resetForTests,
|
|
17
|
-
} from './editorStore';
|
|
18
|
-
|
|
19
|
-
const tokenRef = (name: string) => ({ kind: 'token' as const, name });
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
__resetForTests();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe('component aliases — editor-state round trip', () => {
|
|
26
|
-
it('setComponentAlias → undo restores previous state; redo reapplies', () => {
|
|
27
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-success-high'));
|
|
28
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
29
|
-
|
|
30
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-error-high'));
|
|
31
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-error-high'));
|
|
32
|
-
|
|
33
|
-
undo();
|
|
34
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
35
|
-
|
|
36
|
-
redo();
|
|
37
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-error-high'));
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('clearComponentAlias removes the entry and is undoable', () => {
|
|
41
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-success-high'));
|
|
42
|
-
clearComponentAlias('button', '--button-primary-surface');
|
|
43
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toBeUndefined();
|
|
44
|
-
|
|
45
|
-
undo();
|
|
46
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('setComponentAlias implicitly registers the slice with activeFile "default"', () => {
|
|
50
|
-
setComponentAlias('card', '--card-radius', tokenRef('--radius-lg'));
|
|
51
|
-
expect(get(editorState).components.card.activeFile).toBe('default');
|
|
52
|
-
expect(get(editorState).components.card.aliases['--card-radius']).toEqual(tokenRef('--radius-lg'));
|
|
53
|
-
expect(get(editorState).components.card.config).toEqual({});
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('component config — literal-valued knobs', () => {
|
|
58
|
-
it('setComponentConfig stores the value and is undoable', () => {
|
|
59
|
-
setComponentConfig('dialog', '--dialog-confirm-variant', 'danger');
|
|
60
|
-
expect(get(editorState).components.dialog.config['--dialog-confirm-variant']).toBe('danger');
|
|
61
|
-
|
|
62
|
-
setComponentConfig('dialog', '--dialog-confirm-variant', 'warning');
|
|
63
|
-
expect(get(editorState).components.dialog.config['--dialog-confirm-variant']).toBe('warning');
|
|
64
|
-
|
|
65
|
-
undo();
|
|
66
|
-
expect(get(editorState).components.dialog.config['--dialog-confirm-variant']).toBe('danger');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('clearComponentConfig removes the entry', () => {
|
|
70
|
-
setComponentConfig('dialog', '--dialog-confirm-variant', 'danger');
|
|
71
|
-
clearComponentConfig('dialog', '--dialog-confirm-variant');
|
|
72
|
-
expect(get(editorState).components.dialog.config['--dialog-confirm-variant']).toBeUndefined();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('setComponentConfig implicitly registers the slice with empty aliases', () => {
|
|
76
|
-
setComponentConfig('dialog', '--dialog-cancel-variant', 'outline');
|
|
77
|
-
expect(get(editorState).components.dialog.activeFile).toBe('default');
|
|
78
|
-
expect(get(editorState).components.dialog.aliases).toEqual({});
|
|
79
|
-
expect(get(editorState).components.dialog.config['--dialog-cancel-variant']).toBe('outline');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('componentDirty — per-component scoping', () => {
|
|
84
|
-
it('marks a component dirty only after its aliases diverge from the saved baseline', () => {
|
|
85
|
-
loadComponentActive('button', 'default', { '--button-primary-surface': '--surface-success-high' });
|
|
86
|
-
expect(get(componentDirty).button).toBe(false);
|
|
87
|
-
|
|
88
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-error-high'));
|
|
89
|
-
expect(get(componentDirty).button).toBe(true);
|
|
90
|
-
|
|
91
|
-
markComponentSaved('button');
|
|
92
|
-
expect(get(componentDirty).button).toBe(false);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('marks a component dirty when config diverges from the saved baseline', () => {
|
|
96
|
-
loadComponentActive('dialog', 'default', {}, { '--dialog-confirm-variant': 'primary' });
|
|
97
|
-
expect(get(componentDirty).dialog).toBe(false);
|
|
98
|
-
|
|
99
|
-
setComponentConfig('dialog', '--dialog-confirm-variant', 'danger');
|
|
100
|
-
expect(get(componentDirty).dialog).toBe(true);
|
|
101
|
-
|
|
102
|
-
markComponentSaved('dialog');
|
|
103
|
-
expect(get(componentDirty).dialog).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('editing one component does not dirty another', () => {
|
|
107
|
-
loadComponentActive('button', 'default', { '--button-primary-surface': '--surface-success-high' });
|
|
108
|
-
loadComponentActive('card', 'default', { '--card-radius': '--radius-md' });
|
|
109
|
-
expect(get(componentDirty).button).toBe(false);
|
|
110
|
-
expect(get(componentDirty).card).toBe(false);
|
|
111
|
-
|
|
112
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-error-high'));
|
|
113
|
-
expect(get(componentDirty).button).toBe(true);
|
|
114
|
-
expect(get(componentDirty).card).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('undo after a saved state marks dirty again', () => {
|
|
118
|
-
loadComponentActive('button', 'default', { '--button-primary-surface': '--surface-success-high' });
|
|
119
|
-
setComponentAlias('button', '--button-primary-surface', tokenRef('--surface-error-high'));
|
|
120
|
-
markComponentSaved('button');
|
|
121
|
-
expect(get(componentDirty).button).toBe(false);
|
|
122
|
-
|
|
123
|
-
undo();
|
|
124
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
125
|
-
expect(get(componentDirty).button).toBe(true);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe('loadComponentActive — split-on-load migration', () => {
|
|
130
|
-
it('routes legacy config keys from single-bucket aliases into the config bucket', () => {
|
|
131
|
-
loadComponentActive('dialog', 'default', {
|
|
132
|
-
'--dialog-surface': '--surface-neutral-low',
|
|
133
|
-
'--dialog-confirm-variant': 'danger',
|
|
134
|
-
});
|
|
135
|
-
const slice = get(editorState).components.dialog;
|
|
136
|
-
expect(slice.aliases['--dialog-surface']).toEqual(tokenRef('--surface-neutral-low'));
|
|
137
|
-
expect(slice.aliases['--dialog-confirm-variant']).toBeUndefined();
|
|
138
|
-
expect(slice.config['--dialog-confirm-variant']).toBe('danger');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('keeps CSS-var-valued aliases (e.g. --button-shimmer → --shimmer-on) in the aliases bucket', () => {
|
|
142
|
-
loadComponentActive('button', 'default', {
|
|
143
|
-
'--button-primary-surface': '--surface-success-high',
|
|
144
|
-
'--button-shimmer': '--shimmer-on',
|
|
145
|
-
});
|
|
146
|
-
const slice = get(editorState).components.button;
|
|
147
|
-
expect(slice.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
148
|
-
expect(slice.aliases['--button-shimmer']).toEqual(tokenRef('--shimmer-on'));
|
|
149
|
-
expect(slice.config['--button-shimmer']).toBeUndefined();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('classifies literal alias values as kind "literal"', () => {
|
|
153
|
-
loadComponentActive('myComp', 'default', {
|
|
154
|
-
'--my-comp-token': '--some-token',
|
|
155
|
-
'--my-comp-color': 'rebeccapurple',
|
|
156
|
-
});
|
|
157
|
-
const slice = get(editorState).components.myComp;
|
|
158
|
-
expect(slice.aliases['--my-comp-token']).toEqual(tokenRef('--some-token'));
|
|
159
|
-
expect(slice.aliases['--my-comp-color']).toEqual({ kind: 'literal', value: 'rebeccapurple' });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('explicit config field wins over legacy alias-bucketed value', () => {
|
|
163
|
-
loadComponentActive(
|
|
164
|
-
'dialog',
|
|
165
|
-
'default',
|
|
166
|
-
{ '--dialog-confirm-variant': 'primary' },
|
|
167
|
-
{ '--dialog-confirm-variant': 'danger' },
|
|
168
|
-
);
|
|
169
|
-
expect(get(editorState).components.dialog.config['--dialog-confirm-variant']).toBe('danger');
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe('seedComponentsFromApi — boot-time hydration', () => {
|
|
174
|
-
it('populates state and establishes the clean baseline', () => {
|
|
175
|
-
seedComponentsFromApi({
|
|
176
|
-
button: { activeFile: 'myConfig', aliases: { '--button-primary-surface': '--surface-success-high' } },
|
|
177
|
-
});
|
|
178
|
-
expect(get(editorState).components.button.activeFile).toBe('myConfig');
|
|
179
|
-
expect(get(editorState).components.button.aliases['--button-primary-surface']).toEqual(tokenRef('--surface-success-high'));
|
|
180
|
-
expect(get(componentDirty).button).toBe(false);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('routes config keys when seeding from legacy single-bucket API payload', () => {
|
|
184
|
-
seedComponentsFromApi({
|
|
185
|
-
dialog: {
|
|
186
|
-
activeFile: 'default',
|
|
187
|
-
aliases: { '--dialog-confirm-variant': 'danger', '--dialog-shadow': '--shadow-2xl' },
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
const slice = get(editorState).components.dialog;
|
|
191
|
-
expect(slice.aliases['--dialog-shadow']).toEqual(tokenRef('--shadow-2xl'));
|
|
192
|
-
expect(slice.aliases['--dialog-confirm-variant']).toBeUndefined();
|
|
193
|
-
expect(slice.config['--dialog-confirm-variant']).toBe('danger');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('replaces the full components slice', () => {
|
|
197
|
-
setComponentAlias('card', '--card-radius', tokenRef('--radius-md'));
|
|
198
|
-
seedComponentsFromApi({
|
|
199
|
-
button: { activeFile: 'default', aliases: {} },
|
|
200
|
-
});
|
|
201
|
-
expect(get(editorState).components.card).toBeUndefined();
|
|
202
|
-
expect(get(editorState).components.button).toBeDefined();
|
|
203
|
-
});
|
|
204
|
-
});
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { get } from 'svelte/store';
|
|
4
|
-
import type { PaletteConfig } from './themeTypes';
|
|
5
|
-
import {
|
|
6
|
-
editorState,
|
|
7
|
-
mutate,
|
|
8
|
-
beginScope,
|
|
9
|
-
commitScope,
|
|
10
|
-
cancelScope,
|
|
11
|
-
beginSliderGesture,
|
|
12
|
-
transaction,
|
|
13
|
-
undo,
|
|
14
|
-
redo,
|
|
15
|
-
setPaletteConfig,
|
|
16
|
-
__resetForTests,
|
|
17
|
-
__getHistoryLengths,
|
|
18
|
-
__getPastAt,
|
|
19
|
-
} from './editorStore';
|
|
20
|
-
|
|
21
|
-
function makePaletteConfig(baseColor: string): PaletteConfig {
|
|
22
|
-
return {
|
|
23
|
-
baseColor,
|
|
24
|
-
tintHue: 0,
|
|
25
|
-
tintChroma: 0.04,
|
|
26
|
-
lightnessCurve: [],
|
|
27
|
-
saturationCurve: [],
|
|
28
|
-
grayLightnessCurve: [],
|
|
29
|
-
graySaturationCurve: [],
|
|
30
|
-
scaleCurves: {},
|
|
31
|
-
curveOffset: {},
|
|
32
|
-
overrides: {},
|
|
33
|
-
snappedScales: [],
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const txOpts = { label: 'tx', collapseToOne: true, clipUndoFloor: false } as const;
|
|
38
|
-
const sessionOpts = { label: 'palette session', collapseToOne: true, clipUndoFloor: true } as const;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
__resetForTests();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('editorStore — mutate() outside a scope', () => {
|
|
45
|
-
it('pushes exactly one past[] entry per call and undo restores', () => {
|
|
46
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
47
|
-
expect(__getHistoryLengths().past).toBe(1);
|
|
48
|
-
|
|
49
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
50
|
-
expect(__getHistoryLengths().past).toBe(2);
|
|
51
|
-
|
|
52
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#222222');
|
|
53
|
-
expect(undo()).toBe(true);
|
|
54
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
55
|
-
expect(undo()).toBe(true);
|
|
56
|
-
expect(get(editorState).palettes.Background).toBeUndefined();
|
|
57
|
-
expect(undo()).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('editorStore — non-clipping scopes group gestures', () => {
|
|
62
|
-
it('beginScope + multiple mutate() + commitScope → one past entry equal to pre-gesture snapshot', () => {
|
|
63
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
64
|
-
const baselinePast = __getHistoryLengths().past;
|
|
65
|
-
const preGesture = structuredClone(get(editorState));
|
|
66
|
-
|
|
67
|
-
const scope = beginScope({ label: 'drag hue', collapseToOne: true, clipUndoFloor: false });
|
|
68
|
-
mutate('hue step 1', (s) => { s.palettes.Background.baseColor = '#222222'; });
|
|
69
|
-
mutate('hue step 2', (s) => { s.palettes.Background.baseColor = '#333333'; });
|
|
70
|
-
mutate('hue step 3', (s) => { s.palettes.Background.baseColor = '#444444'; });
|
|
71
|
-
commitScope(scope);
|
|
72
|
-
|
|
73
|
-
expect(__getHistoryLengths().past).toBe(baselinePast + 1);
|
|
74
|
-
const lastEntry = __getPastAt(__getHistoryLengths().past - 1)!;
|
|
75
|
-
expect(lastEntry.palettes.Background.baseColor).toBe(preGesture.palettes.Background.baseColor);
|
|
76
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#444444');
|
|
77
|
-
|
|
78
|
-
// One undo rolls the whole gesture back
|
|
79
|
-
undo();
|
|
80
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('beginSliderGesture opens a scope that groups updates into one entry', () => {
|
|
84
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
85
|
-
const baselinePast = __getHistoryLengths().past;
|
|
86
|
-
|
|
87
|
-
beginSliderGesture('drag');
|
|
88
|
-
mutate('step', (s) => { s.palettes.Background.baseColor = '#222222'; });
|
|
89
|
-
mutate('step', (s) => { s.palettes.Background.baseColor = '#333333'; });
|
|
90
|
-
// Simulate pointerup
|
|
91
|
-
window.dispatchEvent(new Event('pointerup'));
|
|
92
|
-
|
|
93
|
-
expect(__getHistoryLengths().past).toBe(baselinePast + 1);
|
|
94
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#333333');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('empty scope (no mutate calls) does not push history', () => {
|
|
98
|
-
const baselinePast = __getHistoryLengths().past;
|
|
99
|
-
const scope = beginScope({ ...txOpts, label: 'unused' });
|
|
100
|
-
commitScope(scope);
|
|
101
|
-
expect(__getHistoryLengths().past).toBe(baselinePast);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('cancelScope on a non-clipping scope restores pre-gesture state and does not push history', () => {
|
|
105
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
106
|
-
const baselinePast = __getHistoryLengths().past;
|
|
107
|
-
|
|
108
|
-
const scope = beginScope({ ...txOpts, label: 'drag' });
|
|
109
|
-
mutate('step', (s) => { s.palettes.Background.baseColor = '#999999'; });
|
|
110
|
-
cancelScope(scope, { silent: true });
|
|
111
|
-
|
|
112
|
-
expect(__getHistoryLengths().past).toBe(baselinePast);
|
|
113
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe('editorStore — clipping scopes (palette edit sessions)', () => {
|
|
118
|
-
it('beginScope with clipUndoFloor does not push history', () => {
|
|
119
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
120
|
-
const before = __getHistoryLengths().past;
|
|
121
|
-
beginScope({ ...sessionOpts });
|
|
122
|
-
expect(__getHistoryLengths().past).toBe(before);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('undo is clipped to the scope floor while open', () => {
|
|
126
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
127
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
128
|
-
const floor = __getHistoryLengths().past;
|
|
129
|
-
|
|
130
|
-
beginScope({ ...sessionOpts });
|
|
131
|
-
setPaletteConfig('Background', makePaletteConfig('#333333'));
|
|
132
|
-
setPaletteConfig('Background', makePaletteConfig('#444444'));
|
|
133
|
-
expect(__getHistoryLengths().past).toBe(floor + 2);
|
|
134
|
-
|
|
135
|
-
expect(undo()).toBe(true);
|
|
136
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#333333');
|
|
137
|
-
expect(undo()).toBe(true);
|
|
138
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#222222');
|
|
139
|
-
|
|
140
|
-
// Floor reached — further undo returns false, state unchanged
|
|
141
|
-
expect(undo()).toBe(false);
|
|
142
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#222222');
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('commitScope on a clipping scope collapses intra-scope history into one entry equal to the snapshot', () => {
|
|
146
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
147
|
-
const preSessionPastLen = __getHistoryLengths().past;
|
|
148
|
-
|
|
149
|
-
const session = beginScope({ ...sessionOpts });
|
|
150
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
151
|
-
setPaletteConfig('Background', makePaletteConfig('#333333'));
|
|
152
|
-
setPaletteConfig('Background', makePaletteConfig('#444444'));
|
|
153
|
-
commitScope(session);
|
|
154
|
-
|
|
155
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen + 1);
|
|
156
|
-
|
|
157
|
-
const committedEntry = __getPastAt(__getHistoryLengths().past - 1)!;
|
|
158
|
-
expect(committedEntry.palettes.Background.baseColor).toBe('#111111');
|
|
159
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#444444');
|
|
160
|
-
|
|
161
|
-
// One undo restores pre-scope state
|
|
162
|
-
undo();
|
|
163
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('commitScope on a clipping scope with no net change pushes nothing', () => {
|
|
167
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
168
|
-
const preSessionPastLen = __getHistoryLengths().past;
|
|
169
|
-
|
|
170
|
-
const session = beginScope({ ...sessionOpts });
|
|
171
|
-
// Mutate and revert to snapshot value
|
|
172
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
173
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
174
|
-
commitScope(session);
|
|
175
|
-
|
|
176
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen);
|
|
177
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('cancelScope on a clipping scope restores snapshot, drops intra-scope entries, clears future', () => {
|
|
181
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
182
|
-
const preSessionPastLen = __getHistoryLengths().past;
|
|
183
|
-
|
|
184
|
-
const session = beginScope({ ...sessionOpts });
|
|
185
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
186
|
-
setPaletteConfig('Background', makePaletteConfig('#333333'));
|
|
187
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen + 2);
|
|
188
|
-
|
|
189
|
-
cancelScope(session);
|
|
190
|
-
|
|
191
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen);
|
|
192
|
-
expect(__getHistoryLengths().future).toBe(0);
|
|
193
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('nested clipping beginScope auto-commits the prior scope', () => {
|
|
197
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
198
|
-
setPaletteConfig('Accent', makePaletteConfig('#aaaaaa'));
|
|
199
|
-
const preSessionPastLen = __getHistoryLengths().past;
|
|
200
|
-
|
|
201
|
-
beginScope({ ...sessionOpts });
|
|
202
|
-
setPaletteConfig('Background', makePaletteConfig('#222222'));
|
|
203
|
-
const second = beginScope({ ...sessionOpts }); // auto-commits prior
|
|
204
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen + 1);
|
|
205
|
-
|
|
206
|
-
setPaletteConfig('Accent', makePaletteConfig('#bbbbbb'));
|
|
207
|
-
commitScope(second);
|
|
208
|
-
|
|
209
|
-
// Two collapsed entries: prior Background scope, then Accent scope
|
|
210
|
-
expect(__getHistoryLengths().past).toBe(preSessionPastLen + 2);
|
|
211
|
-
// One undo: revert Accent to pre-scope value
|
|
212
|
-
undo();
|
|
213
|
-
expect(get(editorState).palettes.Accent.baseColor).toBe('#aaaaaa');
|
|
214
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#222222');
|
|
215
|
-
// Another undo: revert Background to pre-scope value
|
|
216
|
-
undo();
|
|
217
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#111111');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('undo() with a pending non-clipping scope cancels it first (drag-in-flight is discarded)', () => {
|
|
221
|
-
setPaletteConfig('Background', makePaletteConfig('#111111'));
|
|
222
|
-
const pastLenBefore = __getHistoryLengths().past;
|
|
223
|
-
|
|
224
|
-
beginScope({ ...txOpts, label: 'drag' });
|
|
225
|
-
mutate('step', (s) => { s.palettes.Background.baseColor = '#ffffff'; });
|
|
226
|
-
// An in-flight drag holds pre-drag state in the scope's snapshot;
|
|
227
|
-
// `undo()` cancels it (restoring that snapshot) before consulting history.
|
|
228
|
-
undo();
|
|
229
|
-
// The cancelled in-flight change is gone; history count unchanged by the cancel.
|
|
230
|
-
// (Current impl also consumes one history step after cancelling — the
|
|
231
|
-
// cross-boundary behavior is a separate concern tracked in the plan.)
|
|
232
|
-
expect(__getHistoryLengths().past).toBe(pastLenBefore - 1);
|
|
233
|
-
// The pending mutation did not survive: '#ffffff' is not current.
|
|
234
|
-
expect(get(editorState).palettes.Background?.baseColor).not.toBe('#ffffff');
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe('editorStore — apply + undo matches spec end-to-end', () => {
|
|
239
|
-
it('after Apply, one Cmd+Z restores to pre-session state', () => {
|
|
240
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
241
|
-
const preSessionState = structuredClone(get(editorState));
|
|
242
|
-
|
|
243
|
-
const session = beginScope({ ...sessionOpts });
|
|
244
|
-
// Simulate three slider drags during the session
|
|
245
|
-
for (const hex of ['#702030', '#503090', '#205090']) {
|
|
246
|
-
const drag = beginScope({ ...txOpts, label: `drag ${hex}` });
|
|
247
|
-
setPaletteConfig('Background', makePaletteConfig(hex));
|
|
248
|
-
commitScope(drag);
|
|
249
|
-
}
|
|
250
|
-
commitScope(session);
|
|
251
|
-
|
|
252
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#205090');
|
|
253
|
-
|
|
254
|
-
const undone = undo();
|
|
255
|
-
expect(undone).toBe(true);
|
|
256
|
-
expect(get(editorState).palettes.Background.baseColor).toBe(preSessionState.palettes.Background.baseColor);
|
|
257
|
-
expect(JSON.stringify(get(editorState))).toBe(JSON.stringify(preSessionState));
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('after Cancel, Cmd+Z does not resurrect discarded drags', () => {
|
|
261
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
262
|
-
const preSessionState = structuredClone(get(editorState));
|
|
263
|
-
|
|
264
|
-
const session = beginScope({ ...sessionOpts });
|
|
265
|
-
for (const hex of ['#702030', '#503090', '#205090']) {
|
|
266
|
-
const drag = beginScope({ ...txOpts, label: `drag ${hex}` });
|
|
267
|
-
setPaletteConfig('Background', makePaletteConfig(hex));
|
|
268
|
-
commitScope(drag);
|
|
269
|
-
}
|
|
270
|
-
cancelScope(session);
|
|
271
|
-
|
|
272
|
-
// State is pre-session; no new history entry
|
|
273
|
-
expect(JSON.stringify(get(editorState))).toBe(JSON.stringify(preSessionState));
|
|
274
|
-
// One undo walks back to before the palette existed (setPaletteConfig before scope)
|
|
275
|
-
undo();
|
|
276
|
-
expect(get(editorState).palettes.Background).toBeUndefined();
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe('editorStore — intra-session slider-drag tracking', () => {
|
|
281
|
-
// Regression guard for the two-writer feedback loop fixed in the
|
|
282
|
-
// PaletteEditor single-source-of-truth refactor: during a drag inside a
|
|
283
|
-
// palette edit session, every per-tick mutation must be visible in the
|
|
284
|
-
// store immediately (not pulled back to a stale pre-session value).
|
|
285
|
-
it('store reflects every per-tick mutation during a slider-drag session', () => {
|
|
286
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
287
|
-
|
|
288
|
-
const session = beginScope({ ...sessionOpts });
|
|
289
|
-
beginSliderGesture('drag base');
|
|
290
|
-
|
|
291
|
-
const tickHexes = ['#8c7f73', '#8b7f72', '#8a7f71', '#897f70', '#887f6f'];
|
|
292
|
-
for (const hex of tickHexes) {
|
|
293
|
-
mutate('drag tick', (s) => { s.palettes.Background.baseColor = hex; });
|
|
294
|
-
// Each tick must be visible on read — no stale pre-session value
|
|
295
|
-
expect(get(editorState).palettes.Background.baseColor).toBe(hex);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
window.dispatchEvent(new Event('pointerup'));
|
|
299
|
-
commitScope(session);
|
|
300
|
-
|
|
301
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#887f6f');
|
|
302
|
-
|
|
303
|
-
// One undo after Apply restores to pre-session
|
|
304
|
-
undo();
|
|
305
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8d7f74');
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('Cmd+Z during a session walks one tick back per press', () => {
|
|
309
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
310
|
-
|
|
311
|
-
beginScope({ ...sessionOpts });
|
|
312
|
-
for (const hex of ['#702030', '#503090', '#205090']) {
|
|
313
|
-
const drag = beginScope({ ...txOpts, label: `drag ${hex}` });
|
|
314
|
-
mutate('tick', (s) => { s.palettes.Background.baseColor = hex; });
|
|
315
|
-
commitScope(drag);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#205090');
|
|
319
|
-
undo();
|
|
320
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#503090');
|
|
321
|
-
undo();
|
|
322
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#702030');
|
|
323
|
-
undo();
|
|
324
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8d7f74');
|
|
325
|
-
// Session floor reached — further undo no-ops
|
|
326
|
-
expect(undo()).toBe(false);
|
|
327
|
-
});
|
|
328
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
3
|
-
import { get } from 'svelte/store';
|
|
4
|
-
import { configureEditor, storageKey } from './editorConfig';
|
|
5
|
-
import { route, navigate, init as initRouter } from './router';
|
|
6
|
-
|
|
7
|
-
describe('M8 — lazy storage prefix resolution', () => {
|
|
8
|
-
it('storageKey() reflects the prefix at call time, not at module-load time', () => {
|
|
9
|
-
// The default prefix is `lt-`. configureEditor mutates it; subsequent
|
|
10
|
-
// storageKey() calls must observe the new prefix.
|
|
11
|
-
expect(storageKey('editor-state')).toBe('lt-editor-state');
|
|
12
|
-
|
|
13
|
-
configureEditor({ storagePrefix: 'test-' });
|
|
14
|
-
expect(storageKey('editor-state')).toBe('test-editor-state');
|
|
15
|
-
|
|
16
|
-
// Reset for any later tests in the file.
|
|
17
|
-
configureEditor({ storagePrefix: 'lt-' });
|
|
18
|
-
expect(storageKey('editor-state')).toBe('lt-editor-state');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('editorStore persistNow uses the configured prefix at write time', async () => {
|
|
22
|
-
// Reconfigure BEFORE importing editorStore — but since these tests share
|
|
23
|
-
// module state with other suites, we can only verify that getPersistKey
|
|
24
|
-
// behaviour is lazy via the storageKey contract above. The functional
|
|
25
|
-
// round-trip is covered by manual library-import tests.
|
|
26
|
-
configureEditor({ storagePrefix: 'lazy-' });
|
|
27
|
-
expect(storageKey('editor-state')).toBe('lazy-editor-state');
|
|
28
|
-
configureEditor({ storagePrefix: 'lt-' });
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('m9 — navigate() emits exactly one route store update', () => {
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
// Pin a stable starting route. Router.init() is idempotent; calling it
|
|
35
|
-
// here is safe even if some earlier test already initialised it.
|
|
36
|
-
initRouter();
|
|
37
|
-
// Reset history pushState side effects to a clean state by overwriting
|
|
38
|
-
// the current route to '/'.
|
|
39
|
-
history.replaceState(null, '', '/');
|
|
40
|
-
route.set('/');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('produces a single store write per navigate() call', () => {
|
|
44
|
-
const sub = vi.fn();
|
|
45
|
-
const unsub = route.subscribe(sub);
|
|
46
|
-
sub.mockClear(); // drop the immediate-emit on subscribe
|
|
47
|
-
|
|
48
|
-
navigate('/foo');
|
|
49
|
-
expect(sub).toHaveBeenCalledTimes(1);
|
|
50
|
-
expect(get(route)).toBe('/foo');
|
|
51
|
-
|
|
52
|
-
unsub();
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
CURRENT_THEME_SCHEMA_VERSION,
|
|
4
|
-
CURRENT_COMPONENT_SCHEMA_VERSION,
|
|
5
|
-
runMigrations,
|
|
6
|
-
} from './index';
|
|
7
|
-
|
|
8
|
-
describe('migration runner — schemaVersion gating', () => {
|
|
9
|
-
it('CURRENT_*_SCHEMA_VERSION are positive (at least one migration registered each)', () => {
|
|
10
|
-
expect(CURRENT_THEME_SCHEMA_VERSION).toBeGreaterThan(0);
|
|
11
|
-
expect(CURRENT_COMPONENT_SCHEMA_VERSION).toBeGreaterThan(0);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('legacy theme (schemaVersion: 0) → bg/canvas + legacy-key renames applied; resaved file matches modern shape', () => {
|
|
15
|
-
const legacy = {
|
|
16
|
-
// bg → canvas pattern (exact match + boundary suffix)
|
|
17
|
-
'--surface-bg': '#fff',
|
|
18
|
-
'--text-bg': '#000',
|
|
19
|
-
'--border-bg-strong': '#888',
|
|
20
|
-
// explicit legacy renames
|
|
21
|
-
'--empty': '#111',
|
|
22
|
-
'--empty-attachment': 'fixed',
|
|
23
|
-
// orphan token (silent drop)
|
|
24
|
-
'--border-neutral': '#ccc',
|
|
25
|
-
// unrelated key passes through
|
|
26
|
-
'--text-primary': '#333',
|
|
27
|
-
};
|
|
28
|
-
const migrated = runMigrations('theme', 0, legacy);
|
|
29
|
-
expect(migrated['--surface-canvas']).toBe('#fff');
|
|
30
|
-
expect(migrated['--text-canvas']).toBe('#000');
|
|
31
|
-
expect(migrated['--border-canvas-strong']).toBe('#888');
|
|
32
|
-
expect(migrated['--page-bg']).toBe('#111');
|
|
33
|
-
expect(migrated['--page-bg-attachment']).toBe('fixed');
|
|
34
|
-
expect(migrated['--border-neutral']).toBeUndefined();
|
|
35
|
-
expect(migrated['--text-primary']).toBe('#333');
|
|
36
|
-
// The "drop" old keys are gone
|
|
37
|
-
expect(migrated['--surface-bg']).toBeUndefined();
|
|
38
|
-
expect(migrated['--empty']).toBeUndefined();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('file already at current theme version → no migrations run (passthrough)', () => {
|
|
42
|
-
const modern = { '--surface-canvas': '#fff', '--text-primary': '#333' };
|
|
43
|
-
const out = runMigrations('theme', CURRENT_THEME_SCHEMA_VERSION, modern);
|
|
44
|
-
expect(out).toEqual(modern);
|
|
45
|
-
// identity-preserved values
|
|
46
|
-
expect(out['--surface-canvas']).toBe('#fff');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('legacy component-config (schemaVersion: 0) → prefix + suffix renames; segmentedcontrol option-disabled flatten', () => {
|
|
50
|
-
const legacy = {
|
|
51
|
-
// abbreviated prefix
|
|
52
|
-
'--segment-option-bg': '#eee',
|
|
53
|
-
// segmentedcontrol option-disabled → disabled flatten
|
|
54
|
-
'--segmentedcontrol-option-disabled-text': '#999',
|
|
55
|
-
// selected-disabled is dropped (impossible state)
|
|
56
|
-
'--segmentedcontrol-selected-disabled-bg': 'red',
|
|
57
|
-
// selected-hover is dropped
|
|
58
|
-
'--segmentedcontrol-selected-hover-bg': 'blue',
|
|
59
|
-
// unrelated key
|
|
60
|
-
'--segment-track-radius': '4px',
|
|
61
|
-
};
|
|
62
|
-
const migrated = runMigrations('component-config', 0, legacy, {
|
|
63
|
-
component: 'segmentedcontrol',
|
|
64
|
-
});
|
|
65
|
-
// Prefix renamed, then option-disabled flattened... actually option-disabled
|
|
66
|
-
// was already on the long-form name, so it goes through the flatten step.
|
|
67
|
-
expect(migrated['--segmentedcontrol-option-bg']).toBe('#eee');
|
|
68
|
-
expect(migrated['--segmentedcontrol-disabled-text']).toBe('#999');
|
|
69
|
-
expect(migrated['--segmentedcontrol-selected-disabled-bg']).toBeUndefined();
|
|
70
|
-
expect(migrated['--segmentedcontrol-selected-hover-bg']).toBeUndefined();
|
|
71
|
-
expect(migrated['--segmentedcontrol-track-radius']).toBe('4px');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('component-config file at version 1 → only the >=1 migrations run (segmentedcontrol flatten only)', () => {
|
|
75
|
-
const v1 = {
|
|
76
|
-
// Already on long-form prefix; v1 → v2 step still applies for sc
|
|
77
|
-
'--segmentedcontrol-option-disabled-text': '#999',
|
|
78
|
-
// suffix-rename rules from v0→v1 should NOT re-apply; abbreviated keys
|
|
79
|
-
// present at v1 are user-authored and untouched
|
|
80
|
-
'--segment-something': 'should-pass-through',
|
|
81
|
-
};
|
|
82
|
-
const migrated = runMigrations('component-config', 1, v1, {
|
|
83
|
-
component: 'segmentedcontrol',
|
|
84
|
-
});
|
|
85
|
-
expect(migrated['--segmentedcontrol-disabled-text']).toBe('#999');
|
|
86
|
-
// v0→v1 prefix rename did NOT re-run
|
|
87
|
-
expect(migrated['--segment-something']).toBe('should-pass-through');
|
|
88
|
-
expect(migrated['--segmentedcontrol-something']).toBeUndefined();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('component-config at version 2 → collapsiblesection state tokens namespace into container; v3→v4 cleanup applies on top', () => {
|
|
92
|
-
const v2 = {
|
|
93
|
-
'--collapsiblesection-default-surface': '--surface-canvas-high',
|
|
94
|
-
'--collapsiblesection-hover-icon': '--text-primary',
|
|
95
|
-
'--collapsiblesection-active-border': '--color-primary-400',
|
|
96
|
-
'--collapsiblesection-expanded-padding': '--space-4',
|
|
97
|
-
'--collapsiblesection-default-label-font-family': '--font-sans',
|
|
98
|
-
};
|
|
99
|
-
const migrated = runMigrations('component-config', 2, v2, {
|
|
100
|
-
component: 'collapsiblesection',
|
|
101
|
-
});
|
|
102
|
-
// Old, unscoped keys are gone after v2→v3
|
|
103
|
-
expect(migrated['--collapsiblesection-default-surface']).toBeUndefined();
|
|
104
|
-
expect(migrated['--collapsiblesection-hover-icon']).toBeUndefined();
|
|
105
|
-
// Surviving header tokens land in the container namespace
|
|
106
|
-
expect(migrated['--collapsiblesection-container-default-surface']).toBe('--surface-canvas-high');
|
|
107
|
-
expect(migrated['--collapsiblesection-container-hover-icon']).toBe('--text-primary');
|
|
108
|
-
expect(migrated['--collapsiblesection-container-default-label-font-family']).toBe('--font-sans');
|
|
109
|
-
expect(migrated['--collapsiblesection-container-expanded-padding']).toBe('--space-4');
|
|
110
|
-
// v3→v4 drops container active-border (frame owns chrome now); the
|
|
111
|
-
// pre-existing default-surface is also seeded into the new frame namespace.
|
|
112
|
-
expect(migrated['--collapsiblesection-container-active-border']).toBeUndefined();
|
|
113
|
-
expect(migrated['--collapsiblesection-container-frame-surface']).toBe('--surface-canvas-high');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('component-config v2 namespace migration only fires for collapsiblesection', () => {
|
|
117
|
-
const v2 = { '--button-primary-surface': '--surface-success' };
|
|
118
|
-
const out = runMigrations('component-config', 2, v2, { component: 'button' });
|
|
119
|
-
expect(out).toEqual(v2);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('component-config at version 3 → collapsiblesection container chrome moves into frame, dead per-state tokens drop', () => {
|
|
123
|
-
const v3 = {
|
|
124
|
-
// Container default-state chrome → frame (values preserved).
|
|
125
|
-
'--collapsiblesection-container-default-surface': '--surface-canvas-high',
|
|
126
|
-
'--collapsiblesection-container-default-border': '--color-primary-400',
|
|
127
|
-
'--collapsiblesection-container-default-border-width': '--border-width-3',
|
|
128
|
-
'--collapsiblesection-container-default-radius': '--radius-md',
|
|
129
|
-
'--collapsiblesection-container-default-padding': '--space-4',
|
|
130
|
-
// Container hover/active border tokens drop (frame owns chrome).
|
|
131
|
-
'--collapsiblesection-container-hover-border': '--color-primary-500',
|
|
132
|
-
'--collapsiblesection-container-hover-border-width': '--border-width-3',
|
|
133
|
-
'--collapsiblesection-container-active-radius': '--radius-md',
|
|
134
|
-
'--collapsiblesection-container-active-surface': '--surface-canvas-low',
|
|
135
|
-
// Chromeless per-state border / radius drop.
|
|
136
|
-
'--collapsiblesection-chromeless-default-border': '--color-primary-400',
|
|
137
|
-
'--collapsiblesection-chromeless-hover-border-width': '--border-width-1',
|
|
138
|
-
'--collapsiblesection-chromeless-active-radius': '--radius-none',
|
|
139
|
-
'--collapsiblesection-chromeless-default-padding': '--space-4',
|
|
140
|
-
// Divider radius drops; divider border-* survives (paints bottom rule).
|
|
141
|
-
'--collapsiblesection-divider-default-border': '--border-neutral-faint',
|
|
142
|
-
'--collapsiblesection-divider-default-border-width': '--border-width-1',
|
|
143
|
-
'--collapsiblesection-divider-default-radius': '--radius-none',
|
|
144
|
-
// Expanded panel: only padding for chromeless/divider; surface + padding for container.
|
|
145
|
-
'--collapsiblesection-chromeless-expanded-border': '--color-primary-400',
|
|
146
|
-
'--collapsiblesection-chromeless-expanded-surface': '--surface-canvas',
|
|
147
|
-
'--collapsiblesection-chromeless-expanded-padding': '--space-4',
|
|
148
|
-
'--collapsiblesection-container-expanded-radius': '--radius-md',
|
|
149
|
-
'--collapsiblesection-container-expanded-surface': '--surface-canvas-low',
|
|
150
|
-
'--collapsiblesection-container-expanded-padding': '--space-4',
|
|
151
|
-
};
|
|
152
|
-
const migrated = runMigrations('component-config', 3, v3, {
|
|
153
|
-
component: 'collapsiblesection',
|
|
154
|
-
});
|
|
155
|
-
// Container frame-* seeded from old default-state tokens. The
|
|
156
|
-
// v5→v6 primary→brand migration rewrites `--color-primary-*` values
|
|
157
|
-
// to `--color-brand-*` at the tail of the chain.
|
|
158
|
-
expect(migrated['--collapsiblesection-container-frame-surface']).toBe('--surface-canvas-high');
|
|
159
|
-
expect(migrated['--collapsiblesection-container-frame-border']).toBe('--color-brand-400');
|
|
160
|
-
expect(migrated['--collapsiblesection-container-frame-border-width']).toBe('--border-width-3');
|
|
161
|
-
expect(migrated['--collapsiblesection-container-frame-radius']).toBe('--radius-md');
|
|
162
|
-
// Container default-state surface + padding survive (still drive header strip)
|
|
163
|
-
expect(migrated['--collapsiblesection-container-default-surface']).toBe('--surface-canvas-high');
|
|
164
|
-
expect(migrated['--collapsiblesection-container-default-padding']).toBe('--space-4');
|
|
165
|
-
// Container default-state border / radius dropped (frame owns them now)
|
|
166
|
-
expect(migrated['--collapsiblesection-container-default-border']).toBeUndefined();
|
|
167
|
-
expect(migrated['--collapsiblesection-container-default-border-width']).toBeUndefined();
|
|
168
|
-
expect(migrated['--collapsiblesection-container-default-radius']).toBeUndefined();
|
|
169
|
-
// Container hover/active border tokens dropped
|
|
170
|
-
expect(migrated['--collapsiblesection-container-hover-border']).toBeUndefined();
|
|
171
|
-
expect(migrated['--collapsiblesection-container-hover-border-width']).toBeUndefined();
|
|
172
|
-
expect(migrated['--collapsiblesection-container-active-radius']).toBeUndefined();
|
|
173
|
-
// Container hover/active surface survives (header strip)
|
|
174
|
-
expect(migrated['--collapsiblesection-container-active-surface']).toBe('--surface-canvas-low');
|
|
175
|
-
// Chromeless per-state border / radius dropped; padding stays
|
|
176
|
-
expect(migrated['--collapsiblesection-chromeless-default-border']).toBeUndefined();
|
|
177
|
-
expect(migrated['--collapsiblesection-chromeless-hover-border-width']).toBeUndefined();
|
|
178
|
-
expect(migrated['--collapsiblesection-chromeless-active-radius']).toBeUndefined();
|
|
179
|
-
expect(migrated['--collapsiblesection-chromeless-default-padding']).toBe('--space-4');
|
|
180
|
-
// Divider border / border-width survive; radius drops
|
|
181
|
-
expect(migrated['--collapsiblesection-divider-default-border']).toBe('--border-neutral-faint');
|
|
182
|
-
expect(migrated['--collapsiblesection-divider-default-border-width']).toBe('--border-width-1');
|
|
183
|
-
expect(migrated['--collapsiblesection-divider-default-radius']).toBeUndefined();
|
|
184
|
-
// Expanded panel cleanup
|
|
185
|
-
expect(migrated['--collapsiblesection-chromeless-expanded-border']).toBeUndefined();
|
|
186
|
-
expect(migrated['--collapsiblesection-chromeless-expanded-surface']).toBeUndefined();
|
|
187
|
-
expect(migrated['--collapsiblesection-chromeless-expanded-padding']).toBe('--space-4');
|
|
188
|
-
expect(migrated['--collapsiblesection-container-expanded-radius']).toBeUndefined();
|
|
189
|
-
expect(migrated['--collapsiblesection-container-expanded-surface']).toBe('--surface-canvas-low');
|
|
190
|
-
expect(migrated['--collapsiblesection-container-expanded-padding']).toBe('--space-4');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('component-config at version 4 → sectiondivider gradient stops migrate to angle + stop-{n}-{color,position}', () => {
|
|
194
|
-
const v4 = {
|
|
195
|
-
'--sectiondivider-canvas-padding': '--space-16',
|
|
196
|
-
'--sectiondivider-canvas-gradient-stop-1': '--surface-canvas-highest',
|
|
197
|
-
'--sectiondivider-canvas-gradient-stop-2': '--surface-canvas-higher',
|
|
198
|
-
'--sectiondivider-canvas-gradient-stop-3': '--surface-canvas-high',
|
|
199
|
-
'--sectiondivider-canvas-gradient-stop-4': '--surface-canvas',
|
|
200
|
-
'--sectiondivider-primary-gradient-stop-1': '--color-primary-300',
|
|
201
|
-
'--sectiondivider-primary-gradient-stop-2': '--color-primary-500',
|
|
202
|
-
'--sectiondivider-primary-gradient-stop-4': '--color-primary-800',
|
|
203
|
-
};
|
|
204
|
-
const migrated = runMigrations('component-config', 4, v4, { component: 'sectiondivider' });
|
|
205
|
-
// Old keys gone
|
|
206
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-1']).toBeUndefined();
|
|
207
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-3']).toBeUndefined();
|
|
208
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-4']).toBeUndefined();
|
|
209
|
-
// Unrelated tokens preserved
|
|
210
|
-
expect(migrated['--sectiondivider-canvas-padding']).toBe('--space-16');
|
|
211
|
-
// Canvas: colors mapped from old 1, 2, 4
|
|
212
|
-
expect(migrated['--sectiondivider-canvas-gradient-angle']).toBe('--gradient-angle-diagonal');
|
|
213
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-1-color']).toBe('--surface-canvas-highest');
|
|
214
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-1-position']).toBe('--gradient-stop-start');
|
|
215
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-2-color']).toBe('--surface-canvas-higher');
|
|
216
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-2-position']).toBe('--gradient-stop-mid');
|
|
217
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-3-color']).toBe('--surface-canvas');
|
|
218
|
-
expect(migrated['--sectiondivider-canvas-gradient-stop-3-position']).toBe('--gradient-stop-end');
|
|
219
|
-
// Primary variant: user-tuned colors carry across; v5→v6 also rewrites
|
|
220
|
-
// the brand-family value names.
|
|
221
|
-
expect(migrated['--sectiondivider-primary-gradient-stop-1-color']).toBe('--color-brand-300');
|
|
222
|
-
expect(migrated['--sectiondivider-primary-gradient-stop-2-color']).toBe('--color-brand-500');
|
|
223
|
-
expect(migrated['--sectiondivider-primary-gradient-stop-3-color']).toBe('--color-brand-800');
|
|
224
|
-
// Variants the file didn't set still gain default colors and angle/positions
|
|
225
|
-
expect(migrated['--sectiondivider-special-gradient-angle']).toBe('--gradient-angle-diagonal');
|
|
226
|
-
expect(migrated['--sectiondivider-special-gradient-stop-1-color']).toBe('--surface-special-highest');
|
|
227
|
-
expect(migrated['--sectiondivider-special-gradient-stop-3-position']).toBe('--gradient-stop-end');
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('component-config v4 sectiondivider migration only fires for sectiondivider', () => {
|
|
231
|
-
// Use a value not in the brand-rename map so the v5→v6 step is also a no-op.
|
|
232
|
-
const v4 = { '--button-primary-gradient-stop-1': '--surface-accent' };
|
|
233
|
-
const out = runMigrations('component-config', 4, v4, { component: 'button' });
|
|
234
|
-
expect(out).toEqual(v4);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('component-config v4 sectiondivider migration is idempotent on the new shape', () => {
|
|
238
|
-
const newShape = {
|
|
239
|
-
'--sectiondivider-canvas-gradient-angle': '--gradient-angle-horizontal',
|
|
240
|
-
'--sectiondivider-canvas-gradient-stop-1-color': '--color-primary-200',
|
|
241
|
-
'--sectiondivider-canvas-gradient-stop-1-position': '10%',
|
|
242
|
-
'--sectiondivider-canvas-gradient-stop-2-color': '--color-primary-500',
|
|
243
|
-
'--sectiondivider-canvas-gradient-stop-2-position': '40%',
|
|
244
|
-
'--sectiondivider-canvas-gradient-stop-3-color': '--color-primary-900',
|
|
245
|
-
'--sectiondivider-canvas-gradient-stop-3-position': '85%',
|
|
246
|
-
};
|
|
247
|
-
const out = runMigrations('component-config', 4, newShape, { component: 'sectiondivider' });
|
|
248
|
-
// User-tuned values for canvas survive structurally; v5→v6 rewrites
|
|
249
|
-
// brand-family value names from primary → brand at the tail of the chain.
|
|
250
|
-
expect(out['--sectiondivider-canvas-gradient-angle']).toBe('--gradient-angle-horizontal');
|
|
251
|
-
expect(out['--sectiondivider-canvas-gradient-stop-1-color']).toBe('--color-brand-200');
|
|
252
|
-
expect(out['--sectiondivider-canvas-gradient-stop-1-position']).toBe('10%');
|
|
253
|
-
expect(out['--sectiondivider-canvas-gradient-stop-2-position']).toBe('40%');
|
|
254
|
-
expect(out['--sectiondivider-canvas-gradient-stop-3-position']).toBe('85%');
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('component-config at current version → no migrations run', () => {
|
|
258
|
-
const current = { '--button-primary-surface': '--surface-success' };
|
|
259
|
-
const out = runMigrations(
|
|
260
|
-
'component-config',
|
|
261
|
-
CURRENT_COMPONENT_SCHEMA_VERSION,
|
|
262
|
-
current,
|
|
263
|
-
{ component: 'button' },
|
|
264
|
-
);
|
|
265
|
-
expect(out).toEqual(current);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('theme v1 → v2 primary→brand: brand family keys renamed, neutral --text-primary untouched', () => {
|
|
269
|
-
const v1 = {
|
|
270
|
-
'--color-primary-100': '#ffe6f9',
|
|
271
|
-
'--color-primary-500': '#eb0ad4',
|
|
272
|
-
'--surface-primary': '#55004c',
|
|
273
|
-
'--surface-primary-high': '#6c0061',
|
|
274
|
-
'--border-primary': '#b200a0',
|
|
275
|
-
'--border-primary-strong': '#ff90eb',
|
|
276
|
-
'--text-primary-color': '#ff8eeb',
|
|
277
|
-
'--text-primary-secondary': '#fe5be7',
|
|
278
|
-
// Neutral text ramp — must NOT be touched.
|
|
279
|
-
'--text-primary': '#fff5f0',
|
|
280
|
-
'--text-secondary': '#b0a9a4',
|
|
281
|
-
// Component variant — also must NOT be touched.
|
|
282
|
-
'--button-primary-surface': '#abc123',
|
|
283
|
-
};
|
|
284
|
-
const out = runMigrations('theme', 1, v1);
|
|
285
|
-
// Brand family renamed.
|
|
286
|
-
expect(out['--color-brand-100']).toBe('#ffe6f9');
|
|
287
|
-
expect(out['--color-brand-500']).toBe('#eb0ad4');
|
|
288
|
-
expect(out['--surface-brand']).toBe('#55004c');
|
|
289
|
-
expect(out['--surface-brand-high']).toBe('#6c0061');
|
|
290
|
-
expect(out['--border-brand']).toBe('#b200a0');
|
|
291
|
-
expect(out['--border-brand-strong']).toBe('#ff90eb');
|
|
292
|
-
expect(out['--text-brand']).toBe('#ff8eeb');
|
|
293
|
-
expect(out['--text-brand-secondary']).toBe('#fe5be7');
|
|
294
|
-
// Old keys gone.
|
|
295
|
-
expect(out['--color-primary-100']).toBeUndefined();
|
|
296
|
-
expect(out['--surface-primary']).toBeUndefined();
|
|
297
|
-
expect(out['--border-primary']).toBeUndefined();
|
|
298
|
-
expect(out['--text-primary-color']).toBeUndefined();
|
|
299
|
-
expect(out['--text-primary-secondary']).toBeUndefined();
|
|
300
|
-
// Neutral text + component variants survive verbatim.
|
|
301
|
-
expect(out['--text-primary']).toBe('#fff5f0');
|
|
302
|
-
expect(out['--text-secondary']).toBe('#b0a9a4');
|
|
303
|
-
expect(out['--button-primary-surface']).toBe('#abc123');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('component-config v5 → v6 primary→brand: rewrites alias keys AND values; component variants untouched', () => {
|
|
307
|
-
const v5 = {
|
|
308
|
-
// Brand family on the alias key side (rare but possible).
|
|
309
|
-
'--text-primary-color': '#fff',
|
|
310
|
-
// Brand family on the value side (common: component aliases to family token).
|
|
311
|
-
'--badge-trait-surface': '--surface-primary',
|
|
312
|
-
'--badge-trait-text': '--text-primary-color',
|
|
313
|
-
'--badge-trait-border': '--border-primary-medium',
|
|
314
|
-
// Component variant token — name should NOT change.
|
|
315
|
-
'--button-primary-surface': '--surface-brand-high',
|
|
316
|
-
// Neutral text — value should NOT change.
|
|
317
|
-
'--card-default-title': '--text-primary',
|
|
318
|
-
// Unrelated key/value pair.
|
|
319
|
-
'--card-hover-title': '--text-secondary',
|
|
320
|
-
};
|
|
321
|
-
const out = runMigrations('component-config', 5, v5, { component: 'badge' });
|
|
322
|
-
expect(out['--text-brand']).toBe('#fff');
|
|
323
|
-
expect(out['--text-primary-color']).toBeUndefined();
|
|
324
|
-
expect(out['--badge-trait-surface']).toBe('--surface-brand');
|
|
325
|
-
expect(out['--badge-trait-text']).toBe('--text-brand');
|
|
326
|
-
expect(out['--badge-trait-border']).toBe('--border-brand-medium');
|
|
327
|
-
// Component variant identifier (LHS) is preserved; its value is not in the
|
|
328
|
-
// rename map so it passes through.
|
|
329
|
-
expect(out['--button-primary-surface']).toBe('--surface-brand-high');
|
|
330
|
-
// Neutral text value preserved.
|
|
331
|
-
expect(out['--card-default-title']).toBe('--text-primary');
|
|
332
|
-
expect(out['--card-hover-title']).toBe('--text-secondary');
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it('runMigrations is pure — does not mutate the input map', () => {
|
|
336
|
-
const input = { '--surface-bg': '#fff' };
|
|
337
|
-
const before = { ...input };
|
|
338
|
-
runMigrations('theme', 0, input);
|
|
339
|
-
expect(input).toEqual(before);
|
|
340
|
-
});
|
|
341
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { get } from 'svelte/store';
|
|
4
|
-
import PaletteEditor from './PaletteEditor.svelte';
|
|
5
|
-
import {
|
|
6
|
-
editorState,
|
|
7
|
-
mutate,
|
|
8
|
-
beginScope,
|
|
9
|
-
commitScope,
|
|
10
|
-
cancelScope,
|
|
11
|
-
beginSliderGesture,
|
|
12
|
-
setPaletteConfig,
|
|
13
|
-
undo,
|
|
14
|
-
__resetForTests,
|
|
15
|
-
} from '../lib/editorStore';
|
|
16
|
-
import type { PaletteConfig } from '../lib/themeTypes';
|
|
17
|
-
|
|
18
|
-
function makePaletteConfig(baseColor: string): PaletteConfig {
|
|
19
|
-
return {
|
|
20
|
-
baseColor,
|
|
21
|
-
tintHue: 0,
|
|
22
|
-
tintChroma: 0.04,
|
|
23
|
-
lightnessCurve: [],
|
|
24
|
-
saturationCurve: [],
|
|
25
|
-
grayLightnessCurve: [],
|
|
26
|
-
graySaturationCurve: [],
|
|
27
|
-
scaleCurves: {},
|
|
28
|
-
curveOffset: {},
|
|
29
|
-
overrides: {},
|
|
30
|
-
snappedScales: [],
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const sessionOpts = { label: 'palette session', collapseToOne: true, clipUndoFloor: true } as const;
|
|
35
|
-
|
|
36
|
-
beforeEach(() => {
|
|
37
|
-
__resetForTests();
|
|
38
|
-
document.body.innerHTML = '';
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('PaletteEditor — store-first integration', () => {
|
|
42
|
-
// Mounts the real component to exercise the $: derivations off the store
|
|
43
|
-
// and prove the sync/auto-persist round-trip has been removed. If the
|
|
44
|
-
// previous two-writer loop were reintroduced, per-tick mutations during a
|
|
45
|
-
// session would be pulled back to the pre-session snapshot — this test
|
|
46
|
-
// would fail.
|
|
47
|
-
it('mounts against the editor store without throwing', () => {
|
|
48
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
49
|
-
|
|
50
|
-
const target = document.createElement('div');
|
|
51
|
-
document.body.appendChild(target);
|
|
52
|
-
|
|
53
|
-
const component = new PaletteEditor({
|
|
54
|
-
target,
|
|
55
|
-
props: { label: 'Background', initialColor: '#8d7f74', mode: 'chromatic' },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8d7f74');
|
|
59
|
-
component.$destroy();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('per-tick store mutations are visible immediately during a session', () => {
|
|
63
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
64
|
-
|
|
65
|
-
const target = document.createElement('div');
|
|
66
|
-
document.body.appendChild(target);
|
|
67
|
-
const component = new PaletteEditor({
|
|
68
|
-
target,
|
|
69
|
-
props: { label: 'Background', initialColor: '#8d7f74', mode: 'chromatic' },
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const session = beginScope({ ...sessionOpts });
|
|
73
|
-
beginSliderGesture('drag base');
|
|
74
|
-
|
|
75
|
-
for (const hex of ['#8c7f73', '#8b7f72', '#8a7f71']) {
|
|
76
|
-
mutate('drag tick', (s) => { s.palettes.Background.baseColor = hex; });
|
|
77
|
-
expect(get(editorState).palettes.Background.baseColor).toBe(hex);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
window.dispatchEvent(new Event('pointerup'));
|
|
81
|
-
commitScope(session);
|
|
82
|
-
|
|
83
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8a7f71');
|
|
84
|
-
|
|
85
|
-
undo();
|
|
86
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8d7f74');
|
|
87
|
-
|
|
88
|
-
component.$destroy();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('cancel after drag snaps the store back to pre-session', () => {
|
|
92
|
-
setPaletteConfig('Background', makePaletteConfig('#8d7f74'));
|
|
93
|
-
|
|
94
|
-
const target = document.createElement('div');
|
|
95
|
-
document.body.appendChild(target);
|
|
96
|
-
const component = new PaletteEditor({
|
|
97
|
-
target,
|
|
98
|
-
props: { label: 'Background', initialColor: '#8d7f74', mode: 'chromatic' },
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const session = beginScope({ ...sessionOpts });
|
|
102
|
-
mutate('drag', (s) => { s.palettes.Background.baseColor = '#112233'; });
|
|
103
|
-
cancelScope(session);
|
|
104
|
-
|
|
105
|
-
expect(get(editorState).palettes.Background.baseColor).toBe('#8d7f74');
|
|
106
|
-
component.$destroy();
|
|
107
|
-
});
|
|
108
|
-
});
|