@1agh/maude 0.57.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.
- package/README.md +5 -1
- package/apps/studio/api.ts +69 -4
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +162 -20
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/3-shell-maude.css +45 -0
- package/apps/studio/client/styles/6-acp-chat.css +28 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +1585 -1585
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +8 -0
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +9 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -1507,6 +1507,14 @@ export function createHttp(
|
|
|
1507
1507
|
...ctx.cfg,
|
|
1508
1508
|
canvasOrigin: ctx.canvasOrigin,
|
|
1509
1509
|
readOnly: projectReadOnly(req),
|
|
1510
|
+
// WHICH RELEASE THIS IS. `/_config` reaches the browser in cloud mode,
|
|
1511
|
+
// so the bar for adding a field here is "would I publish it" — and a
|
|
1512
|
+
// version is the git tag, which is public. Nothing else rides along.
|
|
1513
|
+
//
|
|
1514
|
+
// Resolved through `resolveMaudeVersion`, the same function the What's
|
|
1515
|
+
// New feed uses: one answer, so the chip in the status bar and the feed
|
|
1516
|
+
// can never name different versions.
|
|
1517
|
+
version: resolveMaudeVersion(),
|
|
1510
1518
|
// Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
|
|
1511
1519
|
// canvas origin, minted per session by the proxy. Absent on a desktop,
|
|
1512
1520
|
// where the canvas origin is loopback and needs none. `canvasUrl()`
|
|
@@ -446,6 +446,34 @@ export function yieldsToArtboardDrag(
|
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Click-phase counterpart to `yieldsToArtboardDrag` (issue-78). The onClick
|
|
451
|
+
* handler below suppresses the browser's native `click` for every gesture it
|
|
452
|
+
* already routed as `select` on the matching pointerdown, so a just-selected
|
|
453
|
+
* element's own click handler doesn't also fire. But an artboard-chrome
|
|
454
|
+
* control — e.g. the video-timeline badge (`.dc-artboard-video-badge`,
|
|
455
|
+
* DDR-148) rendered as a sibling of `.dc-artboard-body` — never goes through
|
|
456
|
+
* `select`; it's an interactive button that needs its own `onClick` to reach
|
|
457
|
+
* React. Without this exemption a bare click on it while in Move (or a
|
|
458
|
+
* Cmd/Ctrl+click from Browse/an annotation tool) is preventDefault +
|
|
459
|
+
* stopImmediatePropagation'd in the capture phase before React's delegated
|
|
460
|
+
* listener ever runs, so the button silently does nothing. Mirrors
|
|
461
|
+
* `yieldsToArtboardDrag`'s modifier gate: a held Cmd/Ctrl is reserved for
|
|
462
|
+
* deep-select and stays unexempted.
|
|
463
|
+
*/
|
|
464
|
+
export function yieldsClickToArtboardChrome(
|
|
465
|
+
wouldRouteKind: RouterAction['kind'] | null,
|
|
466
|
+
target: EventTarget | null,
|
|
467
|
+
modifiers: { metaKey: boolean; ctrlKey: boolean }
|
|
468
|
+
): boolean {
|
|
469
|
+
return (
|
|
470
|
+
wouldRouteKind === 'select' &&
|
|
471
|
+
!modifiers.metaKey &&
|
|
472
|
+
!modifiers.ctrlKey &&
|
|
473
|
+
isArtboardDragChrome(target)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
450
478
|
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
|
|
451
479
|
|
|
@@ -631,8 +659,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
631
659
|
? 'context-menu'
|
|
632
660
|
: null;
|
|
633
661
|
if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
|
|
662
|
+
// issue-78 — a bare click on an artboard-chrome control (the video-
|
|
663
|
+
// timeline badge) must still reach its own onClick; see
|
|
664
|
+
// yieldsClickToArtboardChrome for why. Mirrors the onPointerDown/
|
|
665
|
+
// onMouseDown pattern above: preventDefault always runs (so a forged
|
|
666
|
+
// chrome-alike element inside untrusted canvas content can't use this
|
|
667
|
+
// exemption to let its own native click default action — e.g. an
|
|
668
|
+
// <a>'s navigation or an <input type="file">'s picker — fire), but
|
|
669
|
+
// stopImmediatePropagation is skipped so the real chrome button's own
|
|
670
|
+
// React onClick still gets the (now-inert-by-default) event.
|
|
671
|
+
const yields = yieldsClickToArtboardChrome(wouldRouteKind, e.target, e);
|
|
634
672
|
e.preventDefault();
|
|
635
|
-
e.stopImmediatePropagation();
|
|
673
|
+
if (!yields) e.stopImmediatePropagation();
|
|
636
674
|
}
|
|
637
675
|
};
|
|
638
676
|
|
|
@@ -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,88 @@
|
|
|
1
|
+
// The running version reaches the client — feature-release-reaches-the-fleet.
|
|
2
|
+
//
|
|
3
|
+
// Reading which release a Maude was actually running used to mean probing
|
|
4
|
+
// production by hand, and the first probe of the v0.57.0 incident was MISREAD
|
|
5
|
+
// (a 401 that looked like "route exists" was the blanket answer for any unknown
|
|
6
|
+
// path). The fix has three surfaces reporting one version: the hub's `/health`,
|
|
7
|
+
// this `/_config`, and the status-bar chip the client draws from it.
|
|
8
|
+
//
|
|
9
|
+
// What is asserted here is the middle one, plus the two properties that make it
|
|
10
|
+
// safe to put there: it is resolved through the SINGLE version function, and it
|
|
11
|
+
// carries nothing else.
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
14
|
+
import type { Subprocess } from 'bun';
|
|
15
|
+
|
|
16
|
+
import { resolveMaudeVersion } from '../whats-new.ts';
|
|
17
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
18
|
+
|
|
19
|
+
const procs: Subprocess[] = [];
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
for (const p of procs) await killProc(p);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function boot(env: Record<string, string> = {}) {
|
|
25
|
+
const { root } = makeSandbox();
|
|
26
|
+
const port = nextPort();
|
|
27
|
+
procs.push(await bootServer(root, port, env));
|
|
28
|
+
return `http://localhost:${port}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('/_config names the release it is running', () => {
|
|
32
|
+
test('the version is present, and is a real version', async () => {
|
|
33
|
+
const base = await boot();
|
|
34
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
|
|
35
|
+
|
|
36
|
+
expect(typeof cfg.version).toBe('string');
|
|
37
|
+
// `0.0.0` was the private-workspace placeholder these manifests carried
|
|
38
|
+
// before they joined the release line — reporting it is the bug, not a pass.
|
|
39
|
+
expect(cfg.version).not.toBe('0.0.0');
|
|
40
|
+
expect(cfg.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('it is the SAME answer the What’s New feed uses', async () => {
|
|
44
|
+
// One resolution path (DDR-045). Two would eventually disagree, and a chip
|
|
45
|
+
// that contradicts the feed beside it is worse than no chip.
|
|
46
|
+
const base = await boot();
|
|
47
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
|
|
48
|
+
const feed = (await (await fetch(`${base}/_api/whats-new`)).json()) as { version?: string };
|
|
49
|
+
|
|
50
|
+
expect(cfg.version).toBe(resolveMaudeVersion());
|
|
51
|
+
expect(cfg.version).toBe(feed.version);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('the version rides along, and nothing else does', () => {
|
|
56
|
+
test('a cloud tab gets the version too', async () => {
|
|
57
|
+
// `/_config` reaches a browser on someone else's machine in workspace mode.
|
|
58
|
+
// A git tag is public, so the version is fine there — this pins that it
|
|
59
|
+
// actually arrives, since the chip is the cloud's only version surface.
|
|
60
|
+
const base = await boot({
|
|
61
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
62
|
+
MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
|
|
63
|
+
MAUDE_PROJECT_NAME: 'Alligators',
|
|
64
|
+
});
|
|
65
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as {
|
|
66
|
+
version?: string;
|
|
67
|
+
cloud?: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
expect(typeof cfg.version).toBe('string');
|
|
71
|
+
expect(cfg.cloud).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('no server path or secret came with it', async () => {
|
|
75
|
+
const base = await boot();
|
|
76
|
+
const cfg = (await (await fetch(`${base}/_config`)).json()) as Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
// The version was the one field this change added. Anything matching these
|
|
79
|
+
// shapes arriving in the browser payload is a leak, and the review that
|
|
80
|
+
// waves it through will be the one that reads "it was already there".
|
|
81
|
+
for (const key of Object.keys(cfg)) {
|
|
82
|
+
expect(key).not.toMatch(/token$/i);
|
|
83
|
+
expect(key).not.toMatch(/secret|password|apiKey/i);
|
|
84
|
+
}
|
|
85
|
+
const serialized = JSON.stringify(cfg);
|
|
86
|
+
expect(serialized).not.toMatch(/BEGIN [A-Z ]*PRIVATE KEY/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isArtboardDragChrome,
|
|
11
11
|
isEditableTarget,
|
|
12
12
|
type Tool,
|
|
13
|
+
yieldsClickToArtboardChrome,
|
|
13
14
|
yieldsToArtboardDrag,
|
|
14
15
|
} from '../input-router.tsx';
|
|
15
16
|
|
|
@@ -552,6 +553,61 @@ describe('input-router / yieldsToArtboardDrag', () => {
|
|
|
552
553
|
});
|
|
553
554
|
});
|
|
554
555
|
|
|
556
|
+
// issue-78 — the actual regression: the router's capture-phase `click`
|
|
557
|
+
// listener claimed every bare click while in the Move tool (and every
|
|
558
|
+
// Cmd/Ctrl+click from Browse/annotation tools) and called
|
|
559
|
+
// `stopImmediatePropagation()` unconditionally, unlike its pointerdown/
|
|
560
|
+
// mousedown siblings above. That killed the native click on any artboard-
|
|
561
|
+
// chrome control BEFORE React's delegated onClick ever ran — including the
|
|
562
|
+
// video-timeline badge (`.dc-artboard-video-badge`, DDR-148), which sits
|
|
563
|
+
// outside `.dc-artboard-body` as chrome. Clicking it did nothing.
|
|
564
|
+
// `yieldsClickToArtboardChrome` is the decision the click handler now gates
|
|
565
|
+
// its suppression on, mirroring `yieldsToArtboardDrag` above.
|
|
566
|
+
describe('input-router / yieldsClickToArtboardChrome', () => {
|
|
567
|
+
const chrome = (): EventTarget =>
|
|
568
|
+
({
|
|
569
|
+
closest: (sel: string) => (sel === '[data-dc-screen]' ? {} : null),
|
|
570
|
+
}) as unknown as EventTarget;
|
|
571
|
+
const body = (): EventTarget => ({ closest: () => ({}) }) as unknown as EventTarget; // matches every selector, incl. .dc-artboard-body
|
|
572
|
+
|
|
573
|
+
test('bare click on the video-timeline badge in Move tool (the reported bug) → true', () => {
|
|
574
|
+
expect(
|
|
575
|
+
yieldsClickToArtboardChrome('select', chrome(), { metaKey: false, ctrlKey: false })
|
|
576
|
+
).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('cmd+click on artboard chrome (deep-select, not a chrome control) → false', () => {
|
|
580
|
+
expect(yieldsClickToArtboardChrome('select', chrome(), { metaKey: true, ctrlKey: false })).toBe(
|
|
581
|
+
false
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test('bare click on .dc-artboard-body content (not chrome) → false', () => {
|
|
586
|
+
expect(yieldsClickToArtboardChrome('select', body(), { metaKey: false, ctrlKey: false })).toBe(
|
|
587
|
+
false
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('bare click outside any artboard → false (nothing to yield to)', () => {
|
|
592
|
+
const outside = { closest: () => null } as unknown as EventTarget;
|
|
593
|
+
expect(yieldsClickToArtboardChrome('select', outside, { metaKey: false, ctrlKey: false })).toBe(
|
|
594
|
+
false
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('non-select routed kind (e.g. context-menu) on chrome → false', () => {
|
|
599
|
+
expect(
|
|
600
|
+
yieldsClickToArtboardChrome('context-menu', chrome(), { metaKey: false, ctrlKey: false })
|
|
601
|
+
).toBe(false);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('null routed kind on chrome → false', () => {
|
|
605
|
+
expect(yieldsClickToArtboardChrome(null, chrome(), { metaKey: false, ctrlKey: false })).toBe(
|
|
606
|
+
false
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
555
611
|
// T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
|
|
556
612
|
// (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
|
|
557
613
|
// reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.
|
|
@@ -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
|
+
});
|
|
@@ -23,8 +23,20 @@ describe('whats-new feed loader', () => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test('resolveMaudeVersion
|
|
26
|
+
test('resolveMaudeVersion resolves a real release version', () => {
|
|
27
27
|
expect(resolveMaudeVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
28
|
+
// The studio's own manifest is preferred over the design plugin's (it is
|
|
29
|
+
// the one staged into the cell image), and `0.0.0` — the placeholder both
|
|
30
|
+
// carried before joining the release line — is never an answer.
|
|
31
|
+
expect(resolveMaudeVersion()).not.toBe('0.0.0');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unversioned root falls through rather than reporting the placeholder', () => {
|
|
35
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-ver-'));
|
|
36
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '0.0.0' }));
|
|
37
|
+
// No plugin manifest above a tmpdir either, so this lands on `dev` — a
|
|
38
|
+
// value obviously not a release, rather than a plausible wrong number.
|
|
39
|
+
expect(resolveMaudeVersion(root)).toBe('dev');
|
|
28
40
|
});
|
|
29
41
|
|
|
30
42
|
test('a missing feed dir yields empty entries and never throws', () => {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "release-version-visible",
|
|
6
|
+
"version": "0.58.0",
|
|
7
|
+
"date": "2026-08-07",
|
|
8
|
+
"kind": "improvement",
|
|
9
|
+
"title": "See which version of Maude you are running",
|
|
10
|
+
"summary": "The Studio status bar now shows the running version, and the hub admin header shows it beside the bundle hash — so you can tell what you are on without digging. In the cloud it is the same chip, because the cloud serves the same Studio.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"id": "cloud-connect-tells-you-what-happened",
|
|
6
15
|
"version": "0.57.0",
|
package/apps/studio/whats-new.ts
CHANGED
|
@@ -43,18 +43,42 @@ export interface WhatsNewFeed {
|
|
|
43
43
|
let cache: WhatsNewFeed | null = null;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Read the installed maude version
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* Read the installed maude version.
|
|
47
|
+
*
|
|
48
|
+
* THE ONE resolution path (DDR-045: disk paths come from `paths.ts`). Everything
|
|
49
|
+
* that wants to name the running version — the What's New feed, `/_config`, the
|
|
50
|
+
* status-bar chip — calls this rather than growing a second answer that can
|
|
51
|
+
* disagree with the first.
|
|
52
|
+
*
|
|
53
|
+
* Two sources, in order:
|
|
54
|
+
*
|
|
55
|
+
* 1. the studio's OWN `package.json`. It rides the release line since the
|
|
56
|
+
* fleet-verification change, and it is the only one of the two that is
|
|
57
|
+
* staged into the cell image — a cloud tab asking "which version am I on"
|
|
58
|
+
* gets an answer rather than `dev`.
|
|
59
|
+
* 2. `plugins/design/.claude-plugin/plugin.json`, which ships in BOTH npm
|
|
60
|
+
* installs and marketplace-cache clones (same rationale as build.ts
|
|
61
|
+
* `readPluginVersion`). Kept as the fallback so an older layout that lacks
|
|
62
|
+
* a version-stamped studio manifest still resolves.
|
|
63
|
+
*
|
|
64
|
+
* Falls back to `dev` — a placeholder that is obviously not a release, never a
|
|
65
|
+
* plausible-looking wrong number.
|
|
50
66
|
*/
|
|
51
67
|
export function resolveMaudeVersion(root: string = DEV_SERVER_ROOT): string {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
const candidates = [
|
|
69
|
+
join(root, 'package.json'),
|
|
70
|
+
join(root, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json'),
|
|
71
|
+
];
|
|
72
|
+
for (const manifest of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown };
|
|
75
|
+
// `0.0.0` is the private-workspace placeholder these manifests carried
|
|
76
|
+
// before they joined the release line. Treating it as an answer is how
|
|
77
|
+
// `/health` came to report `0.0.0` for months.
|
|
78
|
+
if (typeof parsed.version === 'string' && parsed.version !== '0.0.0') return parsed.version;
|
|
79
|
+
} catch {
|
|
80
|
+
/* try the next one */
|
|
81
|
+
}
|
|
58
82
|
}
|
|
59
83
|
return 'dev';
|
|
60
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "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
|
-
"@1agh/maude-darwin-x64": "0.
|
|
59
|
-
"@1agh/maude-linux-arm64": "0.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
61
|
-
"@1agh/maude-linux-x64": "0.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
63
|
-
"@1agh/maude-win32-x64": "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",
|