@1agh/maude 0.29.0 → 0.30.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 (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -10,7 +10,15 @@
10
10
  * selection resets (the strokes themselves persist via `.annotations.svg`).
11
11
  */
12
12
 
13
- import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
13
+ import {
14
+ createContext,
15
+ type ReactNode,
16
+ useCallback,
17
+ useContext,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from 'react';
14
22
 
15
23
  export interface AnnotationSelectionValue {
16
24
  selectedIds: string[];
@@ -57,10 +65,14 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
57
65
 
58
66
  // Read live from state at call time so multi-step flows in the same tick
59
67
  // (e.g. a router callback that adds then asks `contains`) see the staged
60
- // result instead of a stale closure capture.
61
- const containsRef = { current: selectedIds };
68
+ // result instead of a stale closure capture. MUST be a real useRef — the
69
+ // pre-FigJam-v3 code created a fresh `{ current }` object literal every
70
+ // render, so the stable useCallback closure kept reading the FIRST render's
71
+ // (empty) selection forever: `contains()` always returned false, which
72
+ // silently broke hull/multi-drag and click-keeps-selection (user-gate
73
+ // finding, 2026-06-11).
74
+ const containsRef = useRef<string[]>(selectedIds);
62
75
  containsRef.current = selectedIds;
63
- // biome-ignore lint/correctness/useExhaustiveDependencies: ref read intentionally
64
76
  const contains = useCallback((id: string) => containsRef.current.includes(id), []);
65
77
 
66
78
  const value = useMemo<AnnotationSelectionValue>(
@@ -132,7 +132,7 @@ function readActivitySeed(): ActivityMap {
132
132
  const out: ActivityMap = {};
133
133
  for (const file of Object.keys(seed)) {
134
134
  const e = seed[file];
135
- if (!e || e.status !== 'active') continue; // only resurrect active overlays
135
+ if (e?.status !== 'active') continue; // only resurrect active overlays
136
136
  out[file] = {
137
137
  status: 'active',
138
138
  artboardIds: Array.isArray(e.artboardIds) ? e.artboardIds : null,
@@ -199,7 +199,7 @@ export function CanvasActivityProvider({
199
199
  setMap((prev) => {
200
200
  const entry = prev[detail.file];
201
201
  // Only remove if it's still the same idle entry (not re-activated).
202
- if (!entry || entry.status !== 'idle' || entry.ts !== ts) return prev;
202
+ if (entry?.status !== 'idle' || entry.ts !== ts) return prev;
203
203
  const { [detail.file]: _drop, ...rest } = prev;
204
204
  return rest;
205
205
  });
@@ -39,26 +39,31 @@ const MESSAGE_AWARENESS = 1;
39
39
  // Color hash — stable per peer identity.
40
40
 
41
41
  /**
42
- * djb2 string hash → 0xRRGGBB color in a curated palette. Determinism per
42
+ * djb2 string hash → OKLCH color in a curated palette. Determinism per
43
43
  * input name is the load-bearing property: every peer hashing "Alice" must
44
- * land on the SAME color. 12 hues spread evenly around the wheel; saturation
45
- * + lightness fixed so all colors stay readable on light + dark surfaces.
44
+ * land on the SAME color.
45
+ *
46
+ * DS contract (colors-presence specimen): the AI agent rides
47
+ * `--presence-agent` (violet-magenta, hue 322) — "a hue no human state uses,
48
+ * so attribution on a shared canvas is unambiguous". Human hues therefore
49
+ * EXCLUDE the agent band (~292–352) AND the accent indigo band (~245–290,
50
+ * reserved for selection/active). L/C match the DS presence tokens
51
+ * (oklch ≈0.74 0.16) so every cursor reads at the same weight on both themes.
46
52
  */
47
53
  const COLOR_PALETTE = [
48
- '#e91e63', // pink
49
- '#f44336', // red
50
- '#ff9800', // orange
51
- '#ffc107', // amber
52
- '#cddc39', // lime
53
- '#4caf50', // green
54
- '#009688', // teal
55
- '#00bcd4', // cyan
56
- '#03a9f4', // light blue
57
- '#3f51b5', // indigo
58
- '#673ab7', // deep purple
59
- '#9c27b0', // purple
54
+ 'oklch(0.70 0.17 12)', // rose
55
+ 'oklch(0.72 0.16 40)', // coral
56
+ 'oklch(0.78 0.15 78)', // amber (presence-away hue)
57
+ 'oklch(0.76 0.16 108)', // lime
58
+ 'oklch(0.74 0.16 145)', // green (presence-online hue)
59
+ 'oklch(0.75 0.14 172)', // teal
60
+ 'oklch(0.73 0.13 200)', // cyan
61
+ 'oklch(0.72 0.13 238)', // blue (status-info hue)
60
62
  ] as const;
61
63
 
64
+ /** The AI agent's exclusive cursor/avatar hue — `--presence-agent`. */
65
+ export const AGENT_COLOR = 'oklch(0.700 0.190 322)';
66
+
62
67
  export function colorForName(name: string): string {
63
68
  // COLOR_PALETTE is a non-empty const tuple; the explicit `?? '#000'`
64
69
  // fallback is unreachable but satisfies `noUncheckedIndexedAccess`.
@@ -61,6 +61,15 @@ export interface Selection {
61
61
  dom_path?: string[];
62
62
  bounds?: { x: number; y: number; w: number; h: number } | null;
63
63
  html?: string;
64
+ /** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
65
+ authored?: Record<string, string>;
66
+ computed?: Record<string, string>;
67
+ /** Phase 12.3 — authored inline-style props OUTSIDE the curated knob set, so the
68
+ * panel can surface a custom CSS property the user added (e.g. `letter-spacing`). */
69
+ customStyles?: Record<string, string>;
70
+ /** Phase 12.3 — custom HTML attributes on the element (data-, aria-, role, title…),
71
+ * so the panel reflects a custom attribute the user added via the escape hatch. */
72
+ attrs?: Record<string, string>;
64
73
  }
65
74
 
66
75
  interface SelectionSetValue {
@@ -58,6 +58,8 @@ export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
58
58
  // buttons; the primitive is chosen from the palette popover.
59
59
  { id: 'shape', label: 'Shape', shortcut: 'R', cursor: TOOL_CURSORS.shape },
60
60
  { id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
61
+ // FigJam v3 — labelled organizing container.
62
+ { id: 'section', label: 'Section', shortcut: '⇧S', cursor: TOOL_CURSORS.shape },
61
63
  { id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
62
64
  { id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
63
65
  { id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
@@ -114,7 +116,9 @@ export function ToolProvider({
114
116
  const clearSticky = useCallback(() => {
115
117
  setSticky({ tool: null, locked: false });
116
118
  }, []);
117
- const [shapeKind, setShapeKind] = useState<ShapeKind>('square');
119
+ // FigJam v3 soft default: a fresh Shape tool draws ROUNDED squares (the
120
+ // FigJam look); sharp squares stay one popover click away.
121
+ const [shapeKind, setShapeKind] = useState<ShapeKind>('rounded');
118
122
 
119
123
  // Cursor sync — applied inside the canvas (this hook runs in the canvas
120
124
  // context). The active tool's cursor is set on <body> AND forced across the
@@ -1,6 +1,124 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "in-canvas-token-editor",
6
+ "version": "0.30.0",
7
+ "date": "2026-06-12",
8
+ "kind": "feature",
9
+ "title": "A real token editor, right in the canvas",
10
+ "summary": "The Inspector's CSS tab grew into a Figma-style design-token editor. Numeric fields carry a property glyph and scrub left/right to change; spacing fields take Webflow-style modifiers. Any value can bind to a design-system Variable through a searchable popover that shows where the token is defined — and it resolves the right system in multi-DS projects. One unified colour picker now drives every colour field, fill and border alike, with Custom and Variables tabs. Values you've set stand out while untouched rows recede, so the diff is scannable at a glance.",
11
+ "surface": "design-ui",
12
+ "tour": [
13
+ {
14
+ "target": "[data-tour='inspector-tabs']",
15
+ "inspector": true,
16
+ "tab": "css",
17
+ "title": "Inspect · Layers · CSS",
18
+ "body": "Here's the Inspector — opened to its CSS tab. It reads and writes your maude design system itself, not a generic :root. Three tabs: Inspect, Layers, CSS.",
19
+ "placement": "left"
20
+ },
21
+ {
22
+ "target": "[data-tour='css-panel']",
23
+ "inspector": true,
24
+ "tab": "css",
25
+ "requireSelection": true,
26
+ "title": "Your real element, live",
27
+ "body": "Hold ⌘ and click any element in the canvas. The CSS panel fills with its real, editable properties — and every change writes back to the source on blur, no AI round-trip.",
28
+ "placement": "left"
29
+ },
30
+ {
31
+ "target": ".st-cp-row",
32
+ "inspector": true,
33
+ "tab": "css",
34
+ "requireSelection": true,
35
+ "title": "Figma-style property rows",
36
+ "body": "Each row leads with a property glyph. Drag a numeric field left or right to scrub the value, or just type it; spacing fields accept Webflow-style modifiers.",
37
+ "placement": "left"
38
+ },
39
+ {
40
+ "target": ".st-cp-tokbtn",
41
+ "inspector": true,
42
+ "tab": "css",
43
+ "requireSelection": true,
44
+ "title": "Bind to a design token",
45
+ "body": "Click a field's token chip for the Variables popover — Custom for a literal, Variables for the design system's tokens. Search the list; each Variable shows its provenance. One unified colour picker drives every colour field, fill and border alike.",
46
+ "placement": "left"
47
+ },
48
+ {
49
+ "target": ".st-cp-sechd-row",
50
+ "inspector": true,
51
+ "tab": "css",
52
+ "requireSelection": true,
53
+ "title": "Scannable, resettable",
54
+ "body": "Values you've set stand out; untouched rows recede so the difference from the default reads at a glance. The leading dot on each row carries both its status and its reset.",
55
+ "placement": "left"
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "id": "in-canvas-direct-edit",
61
+ "version": "0.30.0",
62
+ "date": "2026-06-12",
63
+ "kind": "feature",
64
+ "title": "Edit the canvas directly — text & layers",
65
+ "summary": "Double-click any text to retype it in place — the new copy is written straight back to the source, no AI round-trip. The Layers tab is a browsable tree with type icons: click a layer to select it in the canvas, hover to highlight, and toggle any layer's visibility from its row. Together with the CSS token editor, small tweaks no longer need a command.",
66
+ "surface": "design-ui",
67
+ "tour": [
68
+ {
69
+ "target": "[data-tour='inspector-tabs']",
70
+ "inspector": true,
71
+ "tab": "layers",
72
+ "requireSelection": true,
73
+ "title": "Browsable layers",
74
+ "body": "⌘-click an element, then this Layers tab shows the artboard's elements as a tree with type icons — click a row to select it in the canvas, hover to highlight.",
75
+ "placement": "left"
76
+ },
77
+ {
78
+ "target": ".st-layer-eye",
79
+ "inspector": true,
80
+ "tab": "layers",
81
+ "requireSelection": true,
82
+ "title": "Toggle visibility",
83
+ "body": "The eye on each layer row hides or shows that element while you work — handy for seeing what sits underneath. Double-click any text in the canvas to retype it in place too.",
84
+ "placement": "left"
85
+ }
86
+ ]
87
+ },
88
+ {
89
+ "id": "studio-chrome-polished-end-to-end",
90
+ "version": "0.30.0",
91
+ "date": "2026-06-12",
92
+ "kind": "feature",
93
+ "title": "Studio chrome, polished end to end",
94
+ "summary": "The whole browser feels hand-finished now. Drag the file and inspector panels to resize them (or nudge with the arrow keys — your widths are remembered), watch a calm skeleton while a canvas compiles, and press ? for a full keyboard-shortcuts cheat sheet. Collaborator cursors and Claude's own cursor now match the design system, annotations snap to the dot grid as you drag, and focus rings, tooltips and scrollbars are consistent throughout. The View menu's Layers and Zoom are wired up, and a shortcut clash is gone — the inspector is ⌘⇧I, so I stays free for the canvas highlighter.",
95
+ "surface": "design-ui"
96
+ },
97
+ {
98
+ "id": "hub-sync-cold-start-safety",
99
+ "version": "0.30.0",
100
+ "date": "2026-06-12",
101
+ "kind": "feature",
102
+ "title": "Hub sync that can't eat your work",
103
+ "summary": "Boot two machines or checkouts linked to the same hub in any order — your local canvas work is safe. When both sides changed the same canvas, Maude keeps a copy of each, takes the newer one, and tells you exactly how to bring back the other; a clean catch-up just updates quietly. The sync status is honest now too: you can see which canvases are synced, pending, or rejected, and why.",
104
+ "surface": "design-ui"
105
+ },
106
+ {
107
+ "id": "canvas-annotations-figjam-v3",
108
+ "version": "0.30.0",
109
+ "date": "2026-06-12",
110
+ "kind": "feature",
111
+ "title": "Annotations grew up: diagram on the canvas",
112
+ "summary": "Marquee-select strokes and just drag them, group with ⌘G, copy/paste, align and reorder. Shapes rotate (hover near a corner), resize from edges with size-matching guides, hold text (double-click any shape), and switch kind — square, circle, diamond, triangle — right from the toolbar. Arrows snap to shapes and re-route as you move things, and the new Section tool (⇧S) frames a region that moves with its content. AI tools can read the board and draw on it too.",
113
+ "surface": "design-ui",
114
+ "tour": [
115
+ {
116
+ "target": ".dc-tool-palette",
117
+ "title": "New tools in the palette",
118
+ "body": "Section (⇧S) frames a region; the Shape tool's primitives can all hold text now — select a shape and press Enter to type."
119
+ }
120
+ ]
121
+ },
4
122
  {
5
123
  "id": "studio-full-functionality",
6
124
  "version": "0.29.0",
@@ -11,14 +129,17 @@
11
129
  "surface": "design-ui",
12
130
  "tour": [
13
131
  {
14
- "target": ".st-mb-right",
132
+ "target": "[data-tour='status']",
15
133
  "title": "Live status, finally",
16
- "body": "Zoom, artboard count, presence and hub-sync now reflect the real state — no more static placeholders."
134
+ "body": "Zoom, artboard count, presence and hub-sync now reflect the real state — no more static placeholders.",
135
+ "placement": "bottom"
17
136
  },
18
137
  {
19
- "target": ".st-rpanel",
138
+ "target": "[data-tour='inspector']",
139
+ "inspector": true,
20
140
  "title": "Inspector",
21
- "body": "⌘-click an element in the canvas, then press I — pos, size, ancestry and markup, read-only (live CSS knobs land in Phase 12)."
141
+ "body": "Opened from View → Inspector (I). ⌘-click an element to read its position, size, ancestry and markup and now edit its CSS against your tokens, live.",
142
+ "placement": "left"
22
143
  }
23
144
  ]
24
145
  },
@@ -41,14 +162,16 @@
41
162
  "surface": "design-ui",
42
163
  "tour": [
43
164
  {
44
- "target": ".st-menubar .st-brand",
165
+ "target": "[data-tour='brand']",
45
166
  "title": "Built in maude",
46
- "body": "The studio chrome now dogfoods the maude DS — one cohesive material across the menubar, sidebar, canvas and status bar."
167
+ "body": "The studio chrome now dogfoods the maude DS — one cohesive material across the menubar, sidebar, canvas and status bar.",
168
+ "placement": "bottom"
47
169
  },
48
170
  {
49
- "target": ".st-menubar .st-whatsnew",
171
+ "target": "[data-tour='menus']",
50
172
  "title": "Press ⌘K",
51
- "body": "A new command palette jumps you to the design-system view, comments, theme toggle, reload, and help — no mouse needed."
173
+ "body": "A command palette jumps you to the design-system view, comments, theme toggle, reload, and help — no mouse needed.",
174
+ "placement": "bottom"
52
175
  }
53
176
  ]
54
177
  },
@@ -62,9 +185,11 @@
62
185
  "surface": "design-ui",
63
186
  "tour": [
64
187
  {
65
- "target": ".dc-tool-palette button[aria-label^='Highlighter']",
188
+ "target": "[data-tour='viewport']",
189
+ "canvas": true,
66
190
  "title": "New: Highlighter",
67
- "body": "A translucent marker pen (I) — overlapping strokes darken with multiply blend. Pick hue and nib width from the chrome below."
191
+ "body": "Open a canvas and press P to enter annotate mode, then pick the Highlighter from the toolbar a translucent marker whose overlapping strokes darken with multiply blend. Sticky notes, shapes and text all take Cmd+B/I/U too.",
192
+ "placement": "auto"
68
193
  }
69
194
  ]
70
195
  },
@@ -59,11 +59,27 @@
59
59
  "properties": {
60
60
  "target": {
61
61
  "type": "string",
62
- "description": "CSS selector or [data-tour=\"<key>\"] anchor to spotlight."
62
+ "description": "CSS selector or [data-tour=\"<key>\"] anchor to spotlight. Prefer the stable [data-tour] anchors over styling classes (which churn on redesigns)."
63
63
  },
64
64
  "title": { "type": "string", "minLength": 1 },
65
65
  "body": { "type": "string", "minLength": 1 },
66
- "placement": { "enum": ["top", "bottom", "left", "right", "auto"] }
66
+ "placement": { "enum": ["top", "bottom", "left", "right", "auto"] },
67
+ "canvas": {
68
+ "type": "boolean",
69
+ "description": "Setup: ensure a canvas is open before the step (the tour opens the first one if none)."
70
+ },
71
+ "inspector": {
72
+ "type": "boolean",
73
+ "description": "Setup: open the right-hand Inspector panel before the step."
74
+ },
75
+ "tab": {
76
+ "enum": ["inspect", "layers", "css"],
77
+ "description": "Setup: switch the Inspector to this tab before the step."
78
+ },
79
+ "requireSelection": {
80
+ "type": "boolean",
81
+ "description": "The target only exists once an element is ⌘-clicked in the (cross-origin) canvas. The card shows a '⌘-click to continue' hint and the spotlight snaps onto the real row when a selection lands."
82
+ }
67
83
  }
68
84
  }
69
85
  }
@@ -282,6 +282,90 @@ test('status reports zero-syncable when _sync.json is notSyncable (DDR-060 / 9.1
282
282
  cleanup();
283
283
  });
284
284
 
285
+ // --------------------------------------------------- DDR-102 status surfaces
286
+
287
+ test('DDR-102: status renders docs counts, rejected slugs, and the conflict recovery hint', async () => {
288
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
289
+ writeFileSync(
290
+ join(workspace, '.design/_sync.json'),
291
+ JSON.stringify({
292
+ state: 'online',
293
+ queuedOps: 0,
294
+ lastSyncAt: 1760000000000,
295
+ offlineSince: null,
296
+ flash: null,
297
+ updatedAt: Date.now(),
298
+ url: URL,
299
+ canvases: 83,
300
+ docs: { synced: 81, pending: 0, rejected: 2 },
301
+ rejectedSlugs: ['system-tokens', 'system-type'],
302
+ conflicts: [
303
+ {
304
+ slug: 'ui-maskot',
305
+ kind: 'cold-start-diverged',
306
+ winner: 'local',
307
+ snapshots: {
308
+ local: '2026-06-11T10:00:00.000Z',
309
+ hub: '2026-06-11T10:00:00.001Z',
310
+ },
311
+ at: 1760000001000,
312
+ },
313
+ ],
314
+ }),
315
+ 'utf8'
316
+ );
317
+
318
+ const human = await runCli(['design', 'status']);
319
+ assert.equal(human.status, 0, human.stderr);
320
+ assert.match(human.stdout, /docs: 81 synced · 0 pending · 2 rejected/);
321
+ assert.match(human.stdout, /not syncing:\s+system-tokens, system-type/);
322
+ assert.match(human.stdout, /ui-maskot — diverged, kept local \(newest-wins\)/);
323
+ assert.match(human.stdout, /local@2026-06-11T10:00:00\.000Z/);
324
+ assert.match(human.stdout, /hub@2026-06-11T10:00:00\.001Z/);
325
+ assert.match(human.stdout, /\/design:rollback ui-maskot/);
326
+
327
+ // --json passes the new fields through verbatim.
328
+ const json = await runCli(['design', 'status', '--json']);
329
+ const payload = JSON.parse(json.stdout);
330
+ assert.equal(payload.sync.docs.rejected, 2);
331
+ assert.equal(payload.sync.conflicts[0].winner, 'local');
332
+ cleanup();
333
+ });
334
+
335
+ test('DDR-102: status renders an OLD-shape _sync.json (no docs/rejectedSlugs) unchanged', async () => {
336
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
337
+ writeFileSync(
338
+ join(workspace, '.design/_sync.json'),
339
+ JSON.stringify({
340
+ state: 'online',
341
+ queuedOps: 0,
342
+ lastSyncAt: null,
343
+ offlineSince: null,
344
+ flash: null,
345
+ updatedAt: Date.now(),
346
+ url: URL,
347
+ canvases: 5,
348
+ conflicts: [],
349
+ }),
350
+ 'utf8'
351
+ );
352
+ const human = await runCli(['design', 'status']);
353
+ assert.equal(human.status, 0, human.stderr);
354
+ assert.match(human.stdout, /sync agent:\s+online/);
355
+ assert.doesNotMatch(human.stdout, /docs:/);
356
+ assert.doesNotMatch(human.stdout, /not syncing:/);
357
+ cleanup();
358
+ });
359
+
360
+ test('DDR-102: re-linking the same hub prints the token-replacement notice', async () => {
361
+ await runCli(['design', 'link', URL, '--token', 'mau_first']);
362
+ const second = await runCli(['design', 'link', URL, '--token', 'mau_second']);
363
+ assert.equal(second.status, 0, second.stderr);
364
+ assert.match(second.stdout, /replacing the stored token for/);
365
+ assert.match(second.stdout, /EVERY project linked to this hub on this machine/);
366
+ cleanup();
367
+ });
368
+
285
369
  // --------------------------------------------------- DDR-054 F2/F4 trust gate
286
370
 
287
371
  const REMOTE_URL = 'http://hub.invalid:9999'; // .invalid never resolves (RFC 6761)
@@ -42,6 +42,7 @@ const BIN_VERBS = new Set([
42
42
  'svg-optimize',
43
43
  'to-lottie',
44
44
  'read-annotations',
45
+ 'annotate',
45
46
  ]);
46
47
 
47
48
  // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
@@ -134,7 +135,7 @@ Lifecycle:
134
135
  Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
135
136
  screenshot · server-up · prep · slug · bootstrap-check · runtime-health
136
137
  smoke · canvas-edit · handoff · asset-sweep · visual-sanity
137
- draw-build · draw-proof · svg-optimize · to-lottie · read-annotations
138
+ draw-build · draw-proof · svg-optimize · to-lottie · read-annotations · annotate
138
139
  Invoke the bundled helper of the same name. maude resolves it from its
139
140
  own package root and sets CLAUDE_PLUGIN_ROOT for the child; stdout,
140
141
  stderr, and exit code pass straight through (so command-substitution
@@ -165,7 +165,11 @@ test('`maude studio` aliases `maude design serve` (identical boot resolution)',
165
165
  encoding: 'utf8',
166
166
  timeout: 30000,
167
167
  });
168
- return { status: r.status, firstErr: (r.stderr || '').split('\n').find(Boolean) ?? '' };
168
+ // Each invocation mkdtemp's its own root, and the diagnostic embeds that
169
+ // path — mask it so we compare the path-independent signature (the test's
170
+ // actual intent: same exit code + same error shape from both entrypoints).
171
+ const firstErr = ((r.stderr || '').split('\n').find(Boolean) ?? '').replaceAll(cwd, '<root>');
172
+ return { status: r.status, firstErr };
169
173
  } finally {
170
174
  rmSync(cwd, { recursive: true, force: true });
171
175
  }
@@ -108,6 +108,16 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
108
108
  process.exit(1);
109
109
  }
110
110
 
111
+ // DDR-102 incident learning (same-machine token overwrite): hubs.json is
112
+ // keyed per hub URL only, so a second `link --token` silently replaced the
113
+ // first machine-wide — both dev servers then authenticated as the same
114
+ // label and shared one hub rate bucket. Make the overwrite explicit.
115
+ if (getHub(normUrl)) {
116
+ process.stdout.write(
117
+ `[design link] note: replacing the stored token for ${normUrl} (the token is per-machine — this applies to EVERY project linked to this hub on this machine).\n`
118
+ );
119
+ }
120
+
111
121
  // Write hub side: token (+ adopt attestation) in ~/.config/maude/hubs.json.
112
122
  const hubRecord = addHub(normUrl, token, adopt ? { adoptedAt: Date.now() } : {});
113
123
 
@@ -262,16 +272,56 @@ export async function runStatus({ args, cwd = process.cwd() }) {
262
272
  }
263
273
 
264
274
  const uptimeS = Math.round((probe.uptimeMs ?? 0) / 1000);
275
+ // DDR-102 — per-doc rollup (old payloads without `docs` render unchanged).
276
+ const docsLine = sync?.docs
277
+ ? `, docs: ${sync.docs.synced} synced · ${sync.docs.pending} pending · ${sync.docs.rejected} rejected`
278
+ : '';
265
279
  const syncLine = sync?.notSyncable
266
280
  ? `linked but 0 syncable canvases — ${sync.reason}`
267
281
  : sync
268
282
  ? `${sync.state}${sync.sharedDoc ? ' [shared-doc]' : ''}${sync.queuedOps ? ` — ${sync.queuedOps} edit(s) queued` : ''}${
269
283
  sync.lastSyncAt ? `, last sync ${new Date(sync.lastSyncAt).toISOString()}` : ''
270
- }${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
284
+ }${docsLine}${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
271
285
  : 'idle (start `maude design serve` in linked mode)';
272
286
  process.stdout.write(
273
287
  `Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n TSX sync: ${cfg.linkedHub.syncTsx === false ? 'off (opted out — linkedHub.syncTsx: false)' : 'on (default — DDR-079)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent: ${syncLine}\n`
274
288
  );
289
+ // DDR-102 — auth-rejected canvases (per-slug honesty).
290
+ if (Array.isArray(sync?.rejectedSlugs) && sync.rejectedSlugs.length > 0) {
291
+ const total = sync.docs?.rejected ?? sync.rejectedSlugs.length;
292
+ const more =
293
+ total > sync.rejectedSlugs.length ? ` (+${total - sync.rejectedSlugs.length} more)` : '';
294
+ process.stdout.write(
295
+ ` not syncing: ${sync.rejectedSlugs.join(', ')}${more} — hub rejected auth; the serve log names the reason (scope / invalid token / rate limit).\n`
296
+ );
297
+ }
298
+ // DDR-102 — conflict log with winner + snapshot refs + the recovery story.
299
+ if (Array.isArray(sync?.conflicts) && sync.conflicts.length > 0) {
300
+ process.stdout.write(' conflicts:\n');
301
+ for (const c of sync.conflicts.slice(-10)) {
302
+ const at = c.at ? new Date(c.at).toISOString() : '?';
303
+ if (c.kind === 'cold-start-diverged') {
304
+ const snaps = c.snapshots
305
+ ? ` snapshots: ${[
306
+ c.snapshots.local ? `local@${c.snapshots.local}` : null,
307
+ c.snapshots.hub ? `hub@${c.snapshots.hub}` : null,
308
+ ]
309
+ .filter(Boolean)
310
+ .join(' ')}`
311
+ : '';
312
+ process.stdout.write(
313
+ ` ${at} ${c.slug} — diverged, kept ${c.winner ?? 'hub'} (newest-wins);${snaps}\n recover the other version: /design:rollback ${c.slug}\n`
314
+ );
315
+ if (c.snapshotFailed) {
316
+ process.stdout.write(
317
+ ` ⚠ the _history snapshot FAILED — local was KEPT instead of overwritten (DDR-102 fail-closed); check disk space / _history write perms.\n`
318
+ );
319
+ }
320
+ } else {
321
+ process.stdout.write(` ${at} ${c.slug} — ${c.kind}\n`);
322
+ }
323
+ }
324
+ }
275
325
  // DDR-079 migration advisory: a config with no explicit `syncTsx` rides the
276
326
  // default, which FLIPPED from off→on in maude 0.27. Surface it so an upgrader
277
327
  // who relied on the old "TSX never syncs" behavior isn't surprised that every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.29.0",
3
+ "version": "0.30.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": {
@@ -41,13 +41,13 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh && bash apps/studio/bin/check-runtime-bundles.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.29.0",
45
- "@1agh/maude-darwin-x64": "0.29.0",
46
- "@1agh/maude-linux-arm64": "0.29.0",
47
- "@1agh/maude-linux-arm64-musl": "0.29.0",
48
- "@1agh/maude-linux-x64": "0.29.0",
49
- "@1agh/maude-linux-x64-musl": "0.29.0",
50
- "@1agh/maude-win32-x64": "0.29.0"
44
+ "@1agh/maude-darwin-arm64": "0.30.0",
45
+ "@1agh/maude-darwin-x64": "0.30.0",
46
+ "@1agh/maude-linux-arm64": "0.30.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.30.0",
48
+ "@1agh/maude-linux-x64": "0.30.0",
49
+ "@1agh/maude-linux-x64-musl": "0.30.0",
50
+ "@1agh/maude-win32-x64": "0.30.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -177,14 +177,27 @@
177
177
  // ai-activity via postMessage when embedded; the own-WS path (standalone /
178
178
  // same-origin inspector socket) is handled in connectHmr below.
179
179
  let agentEditing = false;
180
+ // SECURITY (ethical-hacker A1) — the CSS-panel optimistic-reload-skip
181
+ // timestamp lives HERE, in the shell-script closure, NOT a window global:
182
+ // the canvas module shares this realm, so a window global would let
183
+ // untrusted canvas JS pin it and suppress reloads to desync view/source.
184
+ // It's set ONLY from the parent-gated message below (a canvas self-post
185
+ // can't reach it), and the HMR echo-guard reads this local, not window.*.
186
+ let lastCssOptimisticAt = 0;
180
187
  window.addEventListener('message', (e) => {
181
188
  // DDR-078 security follow-up: only the trusted embedding parent relays
182
- // ai-activity. Reject canvas self-posts (which could flip the HMR gate)
183
- // and any non-parent source. Standalone gets it via its own WS below.
189
+ // ai-activity / apply-style. Reject canvas self-posts (which could flip
190
+ // the HMR gate) and any non-parent source. Standalone gets it via its WS.
184
191
  if (e.source !== window.parent || window.parent === window) return;
185
192
  const m = e && e.data;
186
- if (!m || typeof m !== 'object' || m.dgn !== 'ai-activity') return;
187
- if (myFileKey && m.file === myFileKey) agentEditing = !!m.entry;
193
+ if (!m || typeof m !== 'object') return;
194
+ if (m.dgn === 'ai-activity') {
195
+ if (myFileKey && m.file === myFileKey) agentEditing = !!m.entry;
196
+ } else if (m.dgn === 'apply-style') {
197
+ // The panel just optimistically applied an edit → skip the redundant
198
+ // follow-up module reload (set here, parent-verified).
199
+ lastCssOptimisticAt = Date.now();
200
+ }
188
201
  });
189
202
 
190
203
  // Soft reload — re-import the canvas module (cache-busted) and swap it in
@@ -320,6 +333,17 @@
320
333
  } else if (msg.mode === 'module' || msg.mode === 'hard') {
321
334
  // Only reload when the change touches *this* canvas (or `_lib`).
322
335
  if (msg.mode === 'hard' || !msg.file || (canvasRel && msg.file === canvasRel)) {
336
+ // Phase 12.3 — CSS-panel echo guard. The panel applies each
337
+ // edit optimistically to the live DOM (dgn:'apply-style'), so a
338
+ // `module` reload for THIS canvas's own edit is redundant churn:
339
+ // it remounts the canvas, drops the selection halo, and flickers
340
+ // the iframe. Skip it within a short window of a self-applied
341
+ // optimistic style. `hard` (_lib rebuild) is global — never
342
+ // skipped. Mirrors the meta echo-guard below.
343
+ if (msg.mode === 'module') {
344
+ // read the parent-verified local (NOT a canvas-writable global)
345
+ if (Date.now() - lastCssOptimisticAt < 1500) return;
346
+ }
323
347
  // Phase 13.1 / DDR-077 — when an agent is live-editing, soft-
324
348
  // reload (hold last good on a broken intermediate). Manual edits
325
349
  // (or no runtime yet) keep the plain, immediate hard reload.