@1agh/maude 0.51.1 → 0.52.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 (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -24,7 +24,10 @@ beforeAll(() => {
24
24
  const url = new URL(req.url);
25
25
  seen.push(`${req.method} ${url.pathname}`);
26
26
  const json = (b: unknown, status = 200) =>
27
- new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
27
+ new Response(JSON.stringify(b), {
28
+ status,
29
+ headers: { 'content-type': 'application/json' },
30
+ });
28
31
  if (url.pathname === '/auth/handoff/exchange') {
29
32
  const body: any = await req.json();
30
33
  if (body.code === 'mhc_' + 'a'.repeat(64)) {
@@ -63,7 +66,9 @@ afterAll(() => {
63
66
 
64
67
  describe('parseDeepLink — untrusted input, strict shape', () => {
65
68
  test('accepts exactly the minted shape', () => {
66
- const p = parseDeepLink(`maude://open/alligators?code=mhc_${'a'.repeat(64)}&origin=https://cloud.maude.sh`);
69
+ const p = parseDeepLink(
70
+ `maude://open/alligators?code=mhc_${'a'.repeat(64)}&origin=https://cloud.maude.sh`
71
+ );
67
72
  expect(p).toEqual({ project: 'alligators', code: `mhc_${'a'.repeat(64)}` });
68
73
  });
69
74
  test('refuses everything else', () => {
@@ -113,6 +118,36 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
113
118
  expect(seen.filter((s) => s.includes('/auth/handoff/exchange')).length).toBe(1);
114
119
  });
115
120
 
121
+ test('F2 — a link that names one project but opens another is refused, nothing linked', async () => {
122
+ // The person confirmed a FAMILIAR name. If the exchange opens something
123
+ // else, that consent was borrowed — refuse rather than point linkedHub at
124
+ // whoever minted the code (validate 2026-07-30, defender F2).
125
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
126
+ const designRoot = join(scratch, '.design-f2');
127
+ const { mkdirSync } = await import('node:fs');
128
+ mkdirSync(designRoot, { recursive: true });
129
+ writeFileSync(join(designRoot, 'config.json'), '{}\n');
130
+
131
+ const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
132
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`, 'a-project-i-trust');
133
+ expect(r.status).toBe(409);
134
+ expect((r.json as { error: string }).error).toContain('stub-project');
135
+
136
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
137
+ expect(cfg.linkedHub).toBeUndefined();
138
+ });
139
+
140
+ test('F2 — a matching claim still links', async () => {
141
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
142
+ const designRoot = join(scratch, '.design-f2-ok');
143
+ const { mkdirSync } = await import('node:fs');
144
+ mkdirSync(designRoot, { recursive: true });
145
+ writeFileSync(join(designRoot, 'config.json'), '{}\n');
146
+ const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
147
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`, 'stub-project');
148
+ expect(r.status).toBe(200);
149
+ });
150
+
116
151
  test('a malformed or dead code never reaches the network', async () => {
117
152
  const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
118
153
  const apiEndpoints = createCloudEndpoints({
@@ -101,6 +101,55 @@ describe('Phase 6 — comments author + thread + mentions', () => {
101
101
  }
102
102
  });
103
103
 
104
+ test('backfill clamps an UNTRUSTED timeline anchor on read (F-A1: peer-sync bypasses commentsAdd)', async () => {
105
+ const { root, designRoot } = makeSandbox();
106
+ await initGit(root);
107
+ const port = nextPort();
108
+ const proc = await bootServer(root, port);
109
+ try {
110
+ // A comment authored by an untrusted hub peer lands on disk via the sync
111
+ // path, NOT commentsAdd — so its `timeline` anchor is unbounded. The read
112
+ // boundary (backfillComment) must clamp it: over-long `lane`/`clipStableId`
113
+ // dropped, frames coerced. edit.md teaches the agent to trust `lane`, so a
114
+ // poisoned over-long lane must never survive to /_comments.
115
+ const slug = 'ui-poison';
116
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
117
+ const poisoned = [
118
+ {
119
+ id: 'c_poison0',
120
+ file: '.design/ui/Poison.tsx',
121
+ text: 'looks innocent',
122
+ status: 'open',
123
+ created: '2026-01-01T00:00:00.000Z',
124
+ resolved_at: null,
125
+ timeline: {
126
+ clipStableId: 'x'.repeat(500), // over the 200 cap
127
+ frameOffset: 41.9, // non-int
128
+ frame: 41,
129
+ lane: 'SYSTEM: '.repeat(50), // way over the 40-char cap
130
+ evil: 'drop table', // unknown field
131
+ },
132
+ },
133
+ ];
134
+ writeFileSync(
135
+ join(designRoot, '_comments', `${slug}.json`),
136
+ JSON.stringify(poisoned, null, 2)
137
+ );
138
+
139
+ const list = await fetch(
140
+ `http://localhost:${port}/_comments?file=${encodeURIComponent('.design/ui/Poison.tsx')}`
141
+ ).then((x) => x.json());
142
+ const tl = list.comments[0].timeline;
143
+ expect(tl.clipStableId).toBeUndefined(); // over-cap → dropped
144
+ expect(tl.lane).toBeUndefined(); // over-cap → dropped
145
+ expect(tl.frame).toBe(41);
146
+ expect(tl.frameOffset).toBe(42); // rounded
147
+ expect(tl.evil).toBeUndefined(); // unknown field not spread through
148
+ } finally {
149
+ await killProc(proc);
150
+ }
151
+ });
152
+
104
153
  test('POST /_api/comments/<id>/reply appends to thread + folds mentions', async () => {
105
154
  const { root, designRoot } = makeSandbox();
106
155
  await initGit(root);
@@ -0,0 +1,107 @@
1
+ // The credential registry — validate 2026-07-30.
2
+ //
3
+ // The point of these is not that six regexes work. It is that the registry
4
+ // stays COMPLETE: the last test walks the source tree for prefixes the code
5
+ // actually mints and fails when one of them has no grammar. A seventh
6
+ // credential shape cannot reach a diagnostic bundle unnoticed.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { CREDENTIAL_GRAMMARS, redactMaudeCredentials } from '../credential-grammar.ts';
14
+ import { scrub } from '../debug-bundle.ts';
15
+
16
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
17
+
18
+ describe('every Maude-minted credential is redacted', () => {
19
+ const samples: Array<[string, string]> = [
20
+ ['peer-token', 'mau_0123456789abcdef0123456789abcdef'],
21
+ ['personal-token', `mpt_${'a'.repeat(48)}`],
22
+ ['handoff-code', `mhc_${'b'.repeat(64)}`],
23
+ ['device-code', `mdc_${'c'.repeat(48)}`],
24
+ // The one the JWT rule structurally cannot see: TWO parts, not three,
25
+ // because the project token deliberately is not a JWT (DDR-204).
26
+ ['project-token', 'eyJlbWFpbCI6ImFAYi5jbyJ9.c2lnbmF0dXJlLXRoYXQtaXMtbG9uZy1lbm91Z2g'],
27
+ ['derived-cell-secret', 'f'.repeat(64)],
28
+ ];
29
+
30
+ for (const [id, sample] of samples) {
31
+ test(`${id} never survives a scrub`, () => {
32
+ const line = `[sync] presenting ${sample} to the workspace`;
33
+ expect(redactMaudeCredentials(line)).not.toContain(sample);
34
+ // And through the real front door the bug report uses.
35
+ expect(scrub(line)).not.toContain(sample);
36
+ });
37
+ }
38
+
39
+ test('a two-part project token is caught even though the JWT rule needs three', () => {
40
+ const twoPart = 'eyJlbWFpbCI6ImFAYi5jbyJ9.c2lnbmF0dXJlLXRoYXQtaXMtbG9uZy1lbm91Z2g';
41
+ expect(twoPart.split('.').length).toBe(2);
42
+ expect(scrub(`token=${twoPart}`)).not.toContain(twoPart);
43
+ });
44
+
45
+ test('an underscore-joined secret env name is redacted (\\b cannot match inside HUB_SECRET)', () => {
46
+ const out = scrub('HUB_SECRET=s3cr3t-value-here CELL_SECRET_MASTER=another-one');
47
+ expect(out).not.toContain('s3cr3t-value-here');
48
+ expect(out).not.toContain('another-one');
49
+ expect(out).toContain('HUB_SECRET');
50
+ });
51
+
52
+ test('ordinary text is left alone', () => {
53
+ const plain = 'opened .design/ui/home.tsx and rendered 42 artboards in 1.2s';
54
+ expect(scrub(plain)).toContain('home.tsx');
55
+ expect(scrub(plain)).not.toContain('[redacted]');
56
+ });
57
+ });
58
+
59
+ describe('the registry stays complete', () => {
60
+ /** Every `<prefix>_` literal the codebase mints a credential with. */
61
+ function mintedPrefixes(): Set<string> {
62
+ const found = new Set<string>();
63
+ const walk = (dir: string, depth = 0) => {
64
+ if (depth > 4) return;
65
+ for (const entry of readdirSync(dir)) {
66
+ if (entry === 'node_modules' || entry.startsWith('.')) continue;
67
+ const p = join(dir, entry);
68
+ let st: ReturnType<typeof statSync>;
69
+ try {
70
+ st = statSync(p);
71
+ } catch {
72
+ continue;
73
+ }
74
+ if (st.isDirectory()) {
75
+ walk(p, depth + 1);
76
+ continue;
77
+ }
78
+ if (!/\.(mjs|ts)$/.test(entry) || /\.test\./.test(entry)) continue;
79
+ const src = readFileSync(p, 'utf8');
80
+ // A mint always builds the value from a literal prefix plus randomness,
81
+ // e.g. `` `mpt_${b64hex(crypto.getRandomValues(...))}` ``.
82
+ for (const m of src.matchAll(/`(m[a-z]{2})_\$\{/g)) found.add(m[1]);
83
+ }
84
+ };
85
+ for (const app of ['apps/cloud', 'apps/hub', 'apps/cells', 'cli']) {
86
+ try {
87
+ walk(join(REPO, app));
88
+ } catch {
89
+ /* an absent app is not a failure */
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+
95
+ test('every prefix the code mints has a grammar', () => {
96
+ const registered = new Set(
97
+ CREDENTIAL_GRAMMARS.flatMap((g) => {
98
+ const m = /\\b(m[a-z]{2})_/.exec(g.pattern.source);
99
+ return m ? [m[1]] : [];
100
+ })
101
+ );
102
+ const minted = mintedPrefixes();
103
+ expect(minted.size).toBeGreaterThan(0); // the walk must actually find things
104
+ const missing = [...minted].filter((p) => !registered.has(p));
105
+ expect(missing).toEqual([]);
106
+ });
107
+ });
@@ -0,0 +1,95 @@
1
+ // The client learns it is a viewer at BOOT — Cloud Phase 25 C2.
2
+ //
3
+ // The alternative is learning it from the first refused write, which means
4
+ // drawing somebody an editor and then taking it away. So the role the
5
+ // workspace vouched for at sign-in is persisted with the credential and
6
+ // resolved before the UI paints.
7
+ //
8
+ // This flag decides what the UI OFFERS. It is never what stops a write: the
9
+ // cell enforces that (Phase 25 C1 — a read-only peer token, Hocuspocus
10
+ // dropping SyncStep2/Update, one HTTP gate), and it holds whatever a patched
11
+ // client believes about itself.
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { saveHubCredential } from '../sync/hub-link.ts';
19
+ import { signInToWorkspace } from '../sync/workspace-signin.ts';
20
+
21
+ function withHome<T>(fn: (home: string) => T): T {
22
+ const home = mkdtempSync(join(tmpdir(), 'maude-ro-'));
23
+ const prevXdg = process.env.XDG_CONFIG_HOME;
24
+ process.env.XDG_CONFIG_HOME = home;
25
+ try {
26
+ return fn(home);
27
+ } finally {
28
+ if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
29
+ else process.env.XDG_CONFIG_HOME = prevXdg;
30
+ rmSync(home, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ describe('the vouched role is persisted with the credential', () => {
35
+ test('a viewer is recorded as one', () => {
36
+ withHome((home) => {
37
+ saveHubCredential('https://p.cloud.maude.sh', 'mau_tok', 'viewer');
38
+ const cfg = JSON.parse(readFileSync(join(home, 'maude', 'hubs.json'), 'utf8'));
39
+ expect(cfg.hubs['https://p.cloud.maude.sh'].role).toBe('viewer');
40
+ });
41
+ });
42
+
43
+ test('a credential written without a role stays write-capable', () => {
44
+ // The upgrade promise: nobody is demoted by shipping this.
45
+ withHome((home) => {
46
+ saveHubCredential('https://p.cloud.maude.sh', 'mau_tok');
47
+ const cfg = JSON.parse(readFileSync(join(home, 'maude', 'hubs.json'), 'utf8'));
48
+ expect(cfg.hubs['https://p.cloud.maude.sh'].role).toBeUndefined();
49
+ });
50
+ });
51
+ });
52
+
53
+ describe('sign-in carries the role through to the credential', () => {
54
+ const okHub = (role: string) =>
55
+ (async (url: string) => {
56
+ const u = String(url);
57
+ if (u.endsWith('/health')) return Response.json({ ok: true, version: '1.0.0' });
58
+ return Response.json({ token: 'mau_minted', user: { email: 'v@example.com', role } });
59
+ }) as unknown as typeof fetch;
60
+
61
+ test('a viewer sign-in saves the role, and reports it back', async () => {
62
+ const saved: Array<[string, string, string | undefined]> = [];
63
+ const res = await signInToWorkspace(
64
+ { url: 'https://p.cloud.maude.sh', email: 'v@example.com', password: 'x'.repeat(12) },
65
+ {
66
+ save: (u, t, r) => saved.push([u, t, r]),
67
+ fetchImpl: okHub('viewer'),
68
+ }
69
+ );
70
+ expect(res.status).toBe(200);
71
+ expect(saved[0][2]).toBe('viewer');
72
+ expect((res.json as { user: { role: string } }).user.role).toBe('viewer');
73
+ });
74
+
75
+ test('a hub that names no role is treated as member, never as viewer', async () => {
76
+ // Failing OPEN on the UI flag is correct: the cell is what refuses a
77
+ // write, so a wrong guess here costs a redundant affordance, never
78
+ // access. Guessing "viewer" instead would lock out every self-hoster
79
+ // whose hub predates this field.
80
+ const saved: Array<[string, string, string | undefined]> = [];
81
+ const res = await signInToWorkspace(
82
+ { url: 'https://hub.example.com', email: 'm@example.com', password: 'x'.repeat(12) },
83
+ {
84
+ save: (u, t, r) => saved.push([u, t, r]),
85
+ fetchImpl: (async (url: string) => {
86
+ const u = String(url);
87
+ if (u.endsWith('/health')) return Response.json({ ok: true, version: '1.0.0' });
88
+ return Response.json({ token: 'mau_minted', user: { email: 'm@example.com' } });
89
+ }) as unknown as typeof fetch,
90
+ }
91
+ );
92
+ expect(res.status).toBe(200);
93
+ expect(saved[0][2]).toBe('member');
94
+ });
95
+ });
@@ -0,0 +1,197 @@
1
+ // ripple.test.ts — feature-enhanced-video-editing Phase 0 (Task 2). Golden-file
2
+ // fixtures for the pure ripple engine: literal shifts, const-arithmetic
3
+ // trailing-term merges, the TOTAL-follows-const double-shift guard, series
4
+ // TOTAL-only behavior, and the loud structured refusals.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ applyRippleAfterClip,
10
+ collectConsts,
11
+ RippleError,
12
+ resolveNum,
13
+ shiftExpression,
14
+ } from '../ripple.ts';
15
+
16
+ const CANVAS = '/abs/Canvas.tsx';
17
+
18
+ describe('shiftExpression', () => {
19
+ test('literal rewrites in place', () => {
20
+ expect(shiftExpression('90', 12)).toBe('102');
21
+ expect(shiftExpression('90', -30)).toBe('60');
22
+ });
23
+ test('trailing integer term merges', () => {
24
+ expect(shiftExpression('A + B - 20', 12)).toBe('A + B - 8');
25
+ expect(shiftExpression('A + B - 20', 20)).toBe('A + B');
26
+ expect(shiftExpression('A + 10', -22)).toBe('A - 12');
27
+ });
28
+ test('no trailing term → appends', () => {
29
+ expect(shiftExpression('A', 12)).toBe('A + 12');
30
+ expect(shiftExpression('A + B', -12)).toBe('A + B - 12');
31
+ });
32
+ test('trailing int that is an operand of * stays untouched (appends instead)', () => {
33
+ expect(shiftExpression('A * 2', 12)).toBe('A * 2 + 12');
34
+ });
35
+ test('unsafe charset → null (a call slips the charset but fails resolveNum — the engine refuses there)', () => {
36
+ expect(shiftExpression('a ? 1 : 2', 12)).toBe(null);
37
+ expect(resolveNum('fn(A)', { A: 1 })).toBe(null);
38
+ });
39
+ });
40
+
41
+ describe('collectConsts + resolveNum', () => {
42
+ const SRC = 'const A = 40;\nconst B = 50;\nconst TOTAL = A + B - 20;\n';
43
+ test('derived consts resolve', () => {
44
+ const c = collectConsts(SRC);
45
+ expect(c.TOTAL).toBe(70);
46
+ expect(resolveNum('TOTAL - 10', c)).toBe(60);
47
+ expect(resolveNum('mystery + 1', c)).toBe(null);
48
+ });
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Standalone-Sequence comp (overlay/audio band shape) — literal + expression.
53
+
54
+ const STANDALONE = [
55
+ 'const A = 90;',
56
+ 'const TOTAL = 300;',
57
+ 'const Comp = () => (',
58
+ ' <>',
59
+ ' <Sequence name="one" from={0} durationInFrames={A}><Video src="a.mp4" /></Sequence>',
60
+ ' <Sequence name="two" from={90} durationInFrames={100}><Video src="b.mp4" /></Sequence>',
61
+ ' <Sequence name="three" from={A + 100} durationInFrames={110}><Video src="c.mp4" /></Sequence>',
62
+ ' </>',
63
+ ');',
64
+ 'function Canvas() {',
65
+ ' return (',
66
+ ' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
67
+ ' );',
68
+ '}',
69
+ ].join('\n');
70
+
71
+ describe('applyRippleAfterClip — standalone sequences', () => {
72
+ test('shifts literal and expression froms, bumps the TOTAL const', () => {
73
+ const r = applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:one', 12);
74
+ expect(r.totalEdited).toBe(true);
75
+ expect(r.shifted).toEqual(['name:two', 'name:three']);
76
+ expect(r.source).toContain('const TOTAL = 312;');
77
+ expect(r.source).toContain('from={102}');
78
+ expect(r.source).toContain('from={A + 112}');
79
+ // upstream untouched
80
+ expect(r.source).toContain('from={0}');
81
+ });
82
+
83
+ test('negative delta merges the trailing term back down', () => {
84
+ const r = applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:one', -30);
85
+ expect(r.source).toContain('const TOTAL = 270;');
86
+ expect(r.source).toContain('from={60}');
87
+ expect(r.source).toContain('from={A + 70}');
88
+ });
89
+
90
+ test('delta 0 is a no-op', () => {
91
+ const r = applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:one', 0);
92
+ expect(r.source).toBe(STANDALONE);
93
+ expect(r.shifted).toEqual([]);
94
+ });
95
+
96
+ test('only clips AFTER the target shift', () => {
97
+ const r = applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:two', 12);
98
+ expect(r.shifted).toEqual(['name:three']);
99
+ expect(r.source).toContain('from={90}'); // "two" itself untouched
100
+ expect(r.source).toContain('from={A + 112}');
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // The double-shift guard — an end card at `from={TOTAL - 60}` already follows
106
+ // the bumped const and must be SKIPPED, not shifted twice.
107
+
108
+ const COUPLED = [
109
+ 'const TOTAL = 300;',
110
+ 'const Comp = () => (',
111
+ ' <>',
112
+ ' <Sequence name="body" from={0} durationInFrames={240}><Video src="a.mp4" /></Sequence>',
113
+ ' <Sequence name="end" from={TOTAL - 60} durationInFrames={60}><Video src="b.mp4" /></Sequence>',
114
+ ' </>',
115
+ ');',
116
+ 'function Canvas() {',
117
+ ' return (',
118
+ ' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
119
+ ' );',
120
+ '}',
121
+ ].join('\n');
122
+
123
+ describe('applyRippleAfterClip — const-coupled froms', () => {
124
+ test('a from that follows the bumped TOTAL is skipped (no double shift)', () => {
125
+ const r = applyRippleAfterClip(CANVAS, COUPLED, 'a', 'name:body', 30);
126
+ expect(r.source).toContain('const TOTAL = 330;');
127
+ expect(r.source).toContain('from={TOTAL - 60}'); // untouched — follows the const
128
+ expect(r.shifted).toEqual([]);
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // TransitionSeries storyline — series clips get NO from; TOTAL still moves.
134
+
135
+ const SERIES = [
136
+ "import { TransitionSeries } from '@remotion/transitions';",
137
+ 'const S1 = 90;',
138
+ 'const S2 = 120;',
139
+ 'const XF = 15;',
140
+ 'const TOTAL = S1 + S2 - XF;',
141
+ 'const Comp = () => (',
142
+ ' <TransitionSeries>',
143
+ ' <TransitionSeries.Sequence name="s1" durationInFrames={S1}><Video src="a.mp4" /></TransitionSeries.Sequence>',
144
+ ' <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />',
145
+ ' <TransitionSeries.Sequence name="s2" durationInFrames={S2}><Video src="b.mp4" /></TransitionSeries.Sequence>',
146
+ ' </TransitionSeries>',
147
+ ');',
148
+ 'function Canvas() {',
149
+ ' return (',
150
+ ' <DCArtboard id="a"><VideoComp component={Comp} durationInFrames={TOTAL} fps={30} /></DCArtboard>',
151
+ ' );',
152
+ '}',
153
+ ].join('\n');
154
+
155
+ describe('applyRippleAfterClip — series storyline', () => {
156
+ test('series clips get no from shift; TOTAL const merges the delta', () => {
157
+ const r = applyRippleAfterClip(CANVAS, SERIES, 'a', 'name:s1', 12);
158
+ expect(r.totalEdited).toBe(true);
159
+ expect(r.shifted).toEqual([]);
160
+ expect(r.source).toContain('const TOTAL = S1 + S2 - XF + 12;');
161
+ // clip tags untouched
162
+ expect(r.source).toContain('durationInFrames={S1}');
163
+ expect(r.source).toContain('durationInFrames={S2}');
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Refusals — loud, structured, never a silent mis-shift.
169
+
170
+ describe('applyRippleAfterClip — refusals', () => {
171
+ test('unresolvable downstream from refuses with the clip named', () => {
172
+ const SRC = STANDALONE.replace('from={A + 100}', 'from={computeStart()}');
173
+ let err: unknown;
174
+ try {
175
+ applyRippleAfterClip(CANVAS, SRC, 'a', 'name:one', 12);
176
+ } catch (e) {
177
+ err = e;
178
+ }
179
+ expect(err).toBeInstanceOf(RippleError);
180
+ expect(String((err as Error).message)).toContain('name:three');
181
+ });
182
+
183
+ test('unresolvable TOTAL const refuses', () => {
184
+ const SRC = STANDALONE.replace('const TOTAL = 300;', 'const TOTAL = computeTotal();');
185
+ expect(() => applyRippleAfterClip(CANVAS, SRC, 'a', 'name:one', 12)).toThrow(RippleError);
186
+ });
187
+
188
+ test('a shift below frame 0 refuses', () => {
189
+ expect(() => applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:one', -120)).toThrow(
190
+ RippleError
191
+ );
192
+ });
193
+
194
+ test('unknown clip refuses', () => {
195
+ expect(() => applyRippleAfterClip(CANVAS, STANDALONE, 'a', 'name:nope', 12)).toThrow();
196
+ });
197
+ });
@@ -0,0 +1,65 @@
1
+ // timeline-scale.test.ts — Task 4 pure zoom/scale helpers.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import {
6
+ clampPxPerFrame,
7
+ fitPxPerFrame,
8
+ frameToPx,
9
+ MAX_PX_PER_FRAME,
10
+ pxToFrame,
11
+ tickLabel,
12
+ tickStepFrames,
13
+ zoomAroundScroll,
14
+ } from '../client/panels/timeline-scale.js';
15
+
16
+ describe('fit + clamp', () => {
17
+ test('fit spans the viewport exactly', () => {
18
+ expect(fitPxPerFrame(900, 900)).toBe(1);
19
+ expect(fitPxPerFrame(1200, 300)).toBe(4);
20
+ });
21
+ test('clamp: never below fit, never above the max', () => {
22
+ expect(clampPxPerFrame(0.1, 900, 900)).toBe(1); // below fit → fit
23
+ expect(clampPxPerFrame(100, 900, 900)).toBe(MAX_PX_PER_FRAME);
24
+ expect(clampPxPerFrame(3, 900, 900)).toBe(3);
25
+ expect(clampPxPerFrame(null, 900, 900)).toBe(1); // absent → fit
26
+ });
27
+ });
28
+
29
+ describe('frame ↔ px round-trip', () => {
30
+ test('at any scale, px→frame(frame→px) is identity', () => {
31
+ for (const pxf of [0.5, 1, 3.7, 40]) {
32
+ expect(pxToFrame(frameToPx(123, pxf), pxf)).toBeCloseTo(123, 6);
33
+ }
34
+ });
35
+ });
36
+
37
+ describe('zoomAroundScroll', () => {
38
+ test('the anchor frame stays at the same viewport x', () => {
39
+ const prev = 1;
40
+ const next = 2;
41
+ const anchor = 300;
42
+ const prevScroll = 100; // anchor at viewport x = 200
43
+ const nextScroll = zoomAroundScroll(prev, next, anchor, prevScroll);
44
+ expect(frameToPx(anchor, next) - nextScroll).toBeCloseTo(frameToPx(anchor, prev) - prevScroll);
45
+ });
46
+ test('never scrolls negative', () => {
47
+ expect(zoomAroundScroll(2, 1, 10, 0)).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('tickStepFrames — density adapts 1 s → 10 f → 1 f', () => {
52
+ test('at fit on a long comp, ticks are whole/multi seconds', () => {
53
+ const step = tickStepFrames(0.3, 30); // 0.3 px/frame → 30f = 9px, needs ≥56px
54
+ expect(step % 30).toBe(0);
55
+ expect(step).toBeGreaterThanOrEqual(150);
56
+ });
57
+ test('zoomed to 8px/frame → single-frame ticks', () => {
58
+ expect(tickStepFrames(8, 30)).toBe(10); // 10f = 80px ≥ 56 → 10-frame ticks
59
+ expect(tickStepFrames(60, 30)).toBe(1); // 1f = 60px ≥ 56
60
+ });
61
+ test('labels: seconds at whole seconds, frames otherwise', () => {
62
+ expect(tickLabel(60, 30)).toBe('2s');
63
+ expect(tickLabel(45, 30)).toBe('45f');
64
+ });
65
+ });
@@ -172,7 +172,10 @@ describe('failure modes are distinguishable and actionable', () => {
172
172
  '/health': HEALTHY,
173
173
  '/auth/login': () =>
174
174
  json(
175
- { error: 'This workspace signs in through Maude Cloud — use Sign in to Maude Cloud in the app.' },
175
+ {
176
+ error:
177
+ 'This workspace signs in through Maude Cloud — use Sign in to Maude Cloud in the app.',
178
+ },
176
179
  400
177
180
  ),
178
181
  });
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "desktop-opens-again",
6
+ "version": "0.52.0",
7
+ "date": "2026-08-02",
8
+ "kind": "fix",
9
+ "title": "The desktop app opens again",
10
+ "summary": "0.51.1 launched to an empty window on every project — the app booted fine and the interface simply never drew. It only broke inside the desktop shell, which is why a browser and a dev build both looked healthy. Fixed, and the release now refuses to ship an app whose interface does not come up.",
11
+ "surface": "desktop"
12
+ },
13
+ {
14
+ "id": "manual-timeline-editor",
15
+ "version": "0.52.0",
16
+ "date": "2026-08-02",
17
+ "kind": "feature",
18
+ "title": "Edit video on the timeline, like iMovie",
19
+ "summary": "Split, trim, retime, crop, grade, and lay clips across stacked layers right on the timeline — drag to reorder, drop to make a new layer, add transitions, drop AI placeholder clips, and pin comments to an exact frame with the C tool.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "report-a-bug-from-the-app",
24
+ "version": "0.52.0",
25
+ "date": "2026-08-02",
26
+ "kind": "feature",
27
+ "title": "Report a bug without leaving Maude",
28
+ "summary": "Hit a problem? Help › Report a Bug… files it for you — with a screenshot of your canvas you can black-out before sending, and a review screen where you choose exactly what gets attached. The report lands on the public Maude issue tracker; screenshots and logs stay in a private space only maintainers can open.",
29
+ "surface": "design-ui",
30
+ "tour": [
31
+ {
32
+ "target": "[data-tour=\"help\"]",
33
+ "title": "Report a bug",
34
+ "body": "Open Help › Report a bug… whenever something breaks — a screenshot and the details maintainers need are gathered for you, and nothing is sent until you approve it.",
35
+ "placement": "bottom"
36
+ }
37
+ ]
38
+ },
4
39
  {
5
40
  "id": "maude-deeplink-open-in-app",
6
41
  "version": "0.51.0",
@@ -56,9 +56,11 @@ function listFiles(dir, prefix = '', out = []) {
56
56
  }
57
57
 
58
58
  function s3ConfigFromEnv(env = process.env) {
59
- const missing = ['MAUDE_R2_ENDPOINT', 'MAUDE_R2_ACCESS_KEY_ID', 'MAUDE_R2_SECRET_ACCESS_KEY'].filter(
60
- (k) => !env[k]
61
- );
59
+ const missing = [
60
+ 'MAUDE_R2_ENDPOINT',
61
+ 'MAUDE_R2_ACCESS_KEY_ID',
62
+ 'MAUDE_R2_SECRET_ACCESS_KEY',
63
+ ].filter((k) => !env[k]);
62
64
  if (missing.length) {
63
65
  throw new Error(
64
66
  `object storage is not configured — missing ${missing.join(', ')}.\n` +
@@ -95,7 +97,9 @@ export async function run({ args, pkgRoot }) {
95
97
 
96
98
  const project = validProjectId(flags.project);
97
99
  if (!project) {
98
- process.stderr.write('maude share: --project is required (lowercase letters, digits, hyphens)\n');
100
+ process.stderr.write(
101
+ 'maude share: --project is required (lowercase letters, digits, hyphens)\n'
102
+ );
99
103
  process.exit(2);
100
104
  }
101
105
  const zone = String(flags.zone ?? 'cloud.maude.sh');