@1agh/maude 0.58.0 → 0.58.1

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.
@@ -0,0 +1,36 @@
1
+ // artboard-hug-commit.ts — issue #79. The Artboard Inspector's Height
2
+ // NumberField silently dropped a typed value whenever the artboard was in
3
+ // Hug mode (fixed=false, the default per DDR-027 Design Decision 1) — Width
4
+ // always worked because it has no such mode. Typing a height should instead
5
+ // promote the artboard to Fixed at that height, same as the Hug/Fixed
6
+ // toggle's own freeze-current-height write.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { resolveHeightCommit } from '../artboard-hug-commit.ts';
11
+
12
+ describe('artboard-hug-commit / resolveHeightCommit', () => {
13
+ test('fixed=true → plain resize (Width-field parity)', () => {
14
+ expect(resolveHeightCommit(true, 480)).toEqual({ kind: 'resize', height: 480 });
15
+ });
16
+
17
+ test('fixed=false (Hug, the default) → promotes to Fixed at the typed height', () => {
18
+ // Before the fix this typed value was dropped entirely (no action) —
19
+ // reproducing "when I want to set height... manually from inspector
20
+ // panel, nothing happened. Width works."
21
+ expect(resolveHeightCommit(false, 480)).toEqual({ kind: 'promote-to-fixed', height: 480 });
22
+ });
23
+
24
+ test('rounds a fractional typed value', () => {
25
+ expect(resolveHeightCommit(true, 480.6)).toEqual({ kind: 'resize', height: 481 });
26
+ expect(resolveHeightCommit(false, 480.6)).toEqual({ kind: 'promote-to-fixed', height: 481 });
27
+ });
28
+
29
+ test('non-finite or non-positive input → noop, in either mode', () => {
30
+ expect(resolveHeightCommit(true, Number.NaN)).toEqual({ kind: 'noop' });
31
+ expect(resolveHeightCommit(true, 0)).toEqual({ kind: 'noop' });
32
+ expect(resolveHeightCommit(true, -10)).toEqual({ kind: 'noop' });
33
+ expect(resolveHeightCommit(false, Number.NaN)).toEqual({ kind: 'noop' });
34
+ expect(resolveHeightCommit(false, 0)).toEqual({ kind: 'noop' });
35
+ });
36
+ });
@@ -31,6 +31,11 @@ import {
31
31
 
32
32
  const CANVAS = '/abs/Canvas.tsx';
33
33
 
34
+ // Adversarial fixture (security review 2026-07-30) — a prompt embedding a
35
+ // fake </AIPlaceholder> close tag + template-literal/env-var syntax, proving
36
+ // the JSON.stringify escaping discipline (DDR-150 P1) holds under attack.
37
+ const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
38
+
34
39
  const SERIES = [
35
40
  "import { TransitionSeries } from '@remotion/transitions';",
36
41
  'const S1 = 90;',
@@ -568,8 +573,6 @@ describe('greenfield first drop (Task 20)', () => {
568
573
  });
569
574
 
570
575
  describe('AIPlaceholder insert + resolve (Tasks 21–22)', () => {
571
- const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
572
-
573
576
  test('prompt is JSON.stringified into the JSX (injection fixtures) and enumerates back', () => {
574
577
  const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
575
578
  lane: 'storyline',
@@ -833,4 +836,83 @@ describe('layers model — set-text + move between layers', () => {
833
836
  expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
834
837
  expect(back.source).toContain('const TOTAL = 270;'); // exact content end again
835
838
  });
839
+
840
+ // Bug: issue #81 — an AI placeholder slate (no media src yet) was refused by
841
+ // both move verbs, so it could never leave the storyline it was dropped
842
+ // into. A placeholder has no <src> to graft, but it has a prompt + kind
843
+ // that can ride over unchanged.
844
+ test('AI placeholder beat → overlay layer keeps the prompt (no media src required)', () => {
845
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
846
+ lane: 'storyline',
847
+ index: 3,
848
+ durationInFrames: 60,
849
+ placeholder: { prompt: 'drone shot over Brno', kind: 'veo' },
850
+ });
851
+ const r = applyMoveClipToOverlay(
852
+ CANVAS,
853
+ withPh.source,
854
+ 'a',
855
+ withPh.stableId as string,
856
+ undefined
857
+ );
858
+ const cc = enumerateClips(CANVAS, r.source, 'a');
859
+ const overlays = cc.clips.filter((c) => c.tag === 'Sequence');
860
+ expect(overlays.length).toBe(1);
861
+ expect(overlays[0]?.placeholder?.prompt).toBe('drone shot over Brno');
862
+ expect(overlays[0]?.mediaSrc).toBe(null);
863
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
864
+ expect(beats.length).toBe(3); // the placeholder beat left the storyline
865
+ });
866
+
867
+ test('AI placeholder overlay → storyline round-trips the prompt back into a beat', () => {
868
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
869
+ lane: 'overlay',
870
+ from: 0,
871
+ durationInFrames: 45,
872
+ placeholder: { prompt: 'sunset time-lapse', kind: 'image' },
873
+ });
874
+ const back = applyMoveClipToStoryline(
875
+ CANVAS,
876
+ withPh.source,
877
+ 'a',
878
+ withPh.stableId as string,
879
+ undefined
880
+ );
881
+ const cc = enumerateClips(CANVAS, back.source, 'a');
882
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
883
+ expect(beats.some((c) => c.placeholder?.prompt === 'sunset time-lapse')).toBe(true);
884
+ expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
885
+ });
886
+
887
+ // Adversarial (security review 2026-08-07): the move path must go through
888
+ // the same JSON.stringify escaping as a fresh insert (DDR-150 P1) — prove
889
+ // it with the same EVIL fixture the insert/resolve suite uses above, not
890
+ // just infer it from code-path sharing.
891
+ test('AI placeholder move preserves EVIL-prompt escaping (F-A3 companion)', () => {
892
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
893
+ lane: 'storyline',
894
+ index: 3,
895
+ durationInFrames: 60,
896
+ placeholder: { prompt: EVIL, kind: 'veo' },
897
+ });
898
+ const moved = applyMoveClipToOverlay(
899
+ CANVAS,
900
+ withPh.source,
901
+ 'a',
902
+ withPh.stableId as string,
903
+ undefined
904
+ );
905
+ expect(moved.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
906
+ const back = applyMoveClipToStoryline(
907
+ CANVAS,
908
+ moved.source,
909
+ 'a',
910
+ moved.stableId as string,
911
+ undefined
912
+ );
913
+ expect(back.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
914
+ const cc = enumerateClips(CANVAS, back.source, 'a');
915
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
916
+ expect(beats.some((c) => c.placeholder?.prompt === EVIL)).toBe(true);
917
+ });
836
918
  });
