@1agh/maude 0.43.0 → 0.44.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.
Files changed (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -0,0 +1,210 @@
1
+ // media-commit-chain — the Phase-23 batch-drop no-loss guarantee, isolated
2
+ // from React/DOM/real image decode timing so the race is testable
3
+ // deterministically (no real timers — every "completion" is a microtask-tick
4
+ // count, so the interleaving is exact and reproducible on every run).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { createMediaCommitChain } from '../media-commit-chain.ts';
9
+
10
+ interface Item {
11
+ id: string;
12
+ }
13
+
14
+ const keyOf = (it: Item) => it.id;
15
+
16
+ /** Resolve after exactly `n` microtask ticks (0 = next tick). */
17
+ function afterTicks(n: number): Promise<void> {
18
+ let p: Promise<void> = Promise.resolve();
19
+ for (let i = 0; i < n; i++) p = p.then(() => undefined);
20
+ return p;
21
+ }
22
+
23
+ describe('media-commit-chain / no-loss under concurrent completions', () => {
24
+ test('a synchronous burst of N enqueues (worst case — zero timing gap) keeps all N', async () => {
25
+ let snapshot: Item[] = [];
26
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
27
+ const committed: Item[][] = [];
28
+ const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
29
+ committed.push(after as Item[]);
30
+ snapshot = after as Item[];
31
+ };
32
+
33
+ // All 8 fire back-to-back in the same synchronous tick — the exact shape
34
+ // of use-canvas-media-drop's batch-dispatch loop calling onImage/onMedia
35
+ // once per file, each kicking off its own independent async chain that
36
+ // (in the old, buggy code) raced on a shared strokesRef.current read.
37
+ const promises = Array.from({ length: 8 }, (_, i) =>
38
+ chain.enqueue(
39
+ (before) => ({ after: [...before, { id: `item-${i}` }], label: `add ${i}` }),
40
+ onCommit
41
+ )
42
+ );
43
+ await Promise.all(promises);
44
+
45
+ const final = committed[committed.length - 1];
46
+ expect(final.length).toBe(8);
47
+ expect(new Set(final.map((it) => it.id)).size).toBe(8);
48
+ for (let i = 0; i < 8; i++) {
49
+ expect(final.some((it) => it.id === `item-${i}`)).toBe(true);
50
+ }
51
+ });
52
+
53
+ test('staggered/shuffled resolution order still accumulates every item exactly once', async () => {
54
+ let snapshot: Item[] = [];
55
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
56
+ const committed: Item[][] = [];
57
+ const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
58
+ committed.push(after as Item[]);
59
+ snapshot = after as Item[];
60
+ };
61
+
62
+ // A fixed (non-random, so non-flaky) permutation of tick-depths — some
63
+ // items resolve in the same tick as others, some land in between,
64
+ // deliberately out of index order (index 4 resolves before index 0).
65
+ const tickCounts = [4, 0, 6, 2, 7, 1, 5, 3];
66
+ const promises = tickCounts.map((ticks, i) =>
67
+ afterTicks(ticks).then(() =>
68
+ chain.enqueue(
69
+ (before) => ({ after: [...before, { id: `item-${i}` }], label: `add ${i}` }),
70
+ onCommit
71
+ )
72
+ )
73
+ );
74
+ await Promise.all(promises);
75
+
76
+ const final = committed[committed.length - 1];
77
+ expect(final.length).toBe(8);
78
+ expect(new Set(final.map((it) => it.id)).size).toBe(8);
79
+ });
80
+
81
+ test('skip (mutate returns null) leaves the accumulator untouched', async () => {
82
+ let snapshot: Item[] = [];
83
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
84
+ const committed: Item[][] = [];
85
+ const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
86
+ committed.push(after as Item[]);
87
+ snapshot = after as Item[];
88
+ };
89
+
90
+ await chain.enqueue((before) => ({ after: [...before, { id: 'a' }] }), onCommit);
91
+ const skipped = await chain.enqueue(() => null, onCommit);
92
+ expect(skipped.map((it) => it.id)).toEqual(['a']);
93
+ expect(committed.length).toBe(1); // the skipped mutation never called onCommit
94
+ });
95
+
96
+ test('a lagging snapshot (render has not caught up yet) does not lose the prior commit', async () => {
97
+ // This is the regression caught live against a real dev server (Task 5):
98
+ // getSnapshot() (strokesRef.current in production) only catches up on
99
+ // React's NEXT render, which can take longer than the handful of
100
+ // microtask ticks this chain itself needs to settle a link and go idle.
101
+ // `rendered` here deliberately never updates on its own — it models a
102
+ // render that hasn't happened yet by the time the second file's upload
103
+ // resolves and enqueues.
104
+ const rendered: Item[] = [];
105
+ const chain = createMediaCommitChain<Item>(() => rendered, keyOf);
106
+ const commits: Array<{ after: readonly Item[] }> = [];
107
+ const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
108
+ commits.push({ after });
109
+ };
110
+
111
+ await chain.enqueue((before) => ({ after: [...before, { id: 'video' }] }), onCommit);
112
+ // The chain is idle now (its one link has settled) — `rendered` is still
113
+ // [] (no render happened). The next enqueue must reconcile against what
114
+ // THIS chain last produced, not trust the stale empty snapshot.
115
+ const after = await chain.enqueue(
116
+ (before) => ({ after: [...before, { id: 'audio' }] }),
117
+ onCommit
118
+ );
119
+
120
+ expect(after.map((it) => it.id).sort()).toEqual(['audio', 'video']);
121
+ });
122
+
123
+ test('external edits made while idle (snapshot ahead of remembered) are preserved', async () => {
124
+ let snapshot: Item[] = [{ id: 'external' }];
125
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
126
+ const commits: Array<{ before: readonly Item[]; after: readonly Item[] }> = [];
127
+ const onCommit = (before: readonly Item[], after: readonly Item[]) => {
128
+ commits.push({ before, after });
129
+ };
130
+
131
+ await chain.enqueue((before) => ({ after: [...before, { id: 'a' }] }), onCommit);
132
+ // Simulate a synchronous, non-chained edit that landed while idle (e.g. a
133
+ // pen stroke committed directly, its render already caught up) — the
134
+ // next chain link must see it via the fresh snapshot.
135
+ snapshot = [...snapshot, { id: 'a' }, { id: 'pen-stroke' }];
136
+ await chain.enqueue((before) => ({ after: [...before, { id: 'b' }] }), onCommit);
137
+
138
+ const last = commits[commits.length - 1];
139
+ expect(last.after.some((it) => it.id === 'pen-stroke')).toBe(true);
140
+ expect(last.after.some((it) => it.id === 'b')).toBe(true);
141
+ });
142
+
143
+ test('a snapshot with a STALE VALUE for a chain-owned id (not just a missing one) does not revert it', async () => {
144
+ // The real bug this closes: `fresh` (getSnapshot) can contain the SAME
145
+ // id the chain just committed, but with a stale value — e.g. an image
146
+ // stroke's optimistic (blob:) href is set via a separate, non-chain
147
+ // setState, and `fresh` catches up to THAT before it catches up to the
148
+ // chain's own later commit swapping it to the real href. Checking only
149
+ // "is the id present" (the previous implementation) treated this as
150
+ // "already rendered, trust fresh" and used the stale copy — silently
151
+ // reverting the swap, which then gets filtered out entirely as
152
+ // still-ephemeral at the persistence layer one level up. Confirmed live
153
+ // against a real dev server: a single 3-file drop lost exactly one file
154
+ // most of the time, always the one whose swap commit landed while
155
+ // `fresh` still only reflected its pre-swap optimistic value.
156
+ const snapshot: Item[] = [{ id: 'img-optimistic', href: 'blob:x' } as unknown as Item];
157
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
158
+ const commits: Array<{ after: readonly Item[] }> = [];
159
+ const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
160
+ commits.push({ after });
161
+ };
162
+
163
+ // The chain's own commit swaps the id to its real value...
164
+ await chain.enqueue(
165
+ (before) => ({
166
+ after: before.map((it) =>
167
+ it.id === 'img-optimistic' ? { ...it, href: 'assets/real.png' } : it
168
+ ),
169
+ }),
170
+ onCommit
171
+ );
172
+ // ...but `snapshot` (fresh) hasn't re-rendered to reflect that swap yet
173
+ // — it still shows the SAME id with the stale, optimistic href. This
174
+ // models the render lag, not a delete/re-add.
175
+
176
+ const after = await chain.enqueue(
177
+ (before) => ({ after: [...before, { id: 'sibling' }] }),
178
+ onCommit
179
+ );
180
+
181
+ const img = after.find((it) => it.id === 'img-optimistic') as unknown as { href: string };
182
+ expect(img?.href).toBe('assets/real.png');
183
+ expect(after.some((it) => it.id === 'sibling')).toBe(true);
184
+ });
185
+
186
+ test('commitBefore overrides the before passed to onCommit without affecting the accumulator', async () => {
187
+ const snapshot: Item[] = [{ id: 'optimistic' }];
188
+ const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
189
+ const commits: Array<{ before: readonly Item[]; after: readonly Item[] }> = [];
190
+ const onCommit = (before: readonly Item[], after: readonly Item[]) => {
191
+ commits.push({ before, after });
192
+ };
193
+
194
+ // Mirrors createImageFromFile's swap: "before" (accumulator) still
195
+ // contains the ephemeral optimistic entry, but the undo record's
196
+ // `before` should exclude it (undo removes the image outright instead
197
+ // of restoring a revoked blob: URL).
198
+ const after = await chain.enqueue(
199
+ (before) => ({
200
+ after: before.map((it) => (it.id === 'optimistic' ? { id: 'real' } : it)),
201
+ commitBefore: before.filter((it) => it.id !== 'optimistic'),
202
+ }),
203
+ onCommit
204
+ );
205
+
206
+ expect(after).toEqual([{ id: 'real' }]);
207
+ expect(commits[0].before).toEqual([]);
208
+ expect(commits[0].after).toEqual([{ id: 'real' }]);
209
+ });
210
+ });
@@ -0,0 +1,71 @@
1
+ // photo-bg-remove.sh --asset validation — fix-photo-editor-followup-debt Task
2
+ // 21. Drives the real bash script as a subprocess (mirrors annotate-write.test.ts's
3
+ // spawn-a-bin pattern) against a temp design root, asserting the strict
4
+ // `assets/<sha8>.<ext>` shape check (Task 1) rejects path traversal and
5
+ // injection-shaped values with exit 2, while a well-formed reference proceeds
6
+ // PAST validation (a missing on-disk asset then fails for a different, later
7
+ // reason — exit 1 — proving the regex, not the filesystem check, isn't what
8
+ // stopped it).
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
11
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const BIN = new URL('../bin/photo-bg-remove.sh', import.meta.url).pathname;
16
+
17
+ let root: string;
18
+
19
+ beforeAll(() => {
20
+ root = mkdtempSync(join(tmpdir(), 'photo-bg-remove-test-'));
21
+ mkdirSync(join(root, '.design'), { recursive: true });
22
+ });
23
+
24
+ afterAll(() => {
25
+ rmSync(root, { recursive: true, force: true });
26
+ });
27
+
28
+ function run(asset: string): { code: number; err: string } {
29
+ const proc = Bun.spawnSync(['bash', BIN, '--asset', asset, '--root', root]);
30
+ return {
31
+ code: proc.exitCode,
32
+ err: new TextDecoder().decode(proc.stderr),
33
+ };
34
+ }
35
+
36
+ describe('photo-bg-remove.sh --asset validation', () => {
37
+ test('rejects path traversal', () => {
38
+ const res = run('assets/../../../etc/passwd');
39
+ expect(res.code).toBe(2);
40
+ expect(res.err).toContain('must look like assets/<sha8>.<ext>');
41
+ });
42
+
43
+ test('rejects an injection-shaped value (quotes/braces breaking out of the JSX splice)', () => {
44
+ const res = run('assets/aaaaaaaa.png" }; alert(1); ({ "y.png');
45
+ expect(res.code).toBe(2);
46
+ });
47
+
48
+ test('rejects backtick-embedded values', () => {
49
+ const res = run('assets/aaaaaaaa$(whoami).png');
50
+ expect(res.code).toBe(2);
51
+ });
52
+
53
+ test('rejects uppercase hex (sha8 is always lowercase in this codebase)', () => {
54
+ const res = run('assets/DEADBEEF.png');
55
+ expect(res.code).toBe(2);
56
+ });
57
+
58
+ test('rejects a bare prefix with no hex body', () => {
59
+ const res = run('assets/');
60
+ expect(res.code).toBe(2);
61
+ });
62
+
63
+ test('a well-formed reference proceeds past validation (fails later, not with exit 2)', () => {
64
+ const res = run('assets/deadbeef.png');
65
+ expect(res.code).not.toBe(2);
66
+ // No asset on disk under this temp root — fails at the existence check
67
+ // (exit 1), proving the regex accepted the shape and let it through.
68
+ expect(res.code).toBe(1);
69
+ expect(res.err).toContain('asset not found');
70
+ });
71
+ });
@@ -107,33 +107,35 @@ describe('readiness — plugin registry scan (web / manual path)', () => {
107
107
  });
