@commonpub/layer 0.23.3 → 0.25.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 (81) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
@@ -0,0 +1,783 @@
1
+ /**
2
+ * useLayoutResize — pointer + keyboard driven section resize.
3
+ *
4
+ * Phase 3c. Implements the resize gesture documented in
5
+ * `docs/plans/layout-and-pages.md` §7.5: drag the right-edge handle to
6
+ * change a section's `colSpan` in the 12-col grid; the right neighbour
7
+ * absorbs the inverse delta so the row's total stays constant; LAST-in-
8
+ * row sections grow into the trailing space without a neighbour.
9
+ *
10
+ * Why vanilla pointer events (NOT grid-layout-plus, despite the package
11
+ * being installed):
12
+ * - grid-layout-plus uses absolute positioning + transforms; our
13
+ * sections live in a CSS `display:grid` row via
14
+ * `grid-column: span var(--cpub-section-cols-lg)`. The two sizing
15
+ * models can't share a child element.
16
+ * - grid-layout-plus's interactjs registers pointerdown on the GridItem
17
+ * wrapper — the same DOM node `@vue-dnd-kit/core` already owns for
18
+ * the drag-section gesture (session 163's verified collision boundary).
19
+ * Owning a SEPARATE child element (the handle) sidesteps the conflict.
20
+ * - Resize from scratch is ~300 lines + tests; the library's
21
+ * coordinate model (x/y/w/h) doesn't match `colSpan` so we'd carry
22
+ * a propMap shim AND maintain the library.
23
+ * - Decision recorded in session 166-3c.md + the
24
+ * `feedback-phase-3-hybrid-libraries` memory entry.
25
+ *
26
+ * Architecture:
27
+ * - Module-scoped singleton state ref. Pointer + keyboard handlers
28
+ * read + mutate the same ref. Matches `useLayoutHistory` and
29
+ * `useLayoutAnnouncer`'s shape — one resize at a time across the
30
+ * entire editor.
31
+ * - Pure helpers (`computeSnappedColSpan`, `clampResize`) export
32
+ * separately so the composable's tests don't need pointer mocks
33
+ * for the math.
34
+ * - Live preview during drag: pointermove mutates `section.colSpan`
35
+ * directly so the existing CSS render path picks it up. The editor's
36
+ * deep watcher fires on every frame — `dirtyVersion` increments
37
+ * ~60×/sec but auto-save's 1.5s debounce coalesces them; the
38
+ * pointermove is rAF-throttled so reactivity only triggers once per
39
+ * frame.
40
+ * - On pointerup: capture the BEFORE/AFTER colSpans + commit ONE
41
+ * `resizeSectionCommand` to history. A pointer-up that ends at the
42
+ * same span as start records nothing — keeps the undo stack from
43
+ * filling with no-op self-equal entries (mirrors the dispatcher's
44
+ * `from===to` reorder skip).
45
+ *
46
+ * Wire diagram:
47
+ * - LayoutSection.vue — renders the right-edge handle button +
48
+ * calls `useLayoutResize().startResize(opts)` on its pointerdown.
49
+ * - useLayoutResize.ts (this file) — owns state + document handlers.
50
+ * - LayoutRow.vue — reads `state.value` to render the 12-col guide
51
+ * overlay + the constraint-snap label DURING a resize. Same row id
52
+ * only.
53
+ * - useLayoutHotkeys.ts — Shift+ArrowLeft / Shift+ArrowRight call
54
+ * `applyKeyboardResize(opts)` to change colSpan by ±1.
55
+ *
56
+ * **Phase 3c base-only resize (round-2 audit P2 deferral)**: this
57
+ * composable mutates `section.colSpan` — the BASE breakpoint value.
58
+ * When `section.responsive.lg` is set, the LG viewport renders that
59
+ * value instead of the base; mutating the base in that case is
60
+ * INVISIBLE at LG. v1 ships with no built-in section setting
61
+ * `responsive` by default + no UI to author it, so this edge is
62
+ * latent. Phase 3e (per-breakpoint editing) needs to route resize at
63
+ * the active viewport to mutate the right field
64
+ * (`responsive[viewport]` or `colSpan` fallback). Tracked in
65
+ * `docs/sessions/167-kickoff.md` Path A subtask 6.
66
+ *
67
+ * SSR: every `document` / `window` reference is guarded with
68
+ * `typeof window !== 'undefined'` per
69
+ * `feedback-vitest-import-meta-client-undefined` — vitest's jsdom env
70
+ * provides `window` so unit tests exercise the same path as the browser.
71
+ */
72
+ import { ref, type Ref } from 'vue';
73
+ import type { LayoutRecord } from '@commonpub/server';
74
+ import type { LayoutSection } from './useLayout';
75
+ import { findSectionLocation } from './useLayoutHistory';
76
+ import {
77
+ useLayoutHistory,
78
+ resizeSectionCommand,
79
+ } from './useLayoutHistory';
80
+ import {
81
+ useLayoutAnnouncer,
82
+ narrateResize,
83
+ narrateResizeBlocked,
84
+ } from './useLayoutAnnouncer';
85
+
86
+ /* ------------------------------------------------------------------ */
87
+ /* Pure helpers — tested independently */
88
+ /* ------------------------------------------------------------------ */
89
+
90
+ /**
91
+ * Snap a continuous pointer delta to an integer column count. The 12-col
92
+ * grid is the unit; sub-grid pointer motion rounds to the nearest column.
93
+ *
94
+ * deltaCols = round((pointerDX / containerWidth) * 12)
95
+ *
96
+ * `containerWidth` is the row's INSIDE width (no padding). Caller reads
97
+ * via `getBoundingClientRect().width` at gesture-start — the value is
98
+ * stable for the duration of a single drag (no row resize mid-drag).
99
+ *
100
+ * Returns the SIGNED integer delta (-12 to +12). Positive = grow; negative
101
+ * = shrink. The caller adds it to the start span + clamps in
102
+ * `clampResize`.
103
+ *
104
+ * Edge: containerWidth=0 (row not yet measured) → delta=0. Resize is
105
+ * effectively disabled until the next pointermove with a real width;
106
+ * pragmatically this never happens because the handle can't be clicked
107
+ * before the row paints.
108
+ */
109
+ export function computeSnappedColSpan(
110
+ pointerDX: number,
111
+ containerWidth: number,
112
+ ): number {
113
+ if (containerWidth <= 0) return 0;
114
+ return Math.round((pointerDX / containerWidth) * 12);
115
+ }
116
+
117
+ /** Constraint-hit discriminator — drives narration + visual feedback.
118
+ * `null` = no constraint hit; the delta applied cleanly. */
119
+ export type ResizeConstraint = 'section-min' | 'section-max' | 'neighbour-min' | null;
120
+
121
+ /** Result of clamping a desired delta against the section's bounds AND
122
+ * (optionally) its right neighbour's bounds. */
123
+ export interface ClampResult {
124
+ /** The new colSpan for the resized section, clamped. */
125
+ newColSpan: number;
126
+ /** The new colSpan for the right neighbour, clamped. Equals the
127
+ * passed `neighbourStart` when no neighbour (LAST in row). */
128
+ newNeighbourColSpan: number;
129
+ /** Which bound the desired delta hit. */
130
+ constraintHit: ResizeConstraint;
131
+ /** The numeric bound the user pushed against (for narration). */
132
+ constraintBound: number;
133
+ }
134
+
135
+ /**
136
+ * Clamp a desired delta against per-section + (optional) neighbour bounds.
137
+ *
138
+ * Invariant for LAST-in-row (no neighbour): just clamp the section's
139
+ * new span to `[sectionMin, sectionMax]`. The trailing space is the
140
+ * renderer's responsibility — CSS Grid flexes it to fill, or the admin
141
+ * drops another section there.
142
+ *
143
+ * Invariant for non-LAST: the row's total colSpan stays constant. So
144
+ * the neighbour absorbs the inverse of the EFFECTIVE delta (after
145
+ * clamping the section's own bounds). If the neighbour would violate
146
+ * ITS own min, we back off the effective delta further so the neighbour
147
+ * sticks at its bound — the resize "stops cold" at the neighbour's
148
+ * minimum per plan §7.5.
149
+ *
150
+ * Pure — no state mutation. Caller decides whether to apply.
151
+ *
152
+ * Pre-condition: caller has snapped + bounds-checked `desiredDelta`
153
+ * against the grid (0 ≤ |delta| ≤ 12). The function handles any signed
154
+ * integer though, for defensive testability.
155
+ */
156
+ export function clampResize(params: {
157
+ startColSpan: number;
158
+ desiredDelta: number;
159
+ sectionMin: number;
160
+ sectionMax: number;
161
+ /** Null when the section is LAST in its row. */
162
+ neighbourStart: number | null;
163
+ neighbourMin: number;
164
+ neighbourMax: number;
165
+ }): ClampResult {
166
+ const { startColSpan, desiredDelta, sectionMin, sectionMax,
167
+ neighbourStart, neighbourMin, neighbourMax } = params;
168
+
169
+ // 1. Desired new span for the resized section, before any clamp.
170
+ const desiredNew = startColSpan + desiredDelta;
171
+
172
+ // 2. Clamp against the section's own bounds first.
173
+ let newColSpan = Math.max(sectionMin, Math.min(sectionMax, desiredNew));
174
+ let constraintHit: ResizeConstraint = null;
175
+ let constraintBound = 0;
176
+ if (desiredNew < sectionMin) {
177
+ constraintHit = 'section-min';
178
+ constraintBound = sectionMin;
179
+ } else if (desiredNew > sectionMax) {
180
+ constraintHit = 'section-max';
181
+ constraintBound = sectionMax;
182
+ }
183
+
184
+ // 3. LAST in row: no neighbour absorption. Section grows into trailing
185
+ // space; shrinks leave trailing space the renderer flexes to fill.
186
+ if (neighbourStart === null) {
187
+ return {
188
+ newColSpan,
189
+ newNeighbourColSpan: 0, // sentinel — caller knows there's no neighbour
190
+ constraintHit,
191
+ constraintBound,
192
+ };
193
+ }
194
+
195
+ // 4. Non-LAST: neighbour absorbs the inverse of the EFFECTIVE delta.
196
+ const effectiveDelta = newColSpan - startColSpan;
197
+ const desiredNeighbour = neighbourStart - effectiveDelta;
198
+
199
+ // 5. Clamp the neighbour against ITS own bounds.
200
+ const clampedNeighbour = Math.max(
201
+ neighbourMin,
202
+ Math.min(neighbourMax, desiredNeighbour),
203
+ );
204
+
205
+ // 6. If the neighbour would have violated its min, back off the section
206
+ // by the same amount so the sum-invariant holds. Plan §7.5: "When the
207
+ // neighbour hits its minimum, the resize stops cold."
208
+ if (clampedNeighbour > desiredNeighbour) {
209
+ // Neighbour hit its MIN (couldn't shrink any more). Reduce the
210
+ // section's growth so neighbour stays at its min. Only override
211
+ // `constraintHit` if the section's own bounds weren't the limit
212
+ // first — neighbour-min is the binding constraint here.
213
+ const backedOffDelta = neighbourStart - clampedNeighbour;
214
+ newColSpan = startColSpan + backedOffDelta;
215
+ if (constraintHit === null) {
216
+ constraintHit = 'neighbour-min';
217
+ constraintBound = neighbourMin;
218
+ }
219
+ } else if (clampedNeighbour < desiredNeighbour) {
220
+ // Neighbour hit its MAX while shrinking the section. Rare — usually
221
+ // means the neighbour's maxColSpan < 12 AND the section shrunk hard.
222
+ // Back off the section so sum-invariant holds; constraint-hit
223
+ // stays section-* because the user-side action was a section shrink.
224
+ const backedOffDelta = neighbourStart - clampedNeighbour;
225
+ newColSpan = startColSpan + backedOffDelta;
226
+ }
227
+
228
+ return {
229
+ newColSpan,
230
+ newNeighbourColSpan: clampedNeighbour,
231
+ constraintHit,
232
+ constraintBound,
233
+ };
234
+ }
235
+
236
+ /* ------------------------------------------------------------------ */
237
+ /* State machine */
238
+ /* ------------------------------------------------------------------ */
239
+
240
+ /** State shape for the resize gesture. `idle` means no resize in flight;
241
+ * `resizing` carries every field the live preview + commit needs. */
242
+ export type ResizeState =
243
+ | { kind: 'idle' }
244
+ | {
245
+ kind: 'resizing';
246
+ /** The row containing the resized section — useful for the row's
247
+ * guide-line overlay (it filters by id). */
248
+ rowId: string;
249
+ /** Section being resized. */
250
+ sectionId: string;
251
+ /** Span at gesture-start — what the eventual `resizeSectionCommand`
252
+ * reverts to. */
253
+ startColSpan: number;
254
+ /** Live snapped span; reflects what the section's `colSpan`
255
+ * currently holds (the composable mutates the draft to this). */
256
+ currentColSpan: number;
257
+ /** Right neighbour's id; null when the resized section is LAST. */
258
+ neighbourId: string | null;
259
+ /** Neighbour's start + current span. 0 when no neighbour. */
260
+ neighbourStartColSpan: number;
261
+ neighbourCurrentColSpan: number;
262
+ /** Per-section colSpan bounds — used by `clampResize` + by the
263
+ * narration helpers. */
264
+ sectionMin: number;
265
+ sectionMax: number;
266
+ neighbourMin: number;
267
+ neighbourMax: number;
268
+ /** Section type slug — included in narration. */
269
+ sectionType: string;
270
+ /** Pointer-X at gesture-start — pointermove subtracts to get DX. */
271
+ startPointerX: number;
272
+ /** Row's inside-width at gesture-start. Stable for the duration. */
273
+ containerWidth: number;
274
+ /** Last constraint hit — drives the constraint-snap pill + label. */
275
+ constraintHit: ResizeConstraint;
276
+ constraintBound: number;
277
+ /** The captured pointer id — used to release setPointerCapture
278
+ * on end/cancel. */
279
+ pointerId: number;
280
+ };
281
+
282
+ /* ------------------------------------------------------------------ */
283
+ /* Singleton */
284
+ /* ------------------------------------------------------------------ */
285
+
286
+ const state = ref<ResizeState>({ kind: 'idle' });
287
+
288
+ /** Pending rAF id so we throttle pointermove to once per frame. */
289
+ let rafHandle: number | null = null;
290
+ /** The last pointerX seen during the throttled window — consumed by
291
+ * the next rAF tick. */
292
+ let pendingPointerX = 0;
293
+
294
+ /* ------------------------------------------------------------------ */
295
+ /* Document-handler lifecycle */
296
+ /* ------------------------------------------------------------------ */
297
+
298
+ let getDraftClosure: (() => LayoutRecord | null) | null = null;
299
+
300
+ function onDocPointerMove(e: PointerEvent): void {
301
+ if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
302
+ pendingPointerX = e.clientX;
303
+ if (rafHandle === null && typeof window !== 'undefined') {
304
+ rafHandle = window.requestAnimationFrame(() => {
305
+ rafHandle = null;
306
+ applyPointerX(pendingPointerX);
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ function onDocPointerUp(e: PointerEvent): void {
313
+ if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
314
+ endResize();
315
+ }
316
+ }
317
+
318
+ function onDocPointerCancel(e: PointerEvent): void {
319
+ if (state.value.kind !== 'idle' && e.pointerId === state.value.pointerId) {
320
+ // Pointer was lost (window blur, touch interrupted). Same path as
321
+ // endResize — commit the partial result. Cancelling silently could
322
+ // strand the user with a half-applied resize they can't undo because
323
+ // no command was recorded.
324
+ endResize();
325
+ }
326
+ }
327
+
328
+ function onDocKeyDown(e: KeyboardEvent): void {
329
+ if (state.value.kind !== 'resizing') return;
330
+ if (e.key === 'Escape') {
331
+ e.preventDefault();
332
+ cancelResize();
333
+ }
334
+ }
335
+
336
+ /**
337
+ * R2-4 audit fix — Alt+Tab / window blur safety. If the user switches
338
+ * tabs OR the document loses focus mid-drag, the document pointerup
339
+ * may never fire (the OS handed events to the new window). Without a
340
+ * watchdog, the resize state would stay `resizing` indefinitely — the
341
+ * next pointermove ANYWHERE on the document would keep mutating the
342
+ * draft until the user clicked again. Commit instead of cancel so the
343
+ * partial state isn't lost (mirrors pointercancel's choice).
344
+ *
345
+ * `visibilitychange` also covers iOS Safari background-app eviction,
346
+ * where the window doesn't lose focus traditionally but the document
347
+ * stops receiving pointer events. Both cover overlapping but slightly
348
+ * different states; cheap to register both.
349
+ */
350
+ function onWindowBlur(): void {
351
+ if (state.value.kind === 'resizing') endResize();
352
+ }
353
+ function onDocVisibilityChange(): void {
354
+ if (typeof document === 'undefined') return;
355
+ if (document.visibilityState === 'hidden' && state.value.kind === 'resizing') {
356
+ endResize();
357
+ }
358
+ }
359
+
360
+ function attachDocHandlers(): void {
361
+ if (typeof window === 'undefined') return;
362
+ document.addEventListener('pointermove', onDocPointerMove);
363
+ document.addEventListener('pointerup', onDocPointerUp);
364
+ document.addEventListener('pointercancel', onDocPointerCancel);
365
+ document.addEventListener('keydown', onDocKeyDown);
366
+ window.addEventListener('blur', onWindowBlur);
367
+ document.addEventListener('visibilitychange', onDocVisibilityChange);
368
+ }
369
+
370
+ function detachDocHandlers(): void {
371
+ if (typeof window === 'undefined') return;
372
+ document.removeEventListener('pointermove', onDocPointerMove);
373
+ document.removeEventListener('pointerup', onDocPointerUp);
374
+ document.removeEventListener('pointercancel', onDocPointerCancel);
375
+ document.removeEventListener('keydown', onDocKeyDown);
376
+ window.removeEventListener('blur', onWindowBlur);
377
+ document.removeEventListener('visibilitychange', onDocVisibilityChange);
378
+ if (rafHandle !== null) {
379
+ window.cancelAnimationFrame(rafHandle);
380
+ rafHandle = null;
381
+ }
382
+ }
383
+
384
+ /* ------------------------------------------------------------------ */
385
+ /* Live-preview tick */
386
+ /* ------------------------------------------------------------------ */
387
+
388
+ /** Read a pointerX, recompute the snapped delta, mutate the draft for
389
+ * live preview. Called from the rAF callback so reactivity batches. */
390
+ function applyPointerX(pointerX: number): void {
391
+ if (state.value.kind !== 'resizing') return;
392
+ if (!getDraftClosure) return;
393
+ const draft = getDraftClosure();
394
+ if (!draft) return;
395
+ const s = state.value;
396
+
397
+ const desiredDelta = computeSnappedColSpan(
398
+ pointerX - s.startPointerX,
399
+ s.containerWidth,
400
+ );
401
+
402
+ // No-op delta means no mutation needed — short-circuit so the watcher
403
+ // doesn't fire spuriously on every micro-pixel pointermove.
404
+ if (desiredDelta === s.currentColSpan - s.startColSpan
405
+ && (s.neighbourId === null
406
+ || s.neighbourCurrentColSpan === s.neighbourStartColSpan - (s.currentColSpan - s.startColSpan))) {
407
+ return;
408
+ }
409
+
410
+ const clamped = clampResize({
411
+ startColSpan: s.startColSpan,
412
+ desiredDelta,
413
+ sectionMin: s.sectionMin,
414
+ sectionMax: s.sectionMax,
415
+ neighbourStart: s.neighbourId === null ? null : s.neighbourStartColSpan,
416
+ neighbourMin: s.neighbourMin,
417
+ neighbourMax: s.neighbourMax,
418
+ });
419
+
420
+ // Apply to the live draft so the existing CSS render path picks up.
421
+ const loc = findSectionLocation(draft, s.sectionId);
422
+ if (loc) loc.section.colSpan = clamped.newColSpan;
423
+ if (s.neighbourId !== null) {
424
+ const nLoc = findSectionLocation(draft, s.neighbourId);
425
+ if (nLoc) nLoc.section.colSpan = clamped.newNeighbourColSpan;
426
+ }
427
+
428
+ // Audit-of-audit: narrate the constraint hit on STATE TRANSITION
429
+ // (null → bound). Without this, SR users get NO audio cue when a
430
+ // pointer-drag pushes past a bound — `endResize`'s narrateResize fires
431
+ // only at pointer-release. Narrating every frame would flood SR users
432
+ // (60×/sec while held at the bound); narrating once-per-transition
433
+ // matches Linear / Figma's resize behaviour. The visual constraint
434
+ // label (aria-hidden) is the sighted-user signal.
435
+ if (s.constraintHit === null && clamped.constraintHit !== null) {
436
+ const ann = useLayoutAnnouncer();
437
+ ann.announce(narrateResizeBlocked(
438
+ s.sectionType,
439
+ clamped.constraintHit,
440
+ clamped.constraintBound,
441
+ ));
442
+ }
443
+
444
+ // Update state machine — current values + constraint signal.
445
+ s.currentColSpan = clamped.newColSpan;
446
+ s.neighbourCurrentColSpan = s.neighbourId === null
447
+ ? 0 : clamped.newNeighbourColSpan;
448
+ s.constraintHit = clamped.constraintHit;
449
+ s.constraintBound = clamped.constraintBound;
450
+ }
451
+
452
+ /* ------------------------------------------------------------------ */
453
+ /* Public API — start / end / cancel */
454
+ /* ------------------------------------------------------------------ */
455
+
456
+ export interface StartResizeOpts {
457
+ rowId: string;
458
+ sectionId: string;
459
+ sectionType: string;
460
+ startColSpan: number;
461
+ sectionMin: number;
462
+ sectionMax: number;
463
+ /** Null when the resized section is LAST in its row. */
464
+ neighbourId: string | null;
465
+ neighbourStartColSpan: number;
466
+ neighbourMin: number;
467
+ neighbourMax: number;
468
+ /** Pointer coords at gesture-start. */
469
+ startPointerX: number;
470
+ pointerId: number;
471
+ /** Row's inside-width at gesture-start. Stable for the duration. */
472
+ containerWidth: number;
473
+ /** Closure that returns the live draft. The composable holds it for
474
+ * the duration of the resize so pointermove can mutate. */
475
+ getDraft: () => LayoutRecord | null;
476
+ /** Optional handle element to setPointerCapture on. When provided,
477
+ * pointermove keeps firing on the handle even when the cursor leaves
478
+ * it (essential — without capture, dragging the handle off its 14px
479
+ * width loses the pointer). */
480
+ captureEl?: HTMLElement | null;
481
+ }
482
+
483
+ function startResize(opts: StartResizeOpts): void {
484
+ // If a previous resize is somehow still active (defensive), commit
485
+ // it before starting a new one. Prevents two resizes mutating draft
486
+ // simultaneously.
487
+ if (state.value.kind !== 'idle') endResize();
488
+
489
+ getDraftClosure = opts.getDraft;
490
+ state.value = {
491
+ kind: 'resizing',
492
+ rowId: opts.rowId,
493
+ sectionId: opts.sectionId,
494
+ sectionType: opts.sectionType,
495
+ startColSpan: opts.startColSpan,
496
+ currentColSpan: opts.startColSpan,
497
+ neighbourId: opts.neighbourId,
498
+ neighbourStartColSpan: opts.neighbourStartColSpan,
499
+ neighbourCurrentColSpan: opts.neighbourStartColSpan,
500
+ sectionMin: opts.sectionMin,
501
+ sectionMax: opts.sectionMax,
502
+ neighbourMin: opts.neighbourMin,
503
+ neighbourMax: opts.neighbourMax,
504
+ startPointerX: opts.startPointerX,
505
+ containerWidth: opts.containerWidth,
506
+ constraintHit: null,
507
+ constraintBound: 0,
508
+ pointerId: opts.pointerId,
509
+ };
510
+
511
+ // setPointerCapture: keeps pointermove firing on the handle element
512
+ // even when the cursor leaves it. Without capture, a fast drag past
513
+ // the handle's 14px width loses the pointer + the resize freezes.
514
+ // (Browsers without setPointerCapture support — none modern — fall
515
+ // back to the document-level handlers which still fire on the body.)
516
+ if (opts.captureEl && typeof opts.captureEl.setPointerCapture === 'function') {
517
+ try {
518
+ opts.captureEl.setPointerCapture(opts.pointerId);
519
+ } catch {
520
+ // Pointer capture can throw if the pointer was already released
521
+ // (e.g. very fast click); not fatal — handlers still fire on doc.
522
+ }
523
+ }
524
+
525
+ attachDocHandlers();
526
+ }
527
+
528
+ /** Commit the resize if it changed anything, then reset state to idle. */
529
+ function endResize(): void {
530
+ if (state.value.kind !== 'resizing') return;
531
+ const s = state.value;
532
+
533
+ // No-op: pointer ended where it started (drag-back-to-original). Skip
534
+ // the history record so the stack doesn't fill with self-equal entries.
535
+ const sectionChanged = s.currentColSpan !== s.startColSpan;
536
+ const neighbourChanged = s.neighbourId !== null
537
+ && s.neighbourCurrentColSpan !== s.neighbourStartColSpan;
538
+
539
+ if (sectionChanged || neighbourChanged) {
540
+ const history = useLayoutHistory();
541
+ history.record(resizeSectionCommand({
542
+ rowId: s.rowId,
543
+ sectionId: s.sectionId,
544
+ fromColSpan: s.startColSpan,
545
+ toColSpan: s.currentColSpan,
546
+ neighbourId: s.neighbourId,
547
+ neighbourFromColSpan: s.neighbourId === null
548
+ ? undefined : s.neighbourStartColSpan,
549
+ neighbourToColSpan: s.neighbourId === null
550
+ ? undefined : s.neighbourCurrentColSpan,
551
+ label: `resize ${s.sectionType}`,
552
+ }));
553
+ // Narrate the final span (pointer-up = end of gesture). The mid-drag
554
+ // pointermove ticks DON'T narrate — that would flood SR users with
555
+ // 60 announcements/sec. Narrating once at end matches the
556
+ // drag-drop announcer's "announce on END not START" rule.
557
+ const ann = useLayoutAnnouncer();
558
+ ann.announce(narrateResize(s.sectionType, s.currentColSpan));
559
+ }
560
+
561
+ detachDocHandlers();
562
+ getDraftClosure = null;
563
+ state.value = { kind: 'idle' };
564
+ }
565
+
566
+ /** Revert the draft to start values + reset state. Esc keypress + (defensive)
567
+ * pointer-lost scenarios call this. NO history record — the draft never
568
+ * diverged from the user's perspective. */
569
+ function cancelResize(): void {
570
+ if (state.value.kind !== 'resizing') return;
571
+ const s = state.value;
572
+ if (getDraftClosure) {
573
+ const draft = getDraftClosure();
574
+ if (draft) {
575
+ const loc = findSectionLocation(draft, s.sectionId);
576
+ if (loc) loc.section.colSpan = s.startColSpan;
577
+ if (s.neighbourId !== null) {
578
+ const nLoc = findSectionLocation(draft, s.neighbourId);
579
+ if (nLoc) nLoc.section.colSpan = s.neighbourStartColSpan;
580
+ }
581
+ }
582
+ }
583
+ detachDocHandlers();
584
+ getDraftClosure = null;
585
+ state.value = { kind: 'idle' };
586
+ }
587
+
588
+ /* ------------------------------------------------------------------ */
589
+ /* Keyboard resize — Shift+Arrow */
590
+ /* ------------------------------------------------------------------ */
591
+
592
+ export interface KeyboardResizeOpts {
593
+ /** The row + section the keystroke applies to (looked up from the
594
+ * current selection by the caller). */
595
+ rowId: string;
596
+ sectionId: string;
597
+ /** Direction: 'shrink' = Shift+Left = -1; 'grow' = Shift+Right = +1.
598
+ * Composable doesn't care about the actual key; the caller maps it. */
599
+ direction: 'shrink' | 'grow';
600
+ /** Closure to read the live draft (consistent with pointer path). */
601
+ getDraft: () => LayoutRecord | null;
602
+ /** Per-section bounds — typically read by the caller from the
603
+ * section registry. */
604
+ sectionMin: number;
605
+ sectionMax: number;
606
+ /** Section type — used in narration. */
607
+ sectionType: string;
608
+ /** Right-neighbour bounds; pass null when the section is LAST in row. */
609
+ neighbour: {
610
+ sectionId: string;
611
+ min: number;
612
+ max: number;
613
+ } | null;
614
+ }
615
+
616
+ export interface KeyboardResizeResult {
617
+ /** The new span after the keystroke. Equal to the start when the
618
+ * keystroke hit a bound. */
619
+ newColSpan: number;
620
+ /** Which bound the keystroke hit, if any. */
621
+ constraintHit: ResizeConstraint;
622
+ /** The numeric bound (for narration). */
623
+ constraintBound: number;
624
+ /** Whether the keystroke produced a mutation worth recording. */
625
+ changed: boolean;
626
+ }
627
+
628
+ /**
629
+ * Apply a Shift+Arrow keyboard resize. ±1 column per press; same
630
+ * neighbour-absorption rule as the pointer path. Commits one command
631
+ * per keystroke (each press is a discrete user intent — unlike a
632
+ * pointer drag where 60 ticks coalesce to ONE command at release).
633
+ *
634
+ * Narration:
635
+ * - changed=true → "Hero now spans N of 12 columns."
636
+ * - constraintHit → narrateResizeBlocked surface — names the bound.
637
+ *
638
+ * Returns the result so the caller can do its own follow-up (e.g. the
639
+ * editor page might re-focus after the keystroke). The composable
640
+ * handles history + narration itself.
641
+ */
642
+ function applyKeyboardResize(opts: KeyboardResizeOpts): KeyboardResizeResult | null {
643
+ const draft = opts.getDraft();
644
+ if (!draft) return null;
645
+ const loc = findSectionLocation(draft, opts.sectionId);
646
+ if (!loc) return null;
647
+
648
+ const startColSpan = loc.section.colSpan;
649
+ const desiredDelta = opts.direction === 'grow' ? 1 : -1;
650
+
651
+ // Resolve neighbour live (vs at registration). Keyboard presses are
652
+ // discrete + spaced out; the neighbour may have changed since the
653
+ // section was selected.
654
+ const neighbourLoc = opts.neighbour
655
+ ? findSectionLocation(draft, opts.neighbour.sectionId)
656
+ : null;
657
+ const neighbourStart = neighbourLoc ? neighbourLoc.section.colSpan : null;
658
+ const neighbourMin = opts.neighbour?.min ?? 1;
659
+ const neighbourMax = opts.neighbour?.max ?? 12;
660
+
661
+ const clamped = clampResize({
662
+ startColSpan,
663
+ desiredDelta,
664
+ sectionMin: opts.sectionMin,
665
+ sectionMax: opts.sectionMax,
666
+ neighbourStart,
667
+ neighbourMin,
668
+ neighbourMax,
669
+ });
670
+
671
+ const sectionChanged = clamped.newColSpan !== startColSpan;
672
+ const neighbourChanged = neighbourLoc !== null
673
+ && clamped.newNeighbourColSpan !== neighbourLoc.section.colSpan;
674
+
675
+ const ann = useLayoutAnnouncer();
676
+
677
+ if (!sectionChanged && !neighbourChanged) {
678
+ // Pressed at the bound — narrate the block + return.
679
+ if (clamped.constraintHit !== null) {
680
+ ann.announce(narrateResizeBlocked(
681
+ opts.sectionType,
682
+ clamped.constraintHit,
683
+ clamped.constraintBound,
684
+ ));
685
+ }
686
+ return {
687
+ newColSpan: startColSpan,
688
+ constraintHit: clamped.constraintHit,
689
+ constraintBound: clamped.constraintBound,
690
+ changed: false,
691
+ };
692
+ }
693
+
694
+ // Capture BEFORE-snapshot for the command (read live values so
695
+ // intervening edits don't corrupt the invert).
696
+ const neighbourFromColSpan = neighbourLoc?.section.colSpan;
697
+
698
+ // Apply.
699
+ loc.section.colSpan = clamped.newColSpan;
700
+ if (neighbourLoc) {
701
+ neighbourLoc.section.colSpan = clamped.newNeighbourColSpan;
702
+ }
703
+
704
+ // Record.
705
+ const history = useLayoutHistory();
706
+ history.record(resizeSectionCommand({
707
+ rowId: opts.rowId,
708
+ sectionId: opts.sectionId,
709
+ fromColSpan: startColSpan,
710
+ toColSpan: clamped.newColSpan,
711
+ neighbourId: neighbourLoc ? opts.neighbour!.sectionId : null,
712
+ neighbourFromColSpan,
713
+ neighbourToColSpan: neighbourLoc ? clamped.newNeighbourColSpan : undefined,
714
+ label: `resize ${opts.sectionType} (keyboard)`,
715
+ }));
716
+
717
+ // Narrate. If the keystroke hit a bound (e.g. shrank to min in a
718
+ // direction that didn't have room for the full delta — rare edge),
719
+ // narrate the bound FIRST, then the result.
720
+ if (clamped.constraintHit !== null) {
721
+ ann.announce(narrateResizeBlocked(
722
+ opts.sectionType,
723
+ clamped.constraintHit,
724
+ clamped.constraintBound,
725
+ ));
726
+ } else {
727
+ ann.announce(narrateResize(opts.sectionType, clamped.newColSpan));
728
+ }
729
+
730
+ return {
731
+ newColSpan: clamped.newColSpan,
732
+ constraintHit: clamped.constraintHit,
733
+ constraintBound: clamped.constraintBound,
734
+ changed: true,
735
+ };
736
+ }
737
+
738
+ /* ------------------------------------------------------------------ */
739
+ /* Composable surface */
740
+ /* ------------------------------------------------------------------ */
741
+
742
+ export interface LayoutResize {
743
+ /** Current state — `idle` or `resizing` with full gesture context.
744
+ * Read by LayoutSection (handle visual state) + LayoutRow (guide
745
+ * overlay). */
746
+ state: Ref<ResizeState>;
747
+ /** Begin a resize gesture from a pointerdown on the right-edge handle. */
748
+ startResize: (opts: StartResizeOpts) => void;
749
+ /** Commit + reset. Normally called by the document pointerup handler;
750
+ * exported so tests can drive without dispatching events. */
751
+ endResize: () => void;
752
+ /** Revert + reset. Esc keypress on a resize-in-flight; defensive
753
+ * pointer-lost recovery. */
754
+ cancelResize: () => void;
755
+ /** Apply a Shift+Arrow keyboard resize. */
756
+ applyKeyboardResize: (opts: KeyboardResizeOpts) => KeyboardResizeResult | null;
757
+ /** Helper for test fixtures + the LayoutSection handle's rendering —
758
+ * selects the right neighbour from a row's sections array. Returns
759
+ * null when the given section is LAST in the row. */
760
+ findRightNeighbour: (sections: LayoutSection[], sectionId: string) => LayoutSection | null;
761
+ }
762
+
763
+ /** Right neighbour helper. Pure — exported for tests + the component's
764
+ * handle visibility logic. */
765
+ function findRightNeighbour(
766
+ sections: LayoutSection[],
767
+ sectionId: string,
768
+ ): LayoutSection | null {
769
+ const idx = sections.findIndex((s) => s.id === sectionId);
770
+ if (idx === -1 || idx === sections.length - 1) return null;
771
+ return sections[idx + 1] ?? null;
772
+ }
773
+
774
+ export function useLayoutResize(): LayoutResize {
775
+ return {
776
+ state,
777
+ startResize,
778
+ endResize,
779
+ cancelResize,
780
+ applyKeyboardResize,
781
+ findRightNeighbour,
782
+ };
783
+ }