@1agh/maude 0.40.0 → 0.41.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 (73) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -3
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. package/plugins/design/dependencies.json +10 -2
@@ -1409,6 +1409,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1409
1409
  property?: unknown;
1410
1410
  value?: unknown;
1411
1411
  reset?: unknown;
1412
+ idIndex?: unknown;
1412
1413
  }>(req, 8 * 1024);
1413
1414
  if (!body) return new Response('body required', { status: 400 });
1414
1415
  const result = await api.editCss(body);
@@ -1780,6 +1781,228 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
1780
1781
  );
1781
1782
  },
1782
1783
 
1784
+ '/_api/delete-element': async (req: Request) => {
1785
+ // Stage I (feature-element-editing-robustness) — delete an element by
1786
+ // data-cd-id. POST { canvas, id, idIndex? } → api.deleteElementOp (reparse
1787
+ // gate; reused-component instance via idIndex). Logs a whole-file undo seq
1788
+ // (Cmd+Z via /_api/reorder-revert — a structural edit churns positional
1789
+ // ids so an inverse descriptor goes stale). Same MAIN-ORIGIN-ONLY trust
1790
+ // boundary as /_api/reorder: absent from CANVAS_SAFE_API + startCanvasServer
1791
+ // routes (DDR-054); sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated.
1792
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1793
+ if (!sameOriginWrite(req))
1794
+ return new Response('cross-origin write rejected', { status: 403 });
1795
+ if (!isLoopbackHost(req.headers.get('host')))
1796
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1797
+ const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
1798
+ req,
1799
+ 8 * 1024
1800
+ );
1801
+ if (!body) return new Response('body required', { status: 400 });
1802
+ const result = await api.deleteElementOp(body);
1803
+ if (!result.ok) {
1804
+ return Response.json(
1805
+ { ok: false, error: result.error },
1806
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1807
+ );
1808
+ }
1809
+ return Response.json(
1810
+ { ok: true, deletedId: result.deletedId, seq: result.seq },
1811
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1812
+ );
1813
+ },
1814
+
1815
+ '/_api/insert-element': async (req: Request) => {
1816
+ // Stage I — insert a synthesized div/text/image relative to `refId`. POST
1817
+ // { canvas, refId, position, kind, src?, refIndex? } → api.insertElementOp.
1818
+ // Returns the new element's post-transpile id so the shell can select it.
1819
+ // Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both allowlists);
1820
+ // sameOriginWrite + loopback-Host gated. An `image` src is contained to
1821
+ // assets/ in the engine (no remote hotlink / ../ / scheme).
1822
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1823
+ if (!sameOriginWrite(req))
1824
+ return new Response('cross-origin write rejected', { status: 403 });
1825
+ if (!isLoopbackHost(req.headers.get('host')))
1826
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1827
+ const body = await readJson<{
1828
+ canvas?: unknown;
1829
+ refId?: unknown;
1830
+ position?: unknown;
1831
+ kind?: unknown;
1832
+ src?: unknown;
1833
+ refIndex?: unknown;
1834
+ }>(req, 8 * 1024);
1835
+ if (!body) return new Response('body required', { status: 400 });
1836
+ const result = await api.insertElementOp(body);
1837
+ if (!result.ok) {
1838
+ return Response.json(
1839
+ { ok: false, error: result.error },
1840
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1841
+ );
1842
+ }
1843
+ return Response.json(
1844
+ { ok: true, newId: result.newId, seq: result.seq },
1845
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1846
+ );
1847
+ },
1848
+
1849
+ '/_api/duplicate-element': async (req: Request) => {
1850
+ // Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
1851
+ // { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
1852
+ // MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
1853
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1854
+ if (!sameOriginWrite(req))
1855
+ return new Response('cross-origin write rejected', { status: 403 });
1856
+ if (!isLoopbackHost(req.headers.get('host')))
1857
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1858
+ const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
1859
+ req,
1860
+ 8 * 1024
1861
+ );
1862
+ if (!body) return new Response('body required', { status: 400 });
1863
+ const result = await api.duplicateElementOp(body);
1864
+ if (!result.ok) {
1865
+ return Response.json(
1866
+ { ok: false, error: result.error },
1867
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1868
+ );
1869
+ }
1870
+ return Response.json(
1871
+ { ok: true, newId: result.newId, seq: result.seq },
1872
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1873
+ );
1874
+ },
1875
+
1876
+ '/_api/insert-artboard': async (req: Request) => {
1877
+ // Stage I4 — insert a new EMPTY artboard from a screen-size preset. POST
1878
+ // { canvas, id, label, width, height } → api.insertArtboardOp (appends a
1879
+ // <DCArtboard id label width height></DCArtboard> after the last artboard;
1880
+ // size JSX-authoritative per DDR-027). Whole-file undo seq. MAIN-ORIGIN
1881
+ // ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated.
1882
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1883
+ if (!sameOriginWrite(req))
1884
+ return new Response('cross-origin write rejected', { status: 403 });
1885
+ if (!isLoopbackHost(req.headers.get('host')))
1886
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1887
+ const body = await readJson<{
1888
+ canvas?: unknown;
1889
+ id?: unknown;
1890
+ label?: unknown;
1891
+ width?: unknown;
1892
+ height?: unknown;
1893
+ }>(req, 8 * 1024);
1894
+ if (!body) return new Response('body required', { status: 400 });
1895
+ const result = await api.insertArtboardOp(body);
1896
+ if (!result.ok) {
1897
+ return Response.json(
1898
+ { ok: false, error: result.error },
1899
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1900
+ );
1901
+ }
1902
+ return Response.json(
1903
+ { ok: true, artboardId: result.artboardId, seq: result.seq },
1904
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1905
+ );
1906
+ },
1907
+
1908
+ '/_api/assets': async (req: Request) => {
1909
+ // Stage F1 (feature-element-editing-robustness) — list content-addressed
1910
+ // image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
1911
+ // image / insert image). GET → { assets:[{path,name,ext,kind,size,mtimeMs}] }.
1912
+ // MAIN-ORIGIN ONLY: the picker is a shell dialog; absent from CANVAS_SAFE_API
1913
+ // + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
1914
+ // (DNS-rebinding); read-only, so no sameOriginWrite (GET).
1915
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1916
+ if (!isLoopbackHost(req.headers.get('host')))
1917
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1918
+ const r = await api.listAssets();
1919
+ return Response.json(
1920
+ { ok: true, assets: r.assets },
1921
+ { headers: { 'Cache-Control': 'no-store' } }
1922
+ );
1923
+ },
1924
+
1925
+ '/_api/edit-scope': async (req: Request) => {
1926
+ // Stage H (feature-element-editing-robustness) — the INV-3 predictability
1927
+ // verdict. GET ?canvas&id&rendered → { ok, scope:'local'|'shared',
1928
+ // componentName, affects, reason }. READ-only (a parse the shell runs on
1929
+ // selection to render the Local/Shared badge). MAIN-ORIGIN ONLY: absent
1930
+ // from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
1931
+ // loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
1932
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1933
+ if (!isLoopbackHost(req.headers.get('host')))
1934
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1935
+ const url = new URL(req.url);
1936
+ const result = await api.editScopeOp({
1937
+ canvas: url.searchParams.get('canvas') ?? undefined,
1938
+ id: url.searchParams.get('id') ?? undefined,
1939
+ rendered: url.searchParams.get('rendered') ?? undefined,
1940
+ });
1941
+ if (!result.ok) {
1942
+ return Response.json(
1943
+ { ok: false, error: result.error },
1944
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1945
+ );
1946
+ }
1947
+ return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
1948
+ },
1949
+
1950
+ '/_api/resize-artboard': async (req: Request) => {
1951
+ // Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
1952
+ // height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
1953
+ // the <DCArtboard id="…">, addressed by its `id` prop since the rendered
1954
+ // <article data-dc-screen> carries no data-cd-id; DDR-027). Whole-file undo
1955
+ // seq. MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated.
1956
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1957
+ if (!sameOriginWrite(req))
1958
+ return new Response('cross-origin write rejected', { status: 403 });
1959
+ if (!isLoopbackHost(req.headers.get('host')))
1960
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1961
+ const body = await readJson<{
1962
+ canvas?: unknown;
1963
+ artboardId?: unknown;
1964
+ width?: unknown;
1965
+ height?: unknown;
1966
+ }>(req, 8 * 1024);
1967
+ if (!body) return new Response('body required', { status: 400 });
1968
+ const result = await api.resizeArtboardOp(body);
1969
+ if (!result.ok) {
1970
+ return Response.json(
1971
+ { ok: false, error: result.error },
1972
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1973
+ );
1974
+ }
1975
+ return Response.json(
1976
+ { ok: true, seq: result.seq },
1977
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1978
+ );
1979
+ },
1980
+
1981
+ '/_api/delete-artboard': async (req: Request) => {
1982
+ // Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
1983
+ // POST { canvas, artboardId } → api.deleteArtboardOp (removes the
1984
+ // <DCArtboard id="…"> span; refuses the last one). Whole-file undo seq.
1985
+ // MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
1986
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1987
+ if (!sameOriginWrite(req))
1988
+ return new Response('cross-origin write rejected', { status: 403 });
1989
+ if (!isLoopbackHost(req.headers.get('host')))
1990
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1991
+ const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
1992
+ if (!body) return new Response('body required', { status: 400 });
1993
+ const result = await api.deleteArtboardOp(body);
1994
+ if (!result.ok) {
1995
+ return Response.json(
1996
+ { ok: false, error: result.error },
1997
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1998
+ );
1999
+ }
2000
+ return Response.json(
2001
+ { ok: true, seq: result.seq },
2002
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
2003
+ );
2004
+ },
2005
+
1783
2006
  '/_api/asset': async (req: Request) => {
1784
2007
  // Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
1785
2008
  // paste). POST raw bytes → content-addressed write under
@@ -475,6 +475,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
475
475
  if (action.kind !== 'no-op') {
476
476
  e.preventDefault();
477
477
  e.stopImmediatePropagation();
478
+ // preventDefault above stops mousedown's default FOCUS, so a Cmd-click
479
+ // select would leave the iframe unfocused and every in-canvas keyboard
480
+ // shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
481
+ // drag happened to focus it (dogfood: "funguje jen když hnu myší").
482
+ // Restore canvas focus explicitly so the keydown listeners fire.
483
+ if (action.kind === 'select') {
484
+ try {
485
+ window.focus();
486
+ } catch {
487
+ /* focus may be rejected outside a user gesture */
488
+ }
489
+ }
478
490
  }
479
491
  };