108
108
  });
109
109
 
110
- // DDR-143 — on the native/desktop path the ACP session auto-loads the bundled
111
- // plugins, so the row is satisfied even against a pristine registry. This suite
112
- // runs in the dev tree, so the bundled plugin dirs resolve native context is
113
- // on by default (no opt-out).
114
- describe('readiness plugin auto-bootstrap (native, DDR-143)', () => {
115
- test('pristine registry present + "Auto-loaded" detail, no manual remediation', async () => {
110
+ // DDR-143 / DDR-168 — on the native/desktop path the ACP session ALWAYS loads
111
+ // the bundled plugins, so the row is satisfied even against a pristine
112
+ // registry, AND even when a marketplace copy is also installed at the user
113
+ // level (DDR-168 removed the old scan-gated no-op — the bundled copy wins
114
+ // regardless of disk state). This suite runs in the dev tree, so the bundled
115
+ // plugin dirs resolve native context is on by default (no opt-out).
116
+ describe('readiness — plugin auto-bootstrap (native, DDR-143/DDR-168)', () => {
117
+ test('pristine registry → present + "Bundled with this app" detail, no manual remediation', async () => {
116
118
  const item = await pluginsItem(fixtureClaudeDir({}));
117
119
  expect(item.status).toBe('present');
118
- expect(item.detail).toContain('Auto-loaded');
120
+ expect(item.detail).toContain('Bundled with this app');
119
121
  expect(item.remediation).toBeUndefined();
120
122
  });
121
123
 
122
- test('design NOT installed (flow-only registry) → still present (design is auto-loaded, no red wall)', async () => {
124
+ test('design NOT installed (flow-only registry) → still present (design is bundled, no red wall)', async () => {
123
125
  const item = await pluginsItem(
124
126
  fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_FLOW_ONLY })
125
127
  );
126
128
  expect(item.status).toBe('present');
127
- expect(item.detail).toContain('Auto-loaded');
129
+ expect(item.detail).toContain('Bundled with this app');
128
130
  expect(item.remediation).toBeUndefined();
129
131
  });
130
132
 
131
- test('design already installed → present, keeps the "is installed" detail (no auto-load framing)', async () => {
133
+ test('design ALSO installed on disk → present, STILL reads as bundled (DDR-168 — no more "is installed" framing)', async () => {
132
134
  const item = await pluginsItem(
133
135
  fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
134
136
  );
135
137
  expect(item.status).toBe('present');
136
- expect(item.detail).toContain('installed');
138
+ expect(item.detail).toContain('Bundled with this app');
137
139
  });
138
140
  });
139
141
 
@@ -0,0 +1,122 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { scaffoldDesign } from '../scaffold-design.ts';
6
+
7
+ // DDR-166 Phase 1 — scaffoldDesign() now seeds THREE canvases (Welcome +
8
+ // the two onboarding reference canvases), embedded as escaped template
9
+ // literals. A bug in that escaping (an unescaped backtick/${ from the
10
+ // source .design/ui/*.tsx files) would silently truncate the written file
11
+ // into broken JS at scaffold time — every future new project would boot to
12
+ // a corrupt canvas. Exercise the REAL function against a REAL temp dir, not
13
+ // a mock, so a truncation shows up as a parse failure below.
14
+ describe('scaffoldDesign — onboarding canvas seeding', () => {
15
+ function scaffold() {
16
+ const dir = mkdtempSync(join(tmpdir(), 'maude-scaffold-test-'));
17
+ const result = scaffoldDesign(dir, 'Scaffold Test');
18
+ return { dir, result };
19
+ }
20
+
21
+ test('writes ok:true and all three seeded canvases + their meta sidecars', () => {
22
+ const { dir, result } = scaffold();
23
+ try {
24
+ expect(result.ok).toBe(true);
25
+ for (const name of ['Welcome', 'How to use Maude', 'How to make video']) {
26
+ expect(existsSync(join(dir, '.design', 'ui', `${name}.tsx`))).toBe(true);
27
+ expect(existsSync(join(dir, '.design', 'ui', `${name}.meta.json`))).toBe(true);
28
+ }
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test('the two onboarding canvases are non-trivially sized (catches a silent truncation)', () => {
35
+ const { dir } = scaffold();
36
+ try {
37
+ const useMaude = readFileSync(join(dir, '.design', 'ui', 'How to use Maude.tsx'), 'utf8');
38
+ const makeVideo = readFileSync(join(dir, '.design', 'ui', 'How to make video.tsx'), 'utf8');
39
+ // Real sizes are ~30KB/~24KB; a truncated-at-the-first-unescaped-backtick
40
+ // bug would cut this down to a few hundred bytes at most.
41
+ expect(useMaude.length).toBeGreaterThan(10_000);
42
+ expect(makeVideo.length).toBeGreaterThan(10_000);
43
+ // Both files must close their own template structure — the default
44
+ // export is the last thing in each source file.
45
+ expect(useMaude.trim().endsWith('}')).toBe(true);
46
+ expect(makeVideo.trim().endsWith('}')).toBe(true);
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test('every seeded .tsx parses as valid TSX (Bun.Transpiler) and has exactly one default export', () => {
53
+ const { dir } = scaffold();
54
+ try {
55
+ const transpiler = new Bun.Transpiler({ loader: 'tsx' });
56
+ for (const name of ['Welcome', 'How to use Maude', 'How to make video']) {
57
+ const src = readFileSync(join(dir, '.design', 'ui', `${name}.tsx`), 'utf8');
58
+ expect(() => transpiler.transformSync(src)).not.toThrow();
59
+ expect(src).toMatch(/export default function \w+/);
60
+ }
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('every seeded meta.json is valid JSON with a matching artboards list', () => {
67
+ const { dir } = scaffold();
68
+ try {
69
+ // Welcome's meta nests artboards under sections[0] (the older, still-live
70
+ // convention); the two onboarding canvases use a top-level `artboards`.
71
+ const welcome = JSON.parse(
72
+ readFileSync(join(dir, '.design', 'ui', 'Welcome.meta.json'), 'utf8')
73
+ );
74
+ expect(welcome.sections[0].artboards.length).toBe(1);
75
+ for (const [name, count] of [
76
+ ['How to use Maude', 10],
77
+ ['How to make video', 7],
78
+ ] as const) {
79
+ const meta = JSON.parse(
80
+ readFileSync(join(dir, '.design', 'ui', `${name}.meta.json`), 'utf8')
81
+ );
82
+ expect(Array.isArray(meta.artboards)).toBe(true);
83
+ expect(meta.artboards.length).toBe(count);
84
+ }
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test('the two onboarding canvases self-declare their own tokens — no system/maude/ import', () => {
91
+ // Real bug caught while authoring this feature: the canvases originally
92
+ // imported system/maude/colors_and_type.css + preview/_components.css,
93
+ // which do NOT exist on a brand-new project (system/ is empty until
94
+ // /design:setup-ds runs) — every var(--token) would resolve to nothing,
95
+ // silently rendering unstyled black-on-black. Assert the fix holds.
96
+ const { dir } = scaffold();
97
+ try {
98
+ for (const name of ['How to use Maude', 'How to make video']) {
99
+ const src = readFileSync(join(dir, '.design', 'ui', `${name}.tsx`), 'utf8');
100
+ // Check for the actual import STATEMENT, not just the path appearing
101
+ // in an explanatory doc comment (which legitimately mentions it).
102
+ expect(src).not.toContain('import "../system/maude');
103
+ // The self-contained token block must still be present.
104
+ expect(src).toContain('const TOKENS');
105
+ expect(src).toContain('--bg-0');
106
+ }
107
+ } finally {
108
+ rmSync(dir, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('refuses to clobber an existing project (existing behavior, unchanged)', () => {
113
+ const { dir } = scaffold();
114
+ try {
115
+ const second = scaffoldDesign(dir, 'Scaffold Test');
116
+ expect(second.ok).toBe(false);
117
+ expect(second.error).toMatch(/already a Maude project/i);
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
122
+ });
@@ -8,6 +8,7 @@
8
8
  import { describe, expect, test } from 'bun:test';
9
9
  import { renderToStaticMarkup } from 'react-dom/server';
10
10
  import { TourOverlay } from '../client/tour/overlay.jsx';
11
+ import { QUICK_SETUP_TOUR } from '../client/tour/quick-setup-tour.js';
11
12
 
12
13
  const STEP = { target: '.x', title: 'Welcome', body: 'Body copy here' };
13
14
 
@@ -61,6 +62,21 @@ describe('TourOverlay', () => {
61
62
  expect(html).toContain('Working together');
62
63
  });
63
64
 
65
+ test('QUICK_SETUP_TOUR (DDR-166 plan, Phase 2 / T7) is a well-formed, renderable deck', () => {
66
+ expect(QUICK_SETUP_TOUR.length).toBeGreaterThan(1);
67
+ for (const step of QUICK_SETUP_TOUR) {
68
+ expect(typeof step.title).toBe('string');
69
+ expect(step.title.length).toBeGreaterThan(0);
70
+ expect(typeof step.body).toBe('string');
71
+ expect(step.body.length).toBeGreaterThan(0);
72
+ }
73
+ const html = renderToStaticMarkup(
74
+ <TourOverlay open steps={QUICK_SETUP_TOUR} onClose={() => {}} onComplete={() => {}} />
75
+ );
76
+ expect(html).toContain('mdcc-tour__title');
77
+ expect(html).toContain(`1 / ${QUICK_SETUP_TOUR.length}`);
78
+ });
79
+
64
80
  test('closed or empty renders nothing', () => {
65
81
  expect(
66
82
  renderToStaticMarkup(
@@ -360,16 +360,21 @@ export function ToolPalette() {
360
360
  // artboard itself (`resolveInsertAnchor`). Same request verbs the
361
361
  // context-menu's per-element "Insert ▸ …" already posts (`insert-request` /
362
362
  // `insert-image-request`, main-origin shell WRITES — DDR-054), extended with
363
- // an `artboardId` field for the fallback case; a null anchor (no artboard on
364
- // canvas at all) is still a no-op nothing to insert into.
363
+ // an `artboardId` field for the fallback case; a null anchor is still a
364
+ // no-op for div/text (nothing to insert into) but NOT for image —
365
+ // feature-bulk-media-insert still posts `insert-image-request` with no
366
+ // refId/artboardId, so an artboard-less canvas opens the picker (in
367
+ // annotation-only mode, Task 7) instead of silently doing nothing.
365
368
  const insertViaPalette = (kind: 'div' | 'text' | 'image') => {
366
369
  setInsertOpen(false);
367
370
  const anchor = resolveInsertAnchor();
368
- if (!anchor) return;
371
+ if (!anchor && kind !== 'image') return;
369
372
  const anchorFields =
370
- 'artboardId' in anchor
371
- ? { artboardId: anchor.artboardId, position: anchor.position }
372
- : { refId: anchor.refId, position: anchor.position };
373
+ anchor == null
374
+ ? {}
375
+ : 'artboardId' in anchor
376
+ ? { artboardId: anchor.artboardId, position: anchor.position }
377
+ : { refId: anchor.refId, position: anchor.position };
373
378
  try {
374
379
  if (kind === 'image') {
375
380
  window.parent.postMessage({ dgn: 'insert-image-request', ...anchorFields }, '*');
@@ -0,0 +1,130 @@
1
+ // ui-prefs.ts — disk-backed UI / view preferences (feature-unified-settings-modal).
2
+ //
3
+ // The Settings modal's non-secret UI prefs (theme + the Canvas & View toggles)
4
+ // persist here so they survive a restart AND a cleared browser localStorage
5
+ // (the native WKWebView shell is the case that motivated an explicit on-disk
6
+ // store rather than trusting localStorage alone). Stored GLOBALLY per user, not
7
+ // per-project — these are user preferences, not canvas state — at
8
+ // `~/.config/maude/prefs.json` (XDG-aware, same location discipline as
9
+ // generation/keys.ts's keys.json and sync/hubs-config.ts's hubs.json).
10
+ //
11
+ // NON-SECRET by construction: this file only ever holds boolean/enum view
12
+ // toggles. It is never versioned and never served to a canvas — the GET/POST
13
+ // routes are MAIN-ORIGIN ONLY (privileged), like /_api/generate/prefs.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ /** The dockable shell panels the Layout tab can move between the two sides. */
20
+ export const DOCK_PANEL_IDS = [
21
+ 'tree',
22
+ 'layers',
23
+ 'inspector',
24
+ 'comments',
25
+ 'changes',
26
+ 'assistant',
27
+ ] as const;
28
+ export type DockPanelId = (typeof DOCK_PANEL_IDS)[number];
29
+ export type DockSide = 'left' | 'right';
30
+ export type PanelSides = Record<DockPanelId, DockSide>;
31
+
32
+ export interface UiPrefs {
33
+ theme: 'light' | 'dark';
34
+ minimap: boolean;
35
+ zoom: boolean;
36
+ annotations: boolean;
37
+ autoOpenInspector: boolean;
38
+ /** Which side each dockable panel lives on (feature-configurable-panel-docking). */
39
+ panelSides: PanelSides;
40
+ /** Whether Layers is its own dockable panel or a tab inside the Inspector. */
41
+ layersMode: 'separate' | 'in-inspector';
42
+ }
43
+
44
+ // Defaults MUST agree with app.jsx's initial state (THEME default 'dark',
45
+ // MINIMAP/ZOOMCTL false, annotations on, auto-open-inspector on) and with
46
+ // use-chrome-visibility.tsx's provider defaults. Panel defaults mirror the
47
+ // pre-docking shell: tree + layers on the left, everything else on the right;
48
+ // Layers ships as its own panel (`separate`), docked left.
49
+ export const PANEL_SIDES_DEFAULTS: PanelSides = {
50
+ tree: 'left',
51
+ layers: 'left',
52
+ inspector: 'right',
53
+ comments: 'right',
54
+ changes: 'right',
55
+ assistant: 'right',
56
+ };
57
+ export const UI_PREFS_DEFAULTS: UiPrefs = {
58
+ theme: 'dark',
59
+ minimap: false,
60
+ zoom: false,
61
+ annotations: true,
62
+ autoOpenInspector: true,
63
+ panelSides: { ...PANEL_SIDES_DEFAULTS },
64
+ layersMode: 'separate',
65
+ };
66
+
67
+ /** Resolve the on-disk path to prefs.json (mirrors keys.ts's XDG logic). */
68
+ export function uiPrefsPath(): string {
69
+ if (process.env.MAUDE_UI_PREFS_PATH) return process.env.MAUDE_UI_PREFS_PATH;
70
+ const xdg = process.env.XDG_CONFIG_HOME;
71
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
72
+ return join(base, 'maude', 'prefs.json');
73
+ }
74
+
75
+ function coercePanelSides(raw: unknown): PanelSides {
76
+ const o =
77
+ raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
78
+ const out = { ...PANEL_SIDES_DEFAULTS };
79
+ for (const id of DOCK_PANEL_IDS) {
80
+ if (o[id] === 'left' || o[id] === 'right') out[id] = o[id] as DockSide;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function coerce(raw: unknown): UiPrefs {
86
+ const o =
87
+ raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
88
+ const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d);
89
+ return {
90
+ theme: o.theme === 'light' || o.theme === 'dark' ? o.theme : UI_PREFS_DEFAULTS.theme,
91
+ minimap: bool(o.minimap, UI_PREFS_DEFAULTS.minimap),
92
+ zoom: bool(o.zoom, UI_PREFS_DEFAULTS.zoom),
93
+ annotations: bool(o.annotations, UI_PREFS_DEFAULTS.annotations),
94
+ autoOpenInspector: bool(o.autoOpenInspector, UI_PREFS_DEFAULTS.autoOpenInspector),
95
+ panelSides: coercePanelSides(o.panelSides),
96
+ layersMode: o.layersMode === 'in-inspector' ? 'in-inspector' : UI_PREFS_DEFAULTS.layersMode,
97
+ };
98
+ }
99
+
100
+ /** Current UI prefs merged over the defaults (defaults when missing/unreadable). */
101
+ export function readUiPrefs(): UiPrefs {
102
+ const path = uiPrefsPath();
103
+ if (!existsSync(path)) return { ...UI_PREFS_DEFAULTS };
104
+ try {
105
+ return coerce(JSON.parse(readFileSync(path, 'utf8')));
106
+ } catch {
107
+ return { ...UI_PREFS_DEFAULTS };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Persist a partial patch over the on-disk prefs (only the provided keys change;
113
+ * every other stored value is preserved). Returns the merged result. Best-effort
114
+ * — a write failure throws so the route can surface it.
115
+ */
116
+ export function writeUiPrefs(patch: Partial<UiPrefs>): UiPrefs {
117
+ const cur = readUiPrefs();
118
+ // panelSides is deep-merged so a partial patch (one panel moved) preserves the
119
+ // other panels' sides instead of resetting them to defaults via coerce.
120
+ const merged: Partial<UiPrefs> = {
121
+ ...cur,
122
+ ...patch,
123
+ panelSides: { ...cur.panelSides, ...(patch.panelSides ?? {}) },
124
+ };
125
+ const next = coerce(merged);
126
+ const path = uiPrefsPath();
127
+ mkdirSync(dirname(path), { recursive: true });
128
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
129
+ return next;
130
+ }
@@ -302,8 +302,10 @@ export async function uploadAndAnnounceMedia(
302
302
  }
303
303
 
304
304
  /** World-px stagger between cascaded drop targets so a batch Finder drop
305
- * doesn't stack every clip on the exact same point. */
306
- const BATCH_DROP_CASCADE_PX = 28;
305
+ * doesn't stack every clip on the exact same point. Exported — the picker's
306
+ * batched "Add as annotation" insert (annotations-layer.tsx) reuses the same
307
+ * stagger so both entry points cascade identically. */
308
+ export const BATCH_DROP_CASCADE_PX = 28;
307
309
 
308
310
  export function useCanvasMediaDrop(opts: {
309
311
  enabled: boolean;