@@ -0,0 +1,41 @@
1
+ // timeline-media-cache.test.ts — bug fix: dropping a clip on the timeline
2
+ // used to always trim it to a flat 3s instead of its real duration
3
+ // (durationInFrames was `Math.round(fps * 3)` regardless of the probed
4
+ // media length). durationFramesForDrop is the pure decision the drop
5
+ // handler in app.jsx now delegates to.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { durationFramesForDrop } from '../client/panels/timeline-media-cache.js';
10
+
11
+ describe('durationFramesForDrop', () => {
12
+ test('uses the real probed duration, not the flat 3s default', () => {
13
+ // A 7.4s clip at 30fps must land at its own length (222 frames), not the
14
+ // fixed fps*3 = 90 frames the drop handler used to hardcode.
15
+ expect(durationFramesForDrop(30, 7.4)).toBe(Math.round(30 * 7.4));
16
+ expect(durationFramesForDrop(30, 7.4)).not.toBe(Math.round(30 * 3));
17
+ });
18
+
19
+ test('rounds to the nearest frame', () => {
20
+ expect(durationFramesForDrop(24, 2.501)).toBe(60); // 24 * 2.501 = 60.024
21
+ });
22
+
23
+ test('falls back to 3s when no duration was probed (null/failed probe)', () => {
24
+ expect(durationFramesForDrop(30, null)).toBe(90);
25
+ expect(durationFramesForDrop(30, undefined)).toBe(90);
26
+ expect(durationFramesForDrop(30, NaN)).toBe(90);
27
+ });
28
+
29
+ test('falls back to 3s for a non-positive probed duration', () => {
30
+ expect(durationFramesForDrop(30, 0)).toBe(90);
31
+ expect(durationFramesForDrop(30, -1)).toBe(90);
32
+ });
33
+
34
+ test('a caller can override the fallback (e.g. synthetic content)', () => {
35
+ expect(durationFramesForDrop(30, null, 5)).toBe(150);
36
+ });
37
+
38
+ test('never returns less than 1 frame', () => {
39
+ expect(durationFramesForDrop(30, 0.001)).toBeGreaterThanOrEqual(1);
40
+ });
41
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.58.0",
3
+ "version": "0.58.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "0.58.0",
58
- "@1agh/maude-darwin-x64": "0.58.0",
59
- "@1agh/maude-linux-arm64": "0.58.0",
60
- "@1agh/maude-linux-arm64-musl": "0.58.0",
61
- "@1agh/maude-linux-x64": "0.58.0",
62
- "@1agh/maude-linux-x64-musl": "0.58.0",
63
- "@1agh/maude-win32-x64": "0.58.0"
57
+ "@1agh/maude-darwin-arm64": "0.58.1",
58
+ "@1agh/maude-darwin-x64": "0.58.1",
59
+ "@1agh/maude-linux-arm64": "0.58.1",
60
+ "@1agh/maude-linux-arm64-musl": "0.58.1",
61
+ "@1agh/maude-linux-x64": "0.58.1",
62
+ "@1agh/maude-linux-x64-musl": "0.58.1",
63
+ "@1agh/maude-win32-x64": "0.58.1"
64
64
  },
65
65
  "files": [
66
66
  "cli",