480
492
 
@@ -54,7 +54,7 @@ function isChromeTarget(t: EventTarget | null): boolean {
54
54
  const el = t as Element;
55
55
  if (
56
56
  el.closest(
57
- '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
57
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-el-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
58
58
  )
59
59
  ) {
60
60
  return true;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file measure-overlay.tsx — Task L7 (feature-element-editing-robustness)
3
+ * @scope apps/studio/measure-overlay.tsx
4
+ * @purpose Alt-hover distance measurement (Figma parity): with exactly one
5
+ * element selected, holding Alt and hovering a DIFFERENT stamped
6
+ * element paints a red guide line + pixel-distance pill between
7
+ * the two boxes' facing edges, on whichever axis(es) actually
8
+ * have a gap. Read-only — never writes anything, no undo, no
9
+ * write-surface implications.
10
+ *
11
+ * Mirrors `equal-spacing-handles.tsx`'s fixed-rAF-follow scaffold
12
+ * (persistent DOM children, no React re-render per frame) and
13
+ * reuses its pairwise-gap sibling `computePairGap`
14
+ * (equal-spacing-detector.ts) for the math.
15
+ *
16
+ * Alt-tracking mirrors `use-cursor-modifiers.tsx`'s
17
+ * `reduceModifiers` reducer, but keeps the result in React state
18
+ * (gating hover RESOLUTION, a real cost) rather than a DOM
19
+ * attribute (that hook only drives CSS cursor swaps).
20
+ *
21
+ * Distances are printed in WORLD px (screen delta ÷ the world's
22
+ * zoom) so the number stays correct at any zoom level — the
23
+ * Stage-A host-scroll-0 invariant is what makes a screen-rect
24
+ * delta ÷ zoom equivalent to a true world-space measurement.
25
+ */
26
+
27
+ import { useEffect, useRef, useState } from 'react';
28
+ import { useArtboardsContext } from './canvas-lib.tsx';
29
+ import { resolveSelectionEl } from './dom-selection.ts';
30
+ import { computePairGap } from './equal-spacing-detector.ts';
31
+ import { resolveHoverTarget } from './input-router.tsx';
32
+ import { type ModifierState, reduceModifiers } from './use-cursor-modifiers.tsx';
33
+ import { useSelectionSet } from './use-selection-set.tsx';
34
+
35
+ const MEASURE_CSS = `
36
+ .dc-cv-measure-layer {
37
+ position: fixed;
38
+ inset: 0;
39
+ pointer-events: none;
40
+ z-index: 7;
41
+ }
42
+ .dc-cv-measure-line {
43
+ position: absolute;
44
+ background: #FF3B30;
45
+ opacity: 0;
46
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
47
+ }
48
+ .dc-cv-measure-line[data-axis="x"] { height: 1px; }
49
+ .dc-cv-measure-line[data-axis="y"] { width: 1px; }
50
+ .dc-cv-measure-line[data-on="true"] { opacity: 1; }
51
+ .dc-cv-measure-pill {
52
+ position: absolute;
53
+ transform: translate(-50%, -50%);
54
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
55
+ font-size: 10px;
56
+ padding: 2px 5px;
57
+ background: #FF3B30;
58
+ color: #ffffff;
59
+ border-radius: 2px;
60
+ letter-spacing: 0.02em;
61
+ white-space: nowrap;
62
+ opacity: 0;
63
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
64
+ }
65
+ .dc-cv-measure-pill[data-on="true"] { opacity: 1; }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .dc-cv-measure-line, .dc-cv-measure-pill { transition-duration: 1ms; }
68
+ }
69
+ `.trim();
70
+
71
+ function ensureStyles(): void {
72
+ if (typeof document === 'undefined') return;
73
+ if (document.getElementById('dc-cv-measure-css')) return;
74
+ const s = document.createElement('style');
75
+ s.id = 'dc-cv-measure-css';
76
+ s.textContent = MEASURE_CSS;
77
+ document.head.appendChild(s);
78
+ }
79
+
80
+ interface ScreenRect {
81
+ x: number;
82
+ y: number;
83
+ w: number;
84
+ h: number;
85
+ }
86
+
87
+ function rectOf(el: Element): ScreenRect | null {
88
+ const b = (el as HTMLElement).getBoundingClientRect();
89
+ if (b.width === 0 && b.height === 0) return null;
90
+ return { x: b.left, y: b.top, w: b.width, h: b.height };
91
+ }
92
+
93
+ /** Persistent line+pill pair for one axis — created once, toggled via
94
+ * `data-on` (opacity transition) rather than added/removed each frame. */
95
+ function ensureAxisNodes(layer: HTMLDivElement, axis: 'x' | 'y'): [HTMLDivElement, HTMLDivElement] {
96
+ let line = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-line[data-axis="${axis}"]`);
97
+ let pill = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-pill[data-axis="${axis}"]`);
98
+ if (!line) {
99
+ line = document.createElement('div');
100
+ line.className = 'dc-cv-measure-line';
101
+ line.dataset.axis = axis;
102
+ layer.appendChild(line);
103
+ }
104
+ if (!pill) {
105
+ pill = document.createElement('div');
106
+ pill.className = 'dc-cv-measure-pill';
107
+ pill.dataset.axis = axis;
108
+ layer.appendChild(pill);
109
+ }
110
+ return [line, pill];
111
+ }
112
+
113
+ function hideAxis(layer: HTMLDivElement, axis: 'x' | 'y'): void {
114
+ const [line, pill] = ensureAxisNodes(layer, axis);
115
+ line.dataset.on = 'false';
116
+ pill.dataset.on = 'false';
117
+ }
118
+
119
+ function paintAxis(
120
+ layer: HTMLDivElement,
121
+ axis: 'x' | 'y',
122
+ a: ScreenRect,
123
+ b: ScreenRect,
124
+ zoom: number
125
+ ): void {
126
+ const g = computePairGap(a, b, axis);
127
+ const [line, pill] = ensureAxisNodes(layer, axis);
128
+ if (!g) {
129
+ line.dataset.on = 'false';
130
+ pill.dataset.on = 'false';
131
+ return;
132
+ }
133
+ const worldGap = Math.round(g.gap / (zoom || 1));
134
+ if (axis === 'x') {
135
+ line.style.left = `${Math.round(g.from)}px`;
136
+ line.style.top = `${Math.round(g.cross)}px`;
137
+ line.style.width = `${Math.round(g.gap)}px`;
138
+ pill.style.left = `${Math.round(g.from + g.gap / 2)}px`;
139
+ pill.style.top = `${Math.round(g.cross)}px`;
140
+ } else {
141
+ line.style.left = `${Math.round(g.cross)}px`;
142
+ line.style.top = `${Math.round(g.from)}px`;
143
+ line.style.height = `${Math.round(g.gap)}px`;
144
+ pill.style.left = `${Math.round(g.cross)}px`;
145
+ pill.style.top = `${Math.round(g.from + g.gap / 2)}px`;
146
+ }
147
+ pill.textContent = `${worldGap}`;
148
+ line.dataset.on = 'true';
149
+ pill.dataset.on = 'true';
150
+ }
151
+
152
+ export function MeasureOverlay() {
153
+ ensureStyles();
154
+ const { selected } = useSelectionSet();
155
+ const world = useArtboardsContext();
156
+ const layerRef = useRef<HTMLDivElement | null>(null);
157
+ const [alt, setAlt] = useState(false);
158
+ const [hoverEl, setHoverEl] = useState<Element | null>(null);
159
+
160
+ // Alt + hover tracking, gated together: hover RESOLUTION (a real per-move
161
+ // cost — resolveHoverTarget walks the DOM) only runs while Alt is held.
162
+ useEffect(() => {
163
+ if (typeof document === 'undefined') return;
164
+ let mods: ModifierState = { alt: false, shift: false, meta: false };
165
+ const onKeyChange = (e: KeyboardEvent) => {
166
+ const next = reduceModifiers(mods, e);
167
+ if (next === mods) return;
168
+ mods = next;
169
+ setAlt(mods.alt);
170
+ if (!mods.alt) setHoverEl(null);
171
+ };
172
+ const onPointer = (e: PointerEvent) => {
173
+ const next = reduceModifiers(mods, e);
174
+ if (next !== mods) {
175
+ mods = next;
176
+ setAlt(mods.alt);
177
+ }
178
+ if (!mods.alt) {
179
+ setHoverEl(null);
180
+ return;
181
+ }
182
+ const t = resolveHoverTarget(document, e.clientX, e.clientY, { deep: true });
183
+ setHoverEl(t?.el ?? null);
184
+ };
185
+ const onBlur = () => {
186
+ mods = { alt: false, shift: false, meta: false };
187
+ setAlt(false);
188
+ setHoverEl(null);
189
+ };
190
+ document.addEventListener('keydown', onKeyChange, true);
191
+ document.addEventListener('keyup', onKeyChange, true);
192
+ document.addEventListener('pointermove', onPointer, { passive: true });
193
+ window.addEventListener('blur', onBlur);
194
+ return () => {
195
+ document.removeEventListener('keydown', onKeyChange, true);
196
+ document.removeEventListener('keyup', onKeyChange, true);
197
+ document.removeEventListener('pointermove', onPointer);
198
+ window.removeEventListener('blur', onBlur);
199
+ };
200
+ }, []);
201
+
202
+ const one = selected.length === 1 ? selected[0] : null;
203
+ const zoom = world?.viewport?.zoom || 1;
204
+ const active = alt && !!one && !!hoverEl;
205
+
206
+ useEffect(() => {
207
+ const layer = layerRef.current;
208
+ if (!layer) return;
209
+ const hideBoth = () => {
210
+ hideAxis(layer, 'x');
211
+ hideAxis(layer, 'y');
212
+ };
213
+ if (!active || !one || !hoverEl) {
214
+ hideBoth();
215
+ return;
216
+ }
217
+ let alive = true;
218
+ let rafId: number | null = null;
219
+ const tick = () => {
220
+ rafId = null;
221
+ if (!alive) return;
222
+ const selEl = resolveSelectionEl(document, one);
223
+ const a = selEl && selEl !== hoverEl && hoverEl.isConnected ? rectOf(selEl) : null;
224
+ const b = a ? rectOf(hoverEl) : null;
225
+ if (a && b) {
226
+ paintAxis(layer, 'x', a, b, zoom);
227
+ paintAxis(layer, 'y', a, b, zoom);
228
+ } else {
229
+ hideBoth();
230
+ }
231
+ rafId = requestAnimationFrame(tick);
232
+ };
233
+ rafId = requestAnimationFrame(tick);
234
+ return () => {
235
+ alive = false;
236
+ if (rafId != null) cancelAnimationFrame(rafId);
237
+ };
238
+ }, [active, one, hoverEl, zoom]);
239
+
240
+ return <div ref={layerRef} className="dc-cv-measure-layer" aria-hidden="true" />;
241
+ }
@@ -29,7 +29,7 @@ const CHROME_CSS = `
29
29
  display: flex;
30
30
  align-items: center;
31
31
  pointer-events: auto;
32
- font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
32
+ font-family: system-ui, -apple-system, sans-serif;
33
33
  }
34
34
  .dc-participant {
35
35
  width: 26px;
@@ -74,7 +74,7 @@ const CHROME_CSS = `
74
74
  background: var(--maude-chrome-bg-1, #fff);
75
75
  color: var(--maude-chrome-fg-0, #111);
76
76
  border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
77
- border-radius: var(--radius-md, 4px);
77
+ border-radius: 4px;
78
78
  font-size: 12px;
79
79
  line-height: 1.35;
80
80
  white-space: nowrap;
@@ -93,7 +93,7 @@ const CHROME_CSS = `
93
93
  background: var(--maude-hud-accent, oklch(56% 0.170 50));
94
94
  color: var(--maude-hud-accent-fg, #fff);
95
95
  border: none;
96
- border-radius: var(--radius-sm, 2px);
96
+ border-radius: 2px;
97
97
  font-family: inherit;
98
98
  font-weight: 500;
99
99
  font-size: 11px;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file sizing-mode.ts
3
+ * @purpose Pure Fixed / Hug / Fill sizing-mode logic for the CssKnobs Size
4
+ * control (feature-element-editing-robustness Stage M1). Kept framework-
5
+ * free + in a .ts file so it's unit-testable (app.jsx is bundled JSX).
6
+ *
7
+ * Figma auto-layout parity: a per-axis Fixed / Hug / Fill mode is the single
8
+ * biggest source of layout-behaviour clarity for React/flexbox mockups.
9
+ * - Fixed = an explicit width/height value.
10
+ * - Hug = shrink to children (`fit-content`).
11
+ * - Fill = stretch to fill the parent — and the CORRECT CSS depends on the
12
+ * parent's layout: a flex child fills its MAIN axis via `flex-grow`,
13
+ * its CROSS axis via `align-self: stretch`; a block/grid child fills
14
+ * via `width/height: 100%`. So the mode must know the parent context
15
+ * (captured at selection time in Selection.parentDisplay /
16
+ * .parentFlexDirection — the shell can't reach the iframe later).
17
+ */
18
+
19
+ export type SizeAxis = 'width' | 'height';
20
+ export type SizeMode = 'fixed' | 'hug' | 'fill';
21
+
22
+ export interface ParentLayout {
23
+ display?: string;
24
+ flexDirection?: string;
25
+ }
26
+
27
+ const HUG_VALUES = new Set(['fit-content', 'max-content', 'min-content']);
28
+ const FILL_VALUES = new Set(['100%', 'fill', '-webkit-fill-available', 'stretch']);
29
+
30
+ function parentIsFlex(p: ParentLayout | undefined): boolean {
31
+ const d = (p?.display || '').trim();
32
+ return d === 'flex' || d === 'inline-flex';
33
+ }
34
+
35
+ /** True when `axis` is the flex MAIN axis of the parent (row → width, column → height). */
36
+ export function isMainAxis(axis: SizeAxis, p: ParentLayout | undefined): boolean {
37
+ const dir = (p?.flexDirection || 'row').trim();
38
+ const rowMain = dir.startsWith('row'); // row / row-reverse
39
+ return rowMain ? axis === 'width' : axis === 'height';
40
+ }
41
+
42
+ /**
43
+ * Best-effort classification of the current sizing mode for the segmented
44
+ * control's active-state highlight. Reads AUTHORED values first (what the user
45
+ * set), so a value the panel wrote round-trips; defaults to `fixed`. Detection is
46
+ * advisory — the WRITE side (`sizingModePatch`) is what must be deterministic.
47
+ */
48
+ export function sizingModeOf(
49
+ axis: SizeAxis,
50
+ authored: Record<string, string> = {},
51
+ _computed: Record<string, string> = {},
52
+ parent?: ParentLayout
53
+ ): SizeMode {
54
+ const a = (authored[axis] || '').trim().toLowerCase();
55
+ if (HUG_VALUES.has(a)) return 'hug';
56
+ if (FILL_VALUES.has(a)) return 'fill';
57
+ if (parentIsFlex(parent)) {
58
+ const main = isMainAxis(axis, parent);
59
+ if (main && Number.parseFloat(authored['flex-grow'] || '0') >= 1) return 'fill';
60
+ if (!main && (authored['align-self'] || '').trim() === 'stretch') return 'fill';
61
+ }
62
+ // A numeric authored value (px/rem/%…) or nothing authored → Fixed.
63
+ return 'fixed';
64
+ }
65
+
66
+ export interface SizingPatch {
67
+ /** Properties to write (in order), each `[prop, value]`. */
68
+ set: Array<[string, string]>;
69
+ /** Fill-specific properties to REMOVE so a prior Fill doesn't linger. */
70
+ reset: string[];
71
+ }
72
+
73
+ /**
74
+ * The deterministic CSS writes for switching `axis` to `mode`. `currentPx` seeds
75
+ * the Fixed value (the element's current rendered size). Fill is context-aware:
76
+ * a flex child fills its main axis via `flex-grow:1` + `flex-basis:0%`, its cross
77
+ * axis via `align-self:stretch`; a block/grid child via `<axis>: 100%`.
78
+ */
79
+ export function sizingModePatch(
80
+ axis: SizeAxis,
81
+ mode: SizeMode,
82
+ parent: ParentLayout | undefined,
83
+ currentPx: number
84
+ ): SizingPatch {
85
+ const flex = parentIsFlex(parent);
86
+ const main = isMainAxis(axis, parent);
87
+ // Which fill-specific props a non-fill mode must clear so the old Fill doesn't linger.
88
+ const fillProps = flex ? (main ? ['flex-grow', 'flex-basis'] : ['align-self']) : [];
89
+ if (mode === 'fixed') {
90
+ const px = Number.isFinite(currentPx) && currentPx > 0 ? Math.round(currentPx) : 0;
91
+ return { set: [[axis, `${px}px`]], reset: fillProps };
92
+ }
93
+ if (mode === 'hug') {
94
+ return { set: [[axis, 'fit-content']], reset: fillProps };
95
+ }
96
+ // fill
97
+ if (flex) {
98
+ if (main) {
99
+ return {
100
+ set: [
101
+ ['flex-grow', '1'],
102
+ ['flex-basis', '0%'],
103
+ [axis, 'auto'],
104
+ ],
105
+ reset: [],
106
+ };
107
+ }
108
+ return {
109
+ set: [
110
+ ['align-self', 'stretch'],
111
+ [axis, 'auto'],
112
+ ],
113
+ reset: [],
114
+ };
115
+ }
116
+ return { set: [[axis, '100%']], reset: [] };
117
+ }