@1agh/maude 0.57.0 → 0.58.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.
@@ -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,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.
@@ -23,8 +23,20 @@ describe('whats-new feed loader', () => {
23
23
  }
24
24
  });
25
25
 
26
- test('resolveMaudeVersion reads the design plugin manifest', () => {
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",
@@ -43,18 +43,42 @@ export interface WhatsNewFeed {
43
43
  let cache: WhatsNewFeed | null = null;
44
44
 
45
45
  /**
46
- * Read the installed maude version from the design plugin manifest.
47
- * `apps/studio/` → `plugins/design/.claude-plugin/plugin.json`.
48
- * That manifest ships in BOTH npm installs and marketplace-cache clones
49
- * (same rationale as build.ts `readPluginVersion`). Falls back to `dev`.
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 manifest = join(root, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json');
53
- try {
54
- const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown };
55
- if (typeof parsed.version === 'string') return parsed.version;
56
- } catch {
57
- /* fall through */
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.57.0",
3
+ "version": "0.58.0",
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.57.0",
58
- "@1agh/maude-darwin-x64": "0.57.0",
59
- "@1agh/maude-linux-arm64": "0.57.0",
60
- "@1agh/maude-linux-arm64-musl": "0.57.0",
61
- "@1agh/maude-linux-x64": "0.57.0",
62
- "@1agh/maude-linux-x64-musl": "0.57.0",
63
- "@1agh/maude-win32-x64": "0.57.0"
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"
64
64
  },
65
65
  "files": [
66
66
  "cli",