@1agh/maude 0.43.0 → 0.45.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/commands/init.mjs +7 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -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
|
|
111
|
-
// plugins, so the row is satisfied even against a pristine
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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('
|
|
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
|
|
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('
|
|
129
|
+
expect(item.detail).toContain('Bundled with this app');
|
|
128
130
|
expect(item.remediation).toBeUndefined();
|
|
129
131
|
});
|
|
130
132
|
|
|
131
|
-
test('design
|
|
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('
|
|
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
|
|
364
|
-
//
|
|
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
|
-
|
|
371
|
-
? {
|
|
372
|
-
:
|
|
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
|
-
|
|
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;
|