@commonpub/layer 0.24.0 → 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 (82) 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/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -0,0 +1,583 @@
1
+ /**
2
+ * useLayoutHistory — undo / redo for the layout editor.
3
+ *
4
+ * Phase 3b/B. Plan §7.14: in-memory stack of LayoutOps, each operation
5
+ * captures its inverse, cap 50, cleared on Save (saved draft = new
6
+ * baseline; redo across save is hostile) AND on refresh (the local
7
+ * draft was replaced — old commands reference vanished section ids).
8
+ *
9
+ * Module-scoped singleton matching `useLayoutAnnouncer` +
10
+ * `useEditorChrome`. The kickoff prompt named "Pinia store" but the
11
+ * monorepo has zero Pinia surfaces; adding `pinia` + `@pinia/nuxt` +
12
+ * provider plumbing + new test patterns for ONE store creates the
13
+ * cruft the user has explicitly flagged. The semantic — "command
14
+ * pattern undo/redo with the documented contract" — is satisfied
15
+ * cleanly by a module-scoped reactive ref. Same lifetime (editor
16
+ * mounts → unmounts → next mount calls `clear()` to reset). See
17
+ * session 164 log for the deviation rationale.
18
+ *
19
+ * Command pattern:
20
+ * `apply(draft)` — performs the operation; called on the original
21
+ * mutation's redo (the dispatcher does it once at
22
+ * record time; the command stores a CLOSURE so the
23
+ * same op can replay on Cmd+Shift+Z).
24
+ * `invert(draft)` — performs the operation's reverse; called on undo.
25
+ * `label` — human copy for narration + tooltips ("move hero").
26
+ * `timestamp` — when recorded (for diagnostics / future "undo
27
+ * history" UI; not load-bearing today).
28
+ *
29
+ * Both apply + invert are idempotent in the sense that a re-find by
30
+ * `section.id` survives the previous command's mutations. The stack
31
+ * is strict LIFO — between record and the matching undo, no other
32
+ * command can have shifted the state in a way that breaks the find.
33
+ *
34
+ * Stack semantics (Notion / Linear / Figma convention):
35
+ * - `record(cmd)` pushes to `past` AND clears `future` (a new action
36
+ * after undo invalidates the prior redo branch — same rule mature
37
+ * editors converged on).
38
+ * - `undo(draft)` pops `past`, runs `cmd.invert(draft)`, pushes the
39
+ * same cmd to `future`. Returns the cmd so the caller can announce.
40
+ * - `redo(draft)` pops `future`, runs `cmd.apply(draft)`, pushes back
41
+ * to `past`. Symmetrical.
42
+ * - `clear()` empties both stacks; called on save success + refresh.
43
+ *
44
+ * Cap: 50 commands in `past`. When recording past[50], shift the oldest
45
+ * out. `future` is not capped — only built by undo, which is bounded by
46
+ * past.
47
+ */
48
+ import { computed, markRaw, ref, type ComputedRef, type Ref } from 'vue';
49
+ import type { LayoutRecord } from '@commonpub/server';
50
+ import type { LayoutSection, LayoutRow } from './useLayout';
51
+
52
+ /** One reversible operation. `apply` + `invert` are pure with respect
53
+ * to the rest of the system — they mutate ONLY the passed draft. */
54
+ export interface LayoutCommand {
55
+ apply: (draft: LayoutRecord) => void;
56
+ invert: (draft: LayoutRecord) => void;
57
+ /** Human-readable label for narration + toolbar tooltips. */
58
+ label: string;
59
+ /** Recorded at — Date.now() at record-time. */
60
+ timestamp: number;
61
+ }
62
+
63
+ /** Cap chosen to match plan §7.14. Mature editors converge on 50–100;
64
+ * 50 keeps memory bounded (~50 × small JSON ≈ tens of KB at most). */
65
+ const STACK_CAP = 50;
66
+
67
+ const past = ref<LayoutCommand[]>([]);
68
+ const future = ref<LayoutCommand[]>([]);
69
+
70
+ export interface LayoutHistory {
71
+ past: Ref<LayoutCommand[]>;
72
+ future: Ref<LayoutCommand[]>;
73
+ canUndo: ComputedRef<boolean>;
74
+ canRedo: ComputedRef<boolean>;
75
+ /** Top of `past` — the next thing `undo()` will revert. Drives the
76
+ * toolbar's undo button tooltip ("Undo: move hero"). */
77
+ lastLabel: ComputedRef<string | null>;
78
+ /** Top of `future` — the next thing `redo()` will replay. Drives the
79
+ * redo button tooltip. */
80
+ nextLabel: ComputedRef<string | null>;
81
+ /** Record a command AFTER mutating the draft. The mutation already
82
+ * happened (the dispatcher / Move Up handler did it); this just
83
+ * remembers how to invert + how to replay. Clears `future` (new
84
+ * branch invalidates redo). */
85
+ record: (cmd: LayoutCommand) => void;
86
+ /** Undo the most recent command. Returns it so the caller can
87
+ * narrate. Noop + returns null when `past` is empty. */
88
+ undo: (draft: LayoutRecord) => LayoutCommand | null;
89
+ /** Redo the most recently undone command. Returns it for narration.
90
+ * Noop + returns null when `future` is empty. */
91
+ redo: (draft: LayoutRecord) => LayoutCommand | null;
92
+ /** Empty both stacks. Called on save success + refresh. */
93
+ clear: () => void;
94
+ }
95
+
96
+ export function useLayoutHistory(): LayoutHistory {
97
+ const canUndo = computed<boolean>(() => past.value.length > 0);
98
+ const canRedo = computed<boolean>(() => future.value.length > 0);
99
+ const lastLabel = computed<string | null>(() => {
100
+ const last = past.value[past.value.length - 1];
101
+ return last ? last.label : null;
102
+ });
103
+ const nextLabel = computed<string | null>(() => {
104
+ const next = future.value[future.value.length - 1];
105
+ return next ? next.label : null;
106
+ });
107
+
108
+ function record(cmd: LayoutCommand): void {
109
+ // markRaw so Vue's reactive proxy doesn't wrap the command. The
110
+ // command contains closures (`apply` + `invert`); we don't want
111
+ // the array's proxy to re-wrap the cmd on every read, which would
112
+ // (a) break reference-identity assertions (`undo()` returning the
113
+ // exact command the caller recorded — useful for telemetry +
114
+ // tests), and (b) waste a Proxy per command. Only the array
115
+ // length needs to be reactive — that comes from the outer ref().
116
+ const rawCmd = markRaw(cmd);
117
+ // Cap enforcement: shift the oldest out so memory stays bounded.
118
+ // Done BEFORE push so the resulting length is exactly STACK_CAP.
119
+ // Mutate in place so reactive readers (toolbar buttons) re-evaluate.
120
+ if (past.value.length >= STACK_CAP) past.value.shift();
121
+ past.value.push(rawCmd);
122
+ // New action invalidates the redo branch — Notion/Linear/Figma
123
+ // convention. Without this, redo would replay stale commands
124
+ // against a state that no longer matches their expectations.
125
+ if (future.value.length > 0) future.value = [];
126
+ }
127
+
128
+ function undo(draft: LayoutRecord): LayoutCommand | null {
129
+ const cmd = past.value.pop();
130
+ if (!cmd) return null;
131
+ cmd.invert(draft);
132
+ future.value.push(cmd);
133
+ return cmd;
134
+ }
135
+
136
+ function redo(draft: LayoutRecord): LayoutCommand | null {
137
+ const cmd = future.value.pop();
138
+ if (!cmd) return null;
139
+ cmd.apply(draft);
140
+ past.value.push(cmd);
141
+ return cmd;
142
+ }
143
+
144
+ function clear(): void {
145
+ past.value = [];
146
+ future.value = [];
147
+ }
148
+
149
+ return { past, future, canUndo, canRedo, lastLabel, nextLabel, record, undo, redo, clear };
150
+ }
151
+
152
+ /* ------------------------------------------------------------------ */
153
+ /* Command factories — one per layout op. */
154
+ /* ------------------------------------------------------------------ */
155
+ /*
156
+ * Each factory closes over the parameters needed for both apply +
157
+ * invert. The factories live with the history (vs co-located with the
158
+ * drag dispatcher) so that:
159
+ * 1. The undo invariant is centralised. Wrong apply/invert is the
160
+ * easiest mistake; one file makes the symmetry visually obvious.
161
+ * 2. Tests for the factories exercise apply + invert as a pair
162
+ * against a fixture draft — independent of the dispatcher /
163
+ * drag-drop UI.
164
+ *
165
+ * The factories DO mutate the draft on apply/invert. They do NOT call
166
+ * history.record(); the caller decides whether to record (test code
167
+ * may want apply-without-record for fixture setup).
168
+ */
169
+
170
+ /** Deep-clone a section so the invert can re-insert the SAME-shape
171
+ * payload even if the live one is later edited. */
172
+ function cloneSection(s: LayoutSection): LayoutSection {
173
+ return JSON.parse(JSON.stringify(s)) as LayoutSection;
174
+ }
175
+
176
+ /** Walk zones → rows to find a row by id. Returns null if vanished. */
177
+ export function findRowInDraft(draft: LayoutRecord, rowId: string): LayoutRow | null {
178
+ for (const zone of draft.zones) {
179
+ for (const row of zone.rows) {
180
+ if (row.id === rowId) return row as LayoutRow;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /** Walk zones → rows to find the zone slug containing a given row. */
187
+ export function findZoneOfRow(draft: LayoutRecord, rowId: string): string | null {
188
+ for (const zone of draft.zones) {
189
+ for (const row of zone.rows) {
190
+ if (row.id === rowId) return zone.zone;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * Walk zones → rows → sections to locate a section by id. Phase 3d
198
+ * keystrokes (Backspace, Cmd+D) work from an EditorSelection that
199
+ * carries only the section id — finding the host row + zone + index
200
+ * needs a search. O(zones × rows × sections); fine at v1 N=10s.
201
+ *
202
+ * Returns null when the section is no longer in the draft (e.g. the
203
+ * selection went stale because a drag mid-keydown removed it).
204
+ */
205
+ export interface SectionLocation {
206
+ zoneSlug: string;
207
+ row: LayoutRow;
208
+ idx: number;
209
+ section: LayoutSection;
210
+ }
211
+ export function findSectionLocation(
212
+ draft: LayoutRecord,
213
+ sectionId: string,
214
+ ): SectionLocation | null {
215
+ for (const zone of draft.zones) {
216
+ for (const row of zone.rows) {
217
+ const idx = (row.sections as LayoutSection[]).findIndex((s) => s.id === sectionId);
218
+ if (idx !== -1) {
219
+ const section = (row.sections as LayoutSection[])[idx]!;
220
+ return { zoneSlug: zone.zone, row: row as LayoutRow, idx, section };
221
+ }
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Palette → row insert.
229
+ *
230
+ * apply: splice the section into row at `at`
231
+ * invert: remove the section from the row by id
232
+ */
233
+ export function insertSectionCommand(params: {
234
+ rowId: string;
235
+ at: number;
236
+ section: LayoutSection;
237
+ label?: string;
238
+ }): LayoutCommand {
239
+ const sectionClone = cloneSection(params.section);
240
+ const label = params.label ?? `insert ${params.section.type}`;
241
+ return {
242
+ label,
243
+ timestamp: Date.now(),
244
+ apply(draft) {
245
+ const row = findRowInDraft(draft, params.rowId);
246
+ if (!row) return;
247
+ // Use a fresh clone each apply so undo→redo cycles don't share a
248
+ // mutated object. (Vue's reactivity proxies the live tree; the
249
+ // clone stays independent.)
250
+ row.sections.splice(params.at, 0, cloneSection(sectionClone));
251
+ },
252
+ invert(draft) {
253
+ const row = findRowInDraft(draft, params.rowId);
254
+ if (!row) return;
255
+ const idx = row.sections.findIndex((s) => s.id === sectionClone.id);
256
+ if (idx === -1) return;
257
+ row.sections.splice(idx, 1);
258
+ },
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Within-row reorder.
264
+ *
265
+ * apply: remove from `from`, insert at `to`
266
+ * invert: reverse — remove from `to`, insert at `from`
267
+ */
268
+ export function reorderSectionCommand(params: {
269
+ rowId: string;
270
+ sectionId: string;
271
+ from: number;
272
+ to: number;
273
+ label?: string;
274
+ }): LayoutCommand {
275
+ const label = params.label ?? `reorder section`;
276
+ return {
277
+ label,
278
+ timestamp: Date.now(),
279
+ apply(draft) {
280
+ const row = findRowInDraft(draft, params.rowId);
281
+ if (!row) return;
282
+ const idx = row.sections.findIndex((s) => s.id === params.sectionId);
283
+ if (idx === -1) return;
284
+ const [moved] = row.sections.splice(idx, 1);
285
+ if (!moved) return;
286
+ row.sections.splice(params.to, 0, moved);
287
+ },
288
+ invert(draft) {
289
+ const row = findRowInDraft(draft, params.rowId);
290
+ if (!row) return;
291
+ const idx = row.sections.findIndex((s) => s.id === params.sectionId);
292
+ if (idx === -1) return;
293
+ const [moved] = row.sections.splice(idx, 1);
294
+ if (!moved) return;
295
+ row.sections.splice(params.from, 0, moved);
296
+ },
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Add a new row to a zone.
302
+ *
303
+ * apply: splice the row into zone.rows at `position`
304
+ * invert: remove the row by id
305
+ *
306
+ * Used by the "+ Add row" canvas button (plan §7.2). The row's sections
307
+ * are typically empty at add-time, but the command stores a deep clone
308
+ * so future "duplicate row" callers (which pre-populate sections) work
309
+ * with the same factory. Undo of an add-row removes the WHOLE row +
310
+ * any sections that landed in it after the add.
311
+ */
312
+ export function addRowCommand(params: {
313
+ zoneSlug: string;
314
+ position: number;
315
+ row: LayoutRow;
316
+ label?: string;
317
+ }): LayoutCommand {
318
+ const rowClone = JSON.parse(JSON.stringify(params.row)) as LayoutRow;
319
+ const label = params.label ?? `add row`;
320
+ return {
321
+ label,
322
+ timestamp: Date.now(),
323
+ apply(draft) {
324
+ const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
325
+ if (!zone) return;
326
+ // Fresh clone each apply so undo→redo doesn't share a mutated object.
327
+ zone.rows.splice(params.position, 0, JSON.parse(JSON.stringify(rowClone)));
328
+ },
329
+ invert(draft) {
330
+ const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
331
+ if (!zone) return;
332
+ const idx = zone.rows.findIndex((r) => r.id === rowClone.id);
333
+ if (idx === -1) return;
334
+ zone.rows.splice(idx, 1);
335
+ },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Remove a row from a zone.
341
+ *
342
+ * apply: remove the row by id from zone.rows
343
+ * invert: restore the row at `position` (clamped if the zone now has
344
+ * fewer rows)
345
+ *
346
+ * Symmetric pair with `addRowCommand`. The full row (including any
347
+ * sections it contained at remove-time) is deep-cloned + stored on the
348
+ * command so the invert restores the row's contents — Cmd+Z after a
349
+ * row removal brings back the sections too.
350
+ */
351
+ export function removeRowCommand(params: {
352
+ zoneSlug: string;
353
+ position: number;
354
+ row: LayoutRow;
355
+ label?: string;
356
+ }): LayoutCommand {
357
+ const rowClone = JSON.parse(JSON.stringify(params.row)) as LayoutRow;
358
+ const label = params.label ?? `remove row`;
359
+ return {
360
+ label,
361
+ timestamp: Date.now(),
362
+ apply(draft) {
363
+ const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
364
+ if (!zone) return;
365
+ const idx = zone.rows.findIndex((r) => r.id === rowClone.id);
366
+ if (idx === -1) return;
367
+ zone.rows.splice(idx, 1);
368
+ },
369
+ invert(draft) {
370
+ const zone = draft.zones.find((z) => z.zone === params.zoneSlug);
371
+ if (!zone) return;
372
+ // Clamp to the current length — intervening commands may have
373
+ // removed other rows, putting our captured position out of range.
374
+ const pos = Math.min(params.position, zone.rows.length);
375
+ zone.rows.splice(pos, 0, JSON.parse(JSON.stringify(rowClone)));
376
+ },
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Remove a section from a row.
382
+ *
383
+ * apply: remove the section by id from row.sections
384
+ * invert: restore the section at `position` (clamped if the row now has
385
+ * fewer sections)
386
+ *
387
+ * Symmetric pair with `insertSectionCommand`. Phase 3d.1 (Backspace /
388
+ * Delete) records this. The full section (config + responsive + every
389
+ * authored field) is deep-cloned + stored on the command so the invert
390
+ * restores complete content — Cmd+Z after a section removal brings the
391
+ * authored copy back, not just the type stub.
392
+ */
393
+ export function removeSectionCommand(params: {
394
+ rowId: string;
395
+ position: number;
396
+ section: LayoutSection;
397
+ label?: string;
398
+ }): LayoutCommand {
399
+ const sectionClone = cloneSection(params.section);
400
+ const label = params.label ?? `remove ${params.section.type}`;
401
+ return {
402
+ label,
403
+ timestamp: Date.now(),
404
+ apply(draft) {
405
+ const row = findRowInDraft(draft, params.rowId);
406
+ if (!row) return;
407
+ const idx = row.sections.findIndex((s) => s.id === sectionClone.id);
408
+ if (idx === -1) return;
409
+ row.sections.splice(idx, 1);
410
+ },
411
+ invert(draft) {
412
+ const row = findRowInDraft(draft, params.rowId);
413
+ if (!row) return;
414
+ // Clamp to current length — intervening commands may have removed
415
+ // other sections, pushing our captured position out of range.
416
+ const pos = Math.min(params.position, row.sections.length);
417
+ row.sections.splice(pos, 0, cloneSection(sectionClone));
418
+ },
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Duplicate a section in place — clones the source, inserts directly
424
+ * after it. Phase 3d.2 (Cmd/Ctrl+D).
425
+ *
426
+ * apply: splice `clone` into row at `at`
427
+ * invert: remove `clone` by id
428
+ *
429
+ * The clone's id MUST be unique within the layout — the caller mints
430
+ * it via `crypto.randomUUID()` BEFORE building the command, so apply +
431
+ * invert can both find the same instance across undo/redo cycles.
432
+ *
433
+ * Practically equivalent to `insertSectionCommand` for purposes of
434
+ * apply/invert, but kept as a separate factory because:
435
+ * 1. The label defaults differ (`duplicate hero` vs `insert hero`),
436
+ * which surfaces in the toolbar tooltip + screen-reader narration.
437
+ * Telling the user "you can undo the insert" when the action was a
438
+ * duplicate is jarring.
439
+ * 2. Tests + audit lenses can target duplicate semantics specifically
440
+ * (e.g. "does the new id collide with the source?"). One factory
441
+ * per intent matches the convention the row commands already set.
442
+ */
443
+ export function duplicateSectionCommand(params: {
444
+ rowId: string;
445
+ at: number;
446
+ clone: LayoutSection;
447
+ label?: string;
448
+ }): LayoutCommand {
449
+ const clone = cloneSection(params.clone);
450
+ const label = params.label ?? `duplicate ${clone.type}`;
451
+ return {
452
+ label,
453
+ timestamp: Date.now(),
454
+ apply(draft) {
455
+ const row = findRowInDraft(draft, params.rowId);
456
+ if (!row) return;
457
+ // Fresh clone on each apply so undo→redo cycles don't share a
458
+ // mutated object — same rule the other factories follow.
459
+ row.sections.splice(params.at, 0, cloneSection(clone));
460
+ },
461
+ invert(draft) {
462
+ const row = findRowInDraft(draft, params.rowId);
463
+ if (!row) return;
464
+ const idx = row.sections.findIndex((s) => s.id === clone.id);
465
+ if (idx === -1) return;
466
+ row.sections.splice(idx, 1);
467
+ },
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Resize a section + (optionally) its right neighbour. Phase 3c.
473
+ *
474
+ * apply: set section.colSpan to `toColSpan`; if neighbourId present,
475
+ * set neighbour.colSpan to `neighbourToColSpan`
476
+ * invert: restore both to their `*FromColSpan`
477
+ *
478
+ * The pointer-drag handler in `useLayoutResize` mutates the live draft
479
+ * during the gesture (for live preview); on pointer-release it commits
480
+ * ONE command capturing the start + end values. A drag-back-to-original
481
+ * (toColSpan === fromColSpan AND no neighbour change) is a no-op the
482
+ * caller filters before recording — keeps the stack from filling with
483
+ * self-equal entries (mirrors the reorder dispatcher's `from===to` skip).
484
+ *
485
+ * Neighbour semantics:
486
+ * - `neighbourId === null` → section is LAST in its row; neighbour
487
+ * fields are unused. Plain colSpan swap.
488
+ * - `neighbourId` set → both sections were in the same row at command-
489
+ * record time; the apply/invert restores both. The `findSectionLocation`
490
+ * walks zones → rows → sections by id, so neither cares about the
491
+ * specific row index — robust against intervening reorders.
492
+ *
493
+ * Idempotence + intervening commands: if either section was deleted
494
+ * between record + replay, the corresponding find returns null and
495
+ * that side is silently skipped — same defensive shape as the other
496
+ * factories. Cmd+Z on a deleted section's resize is a quiet noop.
497
+ *
498
+ * Symmetric pair design (no separate factory for keyboard vs pointer):
499
+ * Shift+Arrow keyboard resize records the same command shape; the only
500
+ * difference is `label` ("resize hero (keyboard)" vs default "resize
501
+ * section") so narration tells SR users which input drove it.
502
+ */
503
+ export function resizeSectionCommand(params: {
504
+ rowId: string;
505
+ sectionId: string;
506
+ fromColSpan: number;
507
+ toColSpan: number;
508
+ /** Right neighbour at resize-start. Null when the resized section
509
+ * was LAST in its row (no absorption — extends to row edge). */
510
+ neighbourId: string | null;
511
+ neighbourFromColSpan?: number;
512
+ neighbourToColSpan?: number;
513
+ label?: string;
514
+ }): LayoutCommand {
515
+ const label = params.label ?? `resize section`;
516
+ return {
517
+ label,
518
+ timestamp: Date.now(),
519
+ apply(draft) {
520
+ const loc = findSectionLocation(draft, params.sectionId);
521
+ if (loc) loc.section.colSpan = params.toColSpan;
522
+ if (params.neighbourId && params.neighbourToColSpan !== undefined) {
523
+ const nLoc = findSectionLocation(draft, params.neighbourId);
524
+ if (nLoc) nLoc.section.colSpan = params.neighbourToColSpan;
525
+ }
526
+ },
527
+ invert(draft) {
528
+ const loc = findSectionLocation(draft, params.sectionId);
529
+ if (loc) loc.section.colSpan = params.fromColSpan;
530
+ if (params.neighbourId && params.neighbourFromColSpan !== undefined) {
531
+ const nLoc = findSectionLocation(draft, params.neighbourId);
532
+ if (nLoc) nLoc.section.colSpan = params.neighbourFromColSpan;
533
+ }
534
+ },
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Cross-row / cross-zone move.
540
+ *
541
+ * apply: remove from sourceRow at fromIdx, insert into destRow at toIdx
542
+ * invert: reverse — remove from destRow, insert back into sourceRow
543
+ *
544
+ * If either row vanishes between record + replay, the command short-
545
+ * circuits silently (the section vanished too, so there's nothing
546
+ * useful to do). The history's strict LIFO ordering makes this
547
+ * scenario rare: it only happens if a concurrent refresh replaced
548
+ * draft mid-stack, in which case `clear()` should have fired.
549
+ */
550
+ export function moveSectionCommand(params: {
551
+ fromRowId: string;
552
+ toRowId: string;
553
+ sectionId: string;
554
+ fromIdx: number;
555
+ toIdx: number;
556
+ label?: string;
557
+ }): LayoutCommand {
558
+ const label = params.label ?? `move section`;
559
+ return {
560
+ label,
561
+ timestamp: Date.now(),
562
+ apply(draft) {
563
+ const from = findRowInDraft(draft, params.fromRowId);
564
+ const to = findRowInDraft(draft, params.toRowId);
565
+ if (!from || !to) return;
566
+ const idx = from.sections.findIndex((s) => s.id === params.sectionId);
567
+ if (idx === -1) return;
568
+ const [moved] = from.sections.splice(idx, 1);
569
+ if (!moved) return;
570
+ to.sections.splice(params.toIdx, 0, moved);
571
+ },
572
+ invert(draft) {
573
+ const from = findRowInDraft(draft, params.fromRowId);
574
+ const to = findRowInDraft(draft, params.toRowId);
575
+ if (!from || !to) return;
576
+ const idx = to.sections.findIndex((s) => s.id === params.sectionId);
577
+ if (idx === -1) return;
578
+ const [moved] = to.sections.splice(idx, 1);
579
+ if (!moved) return;
580
+ from.sections.splice(params.fromIdx, 0, moved);
581
+ },
582
+ };
583
+ }