@1agh/maude 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
- package/plugins/design/dev-server/annotations-layer.tsx +343 -112
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +691 -119
- package/plugins/design/dev-server/canvas-shell.tsx +818 -42
- package/plugins/design/dev-server/client/app.jsx +173 -52
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
- package/plugins/design/dev-server/client/styles/4-components.css +32 -2
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- package/plugins/design/dev-server/config.schema.json +31 -5
- package/plugins/design/dev-server/context-menu.tsx +3 -3
- package/plugins/design/dev-server/context.ts +37 -3
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +212 -103
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/styles.css +38 -2
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +41 -2
- package/plugins/design/dev-server/input-router.tsx +74 -10
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/server.mjs +84 -18
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
- package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +71 -15
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// snap-distance-pill — DDR-046. Verifies SnapGuide.delta + .kind population
|
|
2
|
+
// in `computeSnap`. The visual `Δ{Math.round(delta)}` pill rendering lives in
|
|
3
|
+
// `SnapGuideOverlay` (`canvas-lib.tsx`); this file covers only the pure-function
|
|
4
|
+
// contract that the overlay reads.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { type Rect, type SnapOptions, computeSnap } from '../use-snap-guides.tsx';
|
|
9
|
+
|
|
10
|
+
const DEFAULTS: SnapOptions = { gridSize: 40, tolerance: 8, disabled: false };
|
|
11
|
+
const rect = (x: number, y: number, w = 100, h = 80): Rect => ({ x, y, w, h });
|
|
12
|
+
|
|
13
|
+
describe('SnapGuide.delta + .kind (DDR-046)', () => {
|
|
14
|
+
test('grid snap emits delta + kind=grid', () => {
|
|
15
|
+
const r = computeSnap(rect(33, 200), [], DEFAULTS);
|
|
16
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
17
|
+
expect(xg?.delta).toBe(7); // 40 − 33
|
|
18
|
+
expect(xg?.kind).toBe('grid');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('grid snap with zero delta (already on grid line) still emits kind=grid', () => {
|
|
22
|
+
const r = computeSnap(rect(40, 200), [], DEFAULTS);
|
|
23
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
24
|
+
expect(xg?.delta).toBe(0);
|
|
25
|
+
expect(xg?.kind).toBe('grid');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('sibling snap beats grid when both fire (closest |delta| wins)', () => {
|
|
29
|
+
// proposed x=37: grid candidate at 40 (delta +3), sibling at 35 (delta −2).
|
|
30
|
+
// |−2| < |3| → sibling wins.
|
|
31
|
+
const r = computeSnap(rect(37, 200), [rect(35, 0, 50, 50)], DEFAULTS);
|
|
32
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
33
|
+
expect(xg?.kind).toBe('sibling');
|
|
34
|
+
expect(xg?.delta).toBe(-2);
|
|
35
|
+
expect(r.x).toBe(35);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('sibling snap emits delta + kind=sibling', () => {
|
|
39
|
+
// proposed x=503, sibling at x=500. Snap left↔left.
|
|
40
|
+
const r = computeSnap(rect(503, 200), [rect(500, 50, 100, 80)], DEFAULTS);
|
|
41
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
42
|
+
expect(xg?.delta).toBe(-3);
|
|
43
|
+
expect(xg?.kind).toBe('sibling');
|
|
44
|
+
expect(r.x).toBe(500);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('merged-at-pos guide keeps the larger |delta|', () => {
|
|
48
|
+
// Two siblings both at x=500 — same edge, different y so merged guide
|
|
49
|
+
// unions their from/to. Both candidates have identical delta here, but
|
|
50
|
+
// we still expect the merge code path to preserve a single delta value.
|
|
51
|
+
const r = computeSnap(
|
|
52
|
+
rect(503, 200),
|
|
53
|
+
[rect(500, 50, 100, 50), rect(500, 400, 100, 50)],
|
|
54
|
+
DEFAULTS
|
|
55
|
+
);
|
|
56
|
+
const xg = r.guides.find((g) => g.axis === 'x');
|
|
57
|
+
expect(xg?.delta).toBe(-3);
|
|
58
|
+
expect(xg?.kind).toBe('sibling');
|
|
59
|
+
// Merged span covers both siblings' Y extents.
|
|
60
|
+
expect(xg?.from).toBe(50);
|
|
61
|
+
expect(xg?.to).toBe(450);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('disabled mode emits no guides (delta + kind absent)', () => {
|
|
65
|
+
const r = computeSnap(rect(33, 200), [rect(35, 0)], { ...DEFAULTS, disabled: true });
|
|
66
|
+
expect(r.guides).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// /_system-data — bias-free token rendering depends on per-DS scoping.
|
|
2
|
+
// Covers DDR-048: ?ds=<name> scopes payload to one designSystem entry;
|
|
3
|
+
// unknown ds → 404; omitted ds → legacy unscoped behavior for single-DS
|
|
4
|
+
// projects that don't declare `designSystems[]`.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import type { Subprocess } from 'bun';
|
|
11
|
+
import { bootServer, killProc, nextPort } from './_helpers';
|
|
12
|
+
|
|
13
|
+
interface MultiDsSandbox {
|
|
14
|
+
root: string;
|
|
15
|
+
designRoot: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeMultiDsSandbox(): MultiDsSandbox {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'mdcc-multi-ds-'));
|
|
20
|
+
const designRoot = join(root, '.design');
|
|
21
|
+
mkdirSync(designRoot, { recursive: true });
|
|
22
|
+
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(designRoot, 'config.json'),
|
|
25
|
+
JSON.stringify(
|
|
26
|
+
{
|
|
27
|
+
name: 'multi-ds',
|
|
28
|
+
designRoot: '.design',
|
|
29
|
+
canvasGroups: [
|
|
30
|
+
{ label: 'Design system', path: 'system' },
|
|
31
|
+
{ label: 'UI', path: 'ui' },
|
|
32
|
+
],
|
|
33
|
+
designSystems: [
|
|
34
|
+
{ name: 'alpha', path: 'system/alpha', description: 'Alpha brand' },
|
|
35
|
+
{ name: 'beta', path: 'system/beta', description: 'Beta brand' },
|
|
36
|
+
],
|
|
37
|
+
defaultDesignSystem: 'alpha',
|
|
38
|
+
},
|
|
39
|
+
null,
|
|
40
|
+
2
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Alpha — periwinkle accent, cream surfaces (mimics StudyFi case).
|
|
45
|
+
mkdirSync(join(designRoot, 'system', 'alpha', 'preview'), { recursive: true });
|
|
46
|
+
writeFileSync(
|
|
47
|
+
join(designRoot, 'system', 'alpha', 'colors_and_type.css'),
|
|
48
|
+
":root {\n --color-bg: #fbfaf7;\n --accent: #9CACFF;\n --color-text-primary: #000000;\n --fs-base: 1rem;\n --font-body: 'Inter', sans-serif;\n}\n"
|
|
49
|
+
);
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(designRoot, 'system', 'alpha', 'preview', 'colors-accent.tsx'),
|
|
52
|
+
'export default function ColorsAccent() { return <div>alpha accent</div>; }\n'
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Beta — different palette + names.
|
|
56
|
+
mkdirSync(join(designRoot, 'system', 'beta', 'preview'), { recursive: true });
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(designRoot, 'system', 'beta', 'colors_and_type.css'),
|
|
59
|
+
':root {\n --bg-0: oklch(13% 0.012 60);\n --accent: oklch(72% 0.16 55);\n --type-base: 14px;\n}\n'
|
|
60
|
+
);
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(designRoot, 'system', 'beta', 'preview', 'colors-accent.tsx'),
|
|
63
|
+
'export default function ColorsAccent() { return <div>beta accent</div>; }\n'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
67
|
+
return { root, designRoot };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('/_system-data — per-DS scope (DDR-048)', () => {
|
|
71
|
+
let proc: Subprocess | null = null;
|
|
72
|
+
let sb: MultiDsSandbox;
|
|
73
|
+
let port: number;
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
sb = makeMultiDsSandbox();
|
|
77
|
+
port = nextPort();
|
|
78
|
+
proc = await bootServer(sb.root, port);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
if (proc) await killProc(proc);
|
|
83
|
+
proc = null;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('?ds=alpha → tokens parsed from alpha colors_and_type.css', async () => {
|
|
87
|
+
const r = await fetch(`http://localhost:${port}/_system-data?ds=alpha`);
|
|
88
|
+
expect(r.status).toBe(200);
|
|
89
|
+
const data = (await r.json()) as {
|
|
90
|
+
tokens: { name: string; value: string }[];
|
|
91
|
+
ds: { name: string; description: string };
|
|
92
|
+
tokensPath: string;
|
|
93
|
+
previewGallery: { label: string }[];
|
|
94
|
+
};
|
|
95
|
+
expect(data.ds.name).toBe('alpha');
|
|
96
|
+
expect(data.ds.description).toBe('Alpha brand');
|
|
97
|
+
expect(data.tokensPath).toContain('system/alpha/colors_and_type.css');
|
|
98
|
+
const accent = data.tokens.find((t) => t.name === '--accent');
|
|
99
|
+
expect(accent?.value).toBe('#9CACFF');
|
|
100
|
+
const colorBg = data.tokens.find((t) => t.name === '--color-bg');
|
|
101
|
+
expect(colorBg?.value).toBe('#fbfaf7');
|
|
102
|
+
// Alpha previews only — no beta leakage.
|
|
103
|
+
expect(data.previewGallery.some((p) => p.label === 'colors-accent')).toBe(true);
|
|
104
|
+
expect(data.previewGallery.every((p) => !p.label.includes('beta'))).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('?ds=beta → distinct tokens, distinct previews', async () => {
|
|
108
|
+
const r = await fetch(`http://localhost:${port}/_system-data?ds=beta`);
|
|
109
|
+
expect(r.status).toBe(200);
|
|
110
|
+
const data = (await r.json()) as {
|
|
111
|
+
tokens: { name: string; value: string }[];
|
|
112
|
+
ds: { name: string };
|
|
113
|
+
tokensPath: string;
|
|
114
|
+
};
|
|
115
|
+
expect(data.ds.name).toBe('beta');
|
|
116
|
+
expect(data.tokensPath).toContain('system/beta/colors_and_type.css');
|
|
117
|
+
const accent = data.tokens.find((t) => t.name === '--accent');
|
|
118
|
+
expect(accent?.value).toBe('oklch(72% 0.16 55)');
|
|
119
|
+
const bg0 = data.tokens.find((t) => t.name === '--bg-0');
|
|
120
|
+
expect(bg0?.value).toBe('oklch(13% 0.012 60)');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('?ds=ghost → 404 + error payload (no silent shell fallback)', async () => {
|
|
124
|
+
const r = await fetch(`http://localhost:${port}/_system-data?ds=ghost`);
|
|
125
|
+
expect(r.status).toBe(404);
|
|
126
|
+
const body = (await r.json()) as { error: string; ds: string };
|
|
127
|
+
expect(body.error).toBe('unknown design system');
|
|
128
|
+
expect(body.ds).toBe('ghost');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('unscoped (no ?ds) → availableDesignSystems list + defaultDesignSystem', async () => {
|
|
132
|
+
const r = await fetch(`http://localhost:${port}/_system-data`);
|
|
133
|
+
expect(r.status).toBe(200);
|
|
134
|
+
const data = (await r.json()) as {
|
|
135
|
+
availableDesignSystems: { name: string; description: string | null }[];
|
|
136
|
+
defaultDesignSystem: string;
|
|
137
|
+
ds: unknown;
|
|
138
|
+
};
|
|
139
|
+
expect(data.availableDesignSystems).toHaveLength(2);
|
|
140
|
+
expect(data.availableDesignSystems.map((d) => d.name)).toEqual(['alpha', 'beta']);
|
|
141
|
+
expect(data.defaultDesignSystem).toBe('alpha');
|
|
142
|
+
expect(data.ds).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CommandRecord,
|
|
5
|
+
MAX_DEPTH,
|
|
6
|
+
_clearBuilderRegistry,
|
|
7
|
+
_clearStackStore,
|
|
8
|
+
canRedo,
|
|
9
|
+
canUndo,
|
|
10
|
+
createUndoStackState,
|
|
11
|
+
loadStackState,
|
|
12
|
+
peekRedo,
|
|
13
|
+
peekUndo,
|
|
14
|
+
rebuildCommand,
|
|
15
|
+
registerCommand,
|
|
16
|
+
saveStackState,
|
|
17
|
+
undoReducer,
|
|
18
|
+
} from '../undo-stack.ts';
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
_clearStackStore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function rec(label = 'fake', payload: unknown = null): CommandRecord {
|
|
25
|
+
return { kind: 'test', label, payload };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('undoReducer', () => {
|
|
29
|
+
test('push appends record to past and clears future', () => {
|
|
30
|
+
const a = rec('a');
|
|
31
|
+
const b = rec('b');
|
|
32
|
+
let s = createUndoStackState();
|
|
33
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
34
|
+
expect(s.past).toEqual([a]);
|
|
35
|
+
expect(s.future).toEqual([]);
|
|
36
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
37
|
+
expect(s.past).toEqual([a, b]);
|
|
38
|
+
expect(s.future).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('double push preserves order', () => {
|
|
42
|
+
const a = rec('a');
|
|
43
|
+
const b = rec('b');
|
|
44
|
+
const c = rec('c');
|
|
45
|
+
let s = createUndoStackState();
|
|
46
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
47
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
48
|
+
s = undoReducer(s, { type: 'push', record: c });
|
|
49
|
+
expect(s.past).toEqual([a, b, c]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('undo pops past top into future', () => {
|
|
53
|
+
const a = rec('a');
|
|
54
|
+
const b = rec('b');
|
|
55
|
+
let s = createUndoStackState();
|
|
56
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
57
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
58
|
+
s = undoReducer(s, { type: 'undo' });
|
|
59
|
+
expect(s.past).toEqual([a]);
|
|
60
|
+
expect(s.future).toEqual([b]);
|
|
61
|
+
expect(peekUndo(s)).toBe(a);
|
|
62
|
+
expect(peekRedo(s)).toBe(b);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('undo on empty is a no-op (identity)', () => {
|
|
66
|
+
const s = createUndoStackState();
|
|
67
|
+
const next = undoReducer(s, { type: 'undo' });
|
|
68
|
+
expect(next).toBe(s);
|
|
69
|
+
expect(canUndo(next)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('redo pops future top back into past', () => {
|
|
73
|
+
const a = rec('a');
|
|
74
|
+
let s = createUndoStackState();
|
|
75
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
76
|
+
s = undoReducer(s, { type: 'undo' });
|
|
77
|
+
s = undoReducer(s, { type: 'redo' });
|
|
78
|
+
expect(s.past).toEqual([a]);
|
|
79
|
+
expect(s.future).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('redo on empty future is a no-op', () => {
|
|
83
|
+
const a = rec('a');
|
|
84
|
+
let s = createUndoStackState();
|
|
85
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
86
|
+
const next = undoReducer(s, { type: 'redo' });
|
|
87
|
+
expect(next).toBe(s);
|
|
88
|
+
expect(canRedo(next)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('push discards future branch (canonical undo-stack semantics)', () => {
|
|
92
|
+
const a = rec('a');
|
|
93
|
+
const b = rec('b');
|
|
94
|
+
const c = rec('c');
|
|
95
|
+
let s = createUndoStackState();
|
|
96
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
97
|
+
s = undoReducer(s, { type: 'push', record: b });
|
|
98
|
+
s = undoReducer(s, { type: 'undo' });
|
|
99
|
+
expect(s.future.length).toBe(1);
|
|
100
|
+
s = undoReducer(s, { type: 'push', record: c });
|
|
101
|
+
expect(s.past).toEqual([a, c]);
|
|
102
|
+
expect(s.future).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('depth cap drops oldest from past (ring buffer)', () => {
|
|
106
|
+
let s = createUndoStackState();
|
|
107
|
+
const records: CommandRecord[] = [];
|
|
108
|
+
for (let i = 0; i < MAX_DEPTH + 5; i++) {
|
|
109
|
+
const r = rec(`r-${i}`);
|
|
110
|
+
records.push(r);
|
|
111
|
+
s = undoReducer(s, { type: 'push', record: r });
|
|
112
|
+
}
|
|
113
|
+
expect(s.past.length).toBe(MAX_DEPTH);
|
|
114
|
+
// biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
|
|
115
|
+
expect(s.past[0]).toBe(records[5]!);
|
|
116
|
+
// biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
|
|
117
|
+
expect(s.past[s.past.length - 1]).toBe(records[records.length - 1]!);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('clear resets both stacks', () => {
|
|
121
|
+
const a = rec('a');
|
|
122
|
+
let s = createUndoStackState();
|
|
123
|
+
s = undoReducer(s, { type: 'push', record: a });
|
|
124
|
+
s = undoReducer(s, { type: 'undo' });
|
|
125
|
+
s = undoReducer(s, { type: 'clear' });
|
|
126
|
+
expect(s.past).toEqual([]);
|
|
127
|
+
expect(s.future).toEqual([]);
|
|
128
|
+
expect(canUndo(s)).toBe(false);
|
|
129
|
+
expect(canRedo(s)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('hydrate replaces both stacks atomically', () => {
|
|
133
|
+
const a = rec('a');
|
|
134
|
+
const b = rec('b');
|
|
135
|
+
let s = createUndoStackState();
|
|
136
|
+
s = undoReducer(s, { type: 'hydrate', state: { past: [a, b], future: [a] } });
|
|
137
|
+
expect(s.past).toEqual([a, b]);
|
|
138
|
+
expect(s.future).toEqual([a]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('command builder registry', () => {
|
|
143
|
+
test('registerCommand + rebuildCommand round-trip', () => {
|
|
144
|
+
_clearBuilderRegistry();
|
|
145
|
+
registerCommand('test', (record, sinks) => {
|
|
146
|
+
const fn = sinks.layoutPatchFn as ((layout: unknown) => void) | undefined;
|
|
147
|
+
if (!fn) return null;
|
|
148
|
+
return {
|
|
149
|
+
kind: record.kind,
|
|
150
|
+
label: record.label,
|
|
151
|
+
do() {
|
|
152
|
+
fn(record.payload);
|
|
153
|
+
},
|
|
154
|
+
undo() {
|
|
155
|
+
fn({ undone: true });
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const record = rec('test-label', { foo: 'bar' });
|
|
160
|
+
const built = rebuildCommand(record, { layoutPatchFn: () => {} } as never);
|
|
161
|
+
expect(built).not.toBeNull();
|
|
162
|
+
expect(built?.label).toBe('test-label');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('rebuildCommand returns null for unknown kind', () => {
|
|
166
|
+
_clearBuilderRegistry();
|
|
167
|
+
const out = rebuildCommand({ kind: 'unknown', label: '?', payload: null }, {});
|
|
168
|
+
expect(out).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('rebuildCommand returns null when required sink is missing', () => {
|
|
172
|
+
_clearBuilderRegistry();
|
|
173
|
+
registerCommand('needs-sink', (_record, sinks) => {
|
|
174
|
+
if (!sinks.layoutPatchFn) return null;
|
|
175
|
+
return { kind: 'needs-sink', label: '', do() {}, undo() {} };
|
|
176
|
+
});
|
|
177
|
+
const out = rebuildCommand({ kind: 'needs-sink', label: '', payload: null }, {});
|
|
178
|
+
expect(out).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('cross-iframe persistence (loadStackState / saveStackState)', () => {
|
|
183
|
+
test('round-trips state under a canvas file key', () => {
|
|
184
|
+
const a = rec('a');
|
|
185
|
+
const initial = loadStackState('ui/Foo.tsx');
|
|
186
|
+
expect(initial).toEqual({ past: [], future: [] });
|
|
187
|
+
saveStackState('ui/Foo.tsx', { past: [a], future: [] });
|
|
188
|
+
expect(loadStackState('ui/Foo.tsx').past).toEqual([a]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('isolation across canvas files', () => {
|
|
192
|
+
saveStackState('ui/Foo.tsx', { past: [rec('foo')], future: [] });
|
|
193
|
+
saveStackState('ui/Bar.tsx', { past: [rec('bar-1'), rec('bar-2')], future: [] });
|
|
194
|
+
expect(loadStackState('ui/Foo.tsx').past.length).toBe(1);
|
|
195
|
+
expect(loadStackState('ui/Bar.tsx').past.length).toBe(2);
|
|
196
|
+
expect(loadStackState('ui/Baz.tsx').past).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('survives a "remount" simulation — load → save → reload yields same state', () => {
|
|
200
|
+
// Simulate: iframe A pushes 2 records, iframe A unmounts, iframe B
|
|
201
|
+
// mounts for the same canvas → sees both records.
|
|
202
|
+
saveStackState('ui/Persist.tsx', {
|
|
203
|
+
past: [rec('e1'), rec('e2')],
|
|
204
|
+
future: [rec('redo-me')],
|
|
205
|
+
});
|
|
206
|
+
// Pretend a remount happens — same window store, fresh load.
|
|
207
|
+
const reloaded = loadStackState('ui/Persist.tsx');
|
|
208
|
+
expect(reloaded.past.length).toBe(2);
|
|
209
|
+
expect(reloaded.future.length).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// use-cursor-modifiers — T28 (Wave 3). Pure reducer covers the cross-platform
|
|
2
|
+
// Ctrl≡Meta normalization + same-state short-circuit.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { type ModifierState, reduceModifiers } from '../use-cursor-modifiers.tsx';
|
|
7
|
+
|
|
8
|
+
const initial: ModifierState = { alt: false, shift: false, meta: false };
|
|
9
|
+
|
|
10
|
+
const k = (over: Partial<KeyboardEventInit & { ctrlKey: boolean }> = {}) => ({
|
|
11
|
+
altKey: false,
|
|
12
|
+
shiftKey: false,
|
|
13
|
+
metaKey: false,
|
|
14
|
+
ctrlKey: false,
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('reduceModifiers', () => {
|
|
19
|
+
test('no change → returns same reference (cheap equality)', () => {
|
|
20
|
+
const out = reduceModifiers(initial, k());
|
|
21
|
+
expect(out).toBe(initial);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('alt down → alt:true', () => {
|
|
25
|
+
const out = reduceModifiers(initial, k({ altKey: true }));
|
|
26
|
+
expect(out).toEqual({ alt: true, shift: false, meta: false });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('shift down → shift:true', () => {
|
|
30
|
+
const out = reduceModifiers(initial, k({ shiftKey: true }));
|
|
31
|
+
expect(out).toEqual({ alt: false, shift: true, meta: false });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('meta down → meta:true', () => {
|
|
35
|
+
const out = reduceModifiers(initial, k({ metaKey: true }));
|
|
36
|
+
expect(out).toEqual({ alt: false, shift: false, meta: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('ctrl is normalized to meta (cross-platform parity)', () => {
|
|
40
|
+
const out = reduceModifiers(initial, k({ ctrlKey: true }));
|
|
41
|
+
expect(out.meta).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('meta + ctrl together → meta:true (not duplicated)', () => {
|
|
45
|
+
const out = reduceModifiers(initial, k({ metaKey: true, ctrlKey: true }));
|
|
46
|
+
expect(out).toEqual({ alt: false, shift: false, meta: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('alt+shift simultaneously → both true', () => {
|
|
50
|
+
const out = reduceModifiers(initial, k({ altKey: true, shiftKey: true }));
|
|
51
|
+
expect(out).toEqual({ alt: true, shift: true, meta: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('releasing alt while shift still held → shift only', () => {
|
|
55
|
+
const both: ModifierState = { alt: true, shift: true, meta: false };
|
|
56
|
+
const out = reduceModifiers(both, k({ shiftKey: true })); // alt released
|
|
57
|
+
expect(out).toEqual({ alt: false, shift: true, meta: false });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// use-keyboard-discipline — T29 (Wave 3). Pure nudge-delta fixture.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { nudgeDelta } from '../use-keyboard-discipline.tsx';
|
|
6
|
+
|
|
7
|
+
describe('nudgeDelta', () => {
|
|
8
|
+
test('non-arrow key → null', () => {
|
|
9
|
+
expect(nudgeDelta({ key: 'a', shift: false })).toBeNull();
|
|
10
|
+
expect(nudgeDelta({ key: 'Tab', shift: false })).toBeNull();
|
|
11
|
+
expect(nudgeDelta({ key: 'Enter', shift: true })).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('arrow without shift → 1 px step', () => {
|
|
15
|
+
expect(nudgeDelta({ key: 'ArrowLeft', shift: false })).toEqual({ dx: -1, dy: 0 });
|
|
16
|
+
expect(nudgeDelta({ key: 'ArrowRight', shift: false })).toEqual({ dx: 1, dy: 0 });
|
|
17
|
+
expect(nudgeDelta({ key: 'ArrowUp', shift: false })).toEqual({ dx: 0, dy: -1 });
|
|
18
|
+
expect(nudgeDelta({ key: 'ArrowDown', shift: false })).toEqual({ dx: 0, dy: 1 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('arrow with shift → 10 px step', () => {
|
|
22
|
+
expect(nudgeDelta({ key: 'ArrowLeft', shift: true })).toEqual({ dx: -10, dy: 0 });
|
|
23
|
+
expect(nudgeDelta({ key: 'ArrowRight', shift: true })).toEqual({ dx: 10, dy: 0 });
|
|
24
|
+
expect(nudgeDelta({ key: 'ArrowUp', shift: true })).toEqual({ dx: 0, dy: -10 });
|
|
25
|
+
expect(nudgeDelta({ key: 'ArrowDown', shift: true })).toEqual({ dx: 0, dy: 10 });
|
|
26
|
+
});
|
|
27
|
+
});
|