@1agh/maude 0.40.0 → 0.42.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 (102) 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 -44
  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/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -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;
@@ -25,6 +25,7 @@ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts'
25
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
26
26
  import { createCollab } from './collab/index.ts';
27
27
  import { createContext, reloadConfig } from './context.ts';
28
+ import { createExportJobQueue } from './exporters/jobs.ts';
28
29
  import { createFsWatch } from './fs-watch.ts';
29
30
  import { createGitWatch } from './git/watch.ts';
30
31
  import { createHttp } from './http.ts';
@@ -105,7 +106,8 @@ const activity = createActivity(ctx);
105
106
  // rca/issue-canvas-hmr-optimistic-update-consistency).
106
107
  const acp = createAcp(ctx, aiActivity);
107
108
  const ws = createWs(ctx, api, inspect, collab, activity, acp);
108
- const http = createHttp(ctx, api, inspect, aiActivity);
109
+ const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
110
+ const http = createHttp(ctx, api, inspect, aiActivity, exportJobs);
109
111
  const fsWatch = createFsWatch(ctx);
110
112
 
111
113
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -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
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @file spacing-handles.ts — Stage J (feature-element-editing-robustness)
3
+ * @purpose Pure geometry + value math for the on-canvas padding + gap drag
4
+ * overlay. Framework-free (unit-testable without a DOM), mirroring
5
+ * `equal-spacing-detector.ts`'s screen-coordinate midpoint shape so
6
+ * the two spacing affordances (read-only equal-spacing dots vs this
7
+ * draggable padding/gap overlay) share a visual vocabulary.
8
+ *
9
+ * Padding: 4 edge lines inset from the element's screen border-box by
10
+ * its resolved padding × zoom, one per side, each carrying a drag axis
11
+ * (top/bottom = vertical, left/right = horizontal).
12
+ * Gap: midpoints between consecutive DIRECT children along the flex
13
+ * main axis (row → x, column → y) — flex-only in v1; CSS-Grid gap
14
+ * editing is deferred to `feature-grid-track-editor.md` (Stage M3).
15
+ */
16
+
17
+ export interface ScreenRect {
18
+ x: number;
19
+ y: number;
20
+ w: number;
21
+ h: number;
22
+ }
23
+
24
+ export type PaddingSide = 'top' | 'right' | 'bottom' | 'left';
25
+
26
+ export interface PaddingLine {
27
+ side: PaddingSide;
28
+ /** Drag axis: top/bottom edges scrub vertically, left/right horizontally. */
29
+ axis: 'x' | 'y';
30
+ /** Line midpoint in screen coords (the handle's paint position). */
31
+ x: number;
32
+ y: number;
33
+ /** Line length in screen px (spans the box's inner width/height on that side). */
34
+ length: number;
35
+ }
36
+
37
+ /**
38
+ * Compute the 4 padding-edge handle positions from the element's screen
39
+ * border-box rect + its resolved padding (WORLD px, pre-zoom) + the render
40
+ * zoom. A handle sits at the midpoint of its edge, INSET from the border by
41
+ * `padding × zoom` — i.e. right on the padding/content boundary, so dragging it
42
+ * outward (toward the border) shrinks padding, inward (toward center) grows it.
43
+ */
44
+ export function computePaddingLines(
45
+ rect: ScreenRect,
46
+ padding: { top: number; right: number; bottom: number; left: number },
47
+ zoom: number
48
+ ): PaddingLine[] {
49
+ const z = zoom > 0 ? zoom : 1;
50
+ const pt = padding.top * z;
51
+ const pr = padding.right * z;
52
+ const pb = padding.bottom * z;
53
+ const pl = padding.left * z;
54
+ return [
55
+ { side: 'top', axis: 'y', x: rect.x + rect.w / 2, y: rect.y + pt, length: rect.w - pl - pr },
56
+ {
57
+ side: 'right',
58
+ axis: 'x',
59
+ x: rect.x + rect.w - pr,
60
+ y: rect.y + rect.h / 2,
61
+ length: rect.h - pt - pb,
62
+ },
63
+ {
64
+ side: 'bottom',
65
+ axis: 'y',
66
+ x: rect.x + rect.w / 2,
67
+ y: rect.y + rect.h - pb,
68
+ length: rect.w - pl - pr,
69
+ },
70
+ { side: 'left', axis: 'x', x: rect.x + pl, y: rect.y + rect.h / 2, length: rect.h - pt - pb },
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * New padding value (WORLD px, clamped ≥ 0) for a drag on `side`, given the
76
+ * screen-space cursor delta and the render zoom. Top/bottom respond to
77
+ * vertical delta, left/right to horizontal — and the SIGN flips for
78
+ * bottom/right (dragging toward the element's center — up for bottom, left for
79
+ * right — GROWS that side's padding, mirroring how top/left already grow when
80
+ * dragged down/right).
81
+ */
82
+ export function computePaddingDrag(
83
+ side: PaddingSide,
84
+ startValue: number,
85
+ dxScreen: number,
86
+ dyScreen: number,
87
+ zoom: number
88
+ ): number {
89
+ const z = zoom > 0 ? zoom : 1;
90
+ const d = side === 'top' || side === 'bottom' ? dyScreen / z : dxScreen / z;
91
+ const signed = side === 'bottom' || side === 'right' ? -d : d;
92
+ return Math.max(0, round2(startValue + signed));
93
+ }
94
+
95
+ export type FlexAxis = 'x' | 'y';
96
+
97
+ /** Row/row-reverse → x (horizontal main axis); column/column-reverse → y. */
98
+ export function flexMainAxis(flexDirection: string | null | undefined): FlexAxis {
99
+ const dir = (flexDirection || 'row').trim();
100
+ return dir.startsWith('column') ? 'y' : 'x';
101
+ }
102
+
103
+ /**
104
+ * Midpoints between consecutive DIRECT children's screen rects, along `axis`.
105
+ * Mirrors `detectEqualSpacing`'s midpoint shape but needs no equal-spacing
106
+ * DETECTION (there's exactly one `gap` value to visualize, not several to
107
+ * confirm) — just the geometric midpoint of each adjacent pair's facing edges.
108
+ * Fewer than 2 children → no gaps to show.
109
+ */
110
+ export function computeGapMidpoints(
111
+ childRects: ScreenRect[],
112
+ axis: FlexAxis
113
+ ): Array<{ x: number; y: number }> {
114
+ if (childRects.length < 2) return [];
115
+ const sorted = [...childRects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
116
+ const out: Array<{ x: number; y: number }> = [];
117
+ for (let i = 0; i < sorted.length - 1; i++) {
118
+ const a = sorted[i] as ScreenRect;
119
+ const b = sorted[i + 1] as ScreenRect;
120
+ if (axis === 'x') {
121
+ out.push({ x: (a.x + a.w + b.x) / 2, y: a.y + a.h / 2 });
122
+ } else {
123
+ out.push({ x: a.x + a.w / 2, y: (a.y + a.h + b.y) / 2 });
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** New `gap` value (WORLD px, clamped ≥ 0) for a drag along `axis`. Gap is a
130
+ * single shared CSS value, so every handle drags the SAME number 1:1 with the
131
+ * cursor (no doubling/halving — dragging a handle N screen px outward should
132
+ * feel like N world px of extra breathing room). */
133
+ export function computeGapDrag(
134
+ axis: FlexAxis,
135
+ startValue: number,
136
+ dxScreen: number,
137
+ dyScreen: number,
138
+ zoom: number
139
+ ): number {
140
+ const z = zoom > 0 ? zoom : 1;
141
+ const d = axis === 'x' ? dxScreen / z : dyScreen / z;
142
+ return Math.max(0, round2(startValue + d));
143
+ }
144
+
145
+ function round2(n: number): number {
146
+ return Math.round(n * 100) / 100;
147
+ }
148
+
149
+ /**
150
+ * Which padding sides a drag on `side` touches, given the held modifiers —
151
+ * matches the CSS-panel box-model widget's OWN scrub grammar exactly (`side()`
152
+ * in `client/app.jsx`, `title="drag to scrub · alt = symmetric · alt+shift =
153
+ * all sides"`): plain drag = just this side; Alt = the symmetric axis PAIR
154
+ * (top+bottom, or left+right); Alt+Shift = all four. Every touched side is set
155
+ * to the SAME value (not each grown by its own delta) — same semantics as the
156
+ * panel's `sidesFor`/`last` write.
157
+ */
158
+ export function paddingSideSet(
159
+ side: PaddingSide,
160
+ altKey: boolean,
161
+ shiftKey: boolean
162
+ ): PaddingSide[] {
163
+ if (altKey && shiftKey) return ['top', 'right', 'bottom', 'left'];
164
+ if (altKey) return side === 'top' || side === 'bottom' ? ['top', 'bottom'] : ['left', 'right'];
165
+ return [side];
166
+ }