@commonpub/layer 0.24.0 → 0.25.1

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 +11 -8
  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,944 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * <LayoutRow> — renders one row of a zone (a horizontal strip in the
4
+ * 12-column grid) PLUS the sections inside it. Extracted from
5
+ * <LayoutSlot> in Phase 3b/A so each row can own its own
6
+ * `makeDroppable` template ref — dnd-kit composables can only be
7
+ * called once per component instance, so one row instance per
8
+ * component instance is the natural fit.
9
+ *
10
+ * Public render path: editable=false → makeDroppable is disabled +
11
+ * tabindex/role/aria are unset on sections + no click handlers wire.
12
+ * Byte-pattern identical to the inline render that used to live in
13
+ * <LayoutSlot> (the existing LayoutSlot tests query for the same
14
+ * .cpub-layout-row / .cpub-layout-section / data-* shape).
15
+ *
16
+ * Editable path: makeDroppable enabled; the row accepts palette tile
17
+ * drops + (commit F) section-instance drops. Drop handling is
18
+ * delegated to the pure `dispatchSectionDrop` function in
19
+ * useLayoutDrag.ts — this component is wiring only.
20
+ */
21
+ import { computed, ref } from 'vue';
22
+ import type { ComputedRef } from 'vue';
23
+ import { makeDroppable, type IDragEvent, type IPlacement } from '@vue-dnd-kit/core';
24
+ import type { LayoutSection, LayoutRow } from '../composables/useLayout';
25
+ import type { EditorSelection } from '../composables/useLayoutEditor';
26
+ import { dispatchSectionDrop } from '../composables/useLayoutDrag';
27
+ import {
28
+ useLayoutAnnouncer,
29
+ narrateInserted,
30
+ narrateReordered,
31
+ narrateMoveBlocked,
32
+ narrateMovedToZone,
33
+ } from '../composables/useLayoutAnnouncer';
34
+ import {
35
+ useLayoutHistory,
36
+ insertSectionCommand,
37
+ reorderSectionCommand,
38
+ moveSectionCommand,
39
+ } from '../composables/useLayoutHistory';
40
+ import { useLayoutResize } from '../composables/useLayoutResize';
41
+ import { useSectionRegistry } from '../sections/registry';
42
+ import LayoutSectionComponent from './LayoutSection.vue';
43
+
44
+ const props = withDefaults(defineProps<{
45
+ /** The row's reactive object — mutating row.sections here is picked
46
+ * up by the editor's deep watcher on draft (no callback needed). */
47
+ row: LayoutRow;
48
+ /** Forwarded from LayoutSlot — used by the section's render meta. */
49
+ route: string;
50
+ /** Zone slug — used by the section's render meta. */
51
+ zone: string;
52
+ /** When true, paint editor chrome + enable selection + makeDroppable. */
53
+ editable?: boolean;
54
+ /** True when the parent LayoutSlot has previewOverride — gates the
55
+ * section's meta.isPreview flag. */
56
+ isPreview?: boolean;
57
+ /** Selection callback — passed verbatim from LayoutSlot. */
58
+ onSelect?: (selection: EditorSelection) => void;
59
+ /** Currently-selected target. */
60
+ selectedId?: EditorSelection | null;
61
+ /**
62
+ * Phase 3b/B — cross-zone lookup. Synthesised by AdminLayoutsCanvas
63
+ * from the editor's draft + threaded through LayoutSlot. When present,
64
+ * the row's drop handler can route cross-row drops (section dragged
65
+ * from a different row in either the same OR a different zone).
66
+ * When absent (public render path / tests), cross-row drops noop.
67
+ */
68
+ findRow?: (rowId: string) => LayoutRow | null;
69
+ /**
70
+ * Phase 3b/B — zone-of-row lookup. Used to narrate cross-zone moves
71
+ * ("Hero moved from main, position 3 of 5, to sidebar, position 1
72
+ * of 2"). Optional — when absent, cross-row narration falls back to
73
+ * the position-only `narrateReordered` form.
74
+ */
75
+ findZone?: (rowId: string) => string | null;
76
+ /**
77
+ * Phase 3b/B — all zone slugs in the layout. Drives the "Move to
78
+ * zone…" popover's option list. The row filters out its OWN zone
79
+ * before passing the list to its sections.
80
+ */
81
+ zoneSlugs?: string[];
82
+ /** Phase 3b/B — first-row-in-zone lookup; landing target for the
83
+ * "Move to zone…" keyboard path. */
84
+ findFirstRowInZone?: (zoneSlug: string) => LayoutRow | null;
85
+ /**
86
+ * Session 164 polish — remove-row handler. Editor page implements;
87
+ * Canvas threads through. When absent, the × button is hidden.
88
+ * The handler receives (zoneSlug, rowId) so it can locate + splice
89
+ * + record + narrate.
90
+ */
91
+ onRemoveRow?: (zoneSlug: string, rowId: string) => void;
92
+ /**
93
+ * Phase 3c — closure threading the editor's live draft into the
94
+ * resize composable. The row's onResizeStart needs the draft (so the
95
+ * composable can mutate it for live preview); passing through props
96
+ * keeps the row decoupled from useLayoutEditor (which already only
97
+ * exists at the editor page level, NOT in LayoutSlot's public path).
98
+ *
99
+ * Absent on the public render path + on LayoutSlot's previewOverride
100
+ * path → resize handles aren't rendered (parent doesn't pass an
101
+ * onResizeStart to its sections). Plan §7.5: resize is editor-only.
102
+ */
103
+ getDraft?: () => import('@commonpub/server').LayoutRecord | null;
104
+ }>(), {
105
+ editable: false,
106
+ isPreview: false,
107
+ onSelect: undefined,
108
+ selectedId: null,
109
+ findRow: undefined,
110
+ findZone: undefined,
111
+ zoneSlugs: () => [],
112
+ findFirstRowInZone: undefined,
113
+ onRemoveRow: undefined,
114
+ getDraft: undefined,
115
+ });
116
+
117
+ /*
118
+ * Visibility filter — features + roles + enabled. hideAt is a CSS-side
119
+ * filter applied inside <LayoutSection> via data-* attrs. Selection +
120
+ * click handling + drag wiring + colSpan resolution all live in
121
+ * <LayoutSection> now.
122
+ */
123
+ const features = useFeatures();
124
+ const { isAuthenticated, user } = useAuth();
125
+
126
+ function isFeatureOn(featureGate: string | undefined): boolean {
127
+ if (!featureGate) return true;
128
+ return (features.features.value as unknown as Record<string, boolean>)?.[featureGate] ?? false;
129
+ }
130
+
131
+ function currentRole(): string {
132
+ if (!isAuthenticated.value) return 'anonymous';
133
+ return user.value?.role ?? 'member';
134
+ }
135
+
136
+ function sectionVisible(s: LayoutSection): boolean {
137
+ if (!s.enabled) return false;
138
+ const v = s.visibility;
139
+ if (!v) return true;
140
+ if (v.features && v.features.some((f: string) => !isFeatureOn(f))) return false;
141
+ if (v.roles && v.roles.length > 0 && !v.roles.includes(currentRole())) return false;
142
+ return true;
143
+ }
144
+
145
+ const rowIsSelected = computed<boolean>(() => {
146
+ const sel = props.selectedId;
147
+ return !!sel && sel.kind === 'row' && sel.id === props.row.id;
148
+ });
149
+
150
+ /* ----- makeDroppable wiring ---------------------------------------- */
151
+ /*
152
+ * `disabled` is a reactive ref so toggling editable on/off (e.g. via
153
+ * the toolbar) flips the row's droppability without remounting.
154
+ *
155
+ * `groups: ['section']` — palette tiles + section instances share
156
+ * this group; cross-feature drags (theme tokens etc) won't accidentally
157
+ * land here.
158
+ *
159
+ * `payload: () => props.row.sections` — dnd-kit's payload factory.
160
+ * Called per drag-tick so it always reads the LIVE sections array.
161
+ *
162
+ * `isDragOver: ComputedRef<IPlacement | undefined>` — bound to a
163
+ * --over modifier class in commit G (drop indicator visuals).
164
+ */
165
+ const rowRef = ref<HTMLElement | null>(null);
166
+ const dragDisabled = computed<boolean>(() => !props.editable);
167
+ const announcer = useLayoutAnnouncer();
168
+ const history = useLayoutHistory();
169
+
170
+ /**
171
+ * Phase 3b/B FLIP — `<TransitionGroup>` is rendered AS the row's outer
172
+ * `<div class="cpub-layout-row">`. That preserves the byte-pattern + the
173
+ * grid-container role + the makeDroppable ref binding. The ref on a
174
+ * component points at the component instance, not the DOM; this setter
175
+ * extracts `.$el` so makeDroppable's HTMLElement contract holds.
176
+ *
177
+ * On the public path (editable=false), the `name` prop is unset so no
178
+ * transition CSS classes are added during the (rare) section re-renders.
179
+ * The initial mount has no `appear` either, so the public byte-pattern
180
+ * is unaffected.
181
+ */
182
+ function setRowRef(el: unknown): void {
183
+ if (!el) { rowRef.value = null; return; }
184
+ if (typeof el === 'object' && '$el' in el) {
185
+ rowRef.value = ((el as { $el: HTMLElement | null }).$el) ?? null;
186
+ } else {
187
+ rowRef.value = el as HTMLElement;
188
+ }
189
+ }
190
+
191
+ /** Animation name — bound conditionally so the public path (editable=false)
192
+ * stays animation-free. */
193
+ const flipName = computed<string | undefined>(() =>
194
+ props.editable ? 'cpub-flip' : undefined,
195
+ );
196
+
197
+ function handleDrop(event: IDragEvent): void {
198
+ // Delegate to the pure dispatcher — same function used by tests, so
199
+ // the behavior matrix is exercised once + this component is wiring.
200
+ // Phase 3b/B: pass `findRow` so cross-row + cross-zone drops route
201
+ // through the dispatcher's `'moved'` branch instead of noop.
202
+ const outcome = dispatchSectionDrop(event, props.row, { findRow: props.findRow });
203
+
204
+ if (outcome.kind === 'inserted') {
205
+ announcer.announce(
206
+ narrateInserted(outcome.section.type, outcome.at, props.row.sections.length),
207
+ );
208
+ // Phase 3b/B: record for undo. The dispatcher already mutated the
209
+ // row; the command captures how to invert + how to replay.
210
+ history.record(insertSectionCommand({
211
+ rowId: props.row.id,
212
+ at: outcome.at,
213
+ section: outcome.section,
214
+ label: `insert ${outcome.section.type}`,
215
+ }));
216
+ return;
217
+ }
218
+ if (outcome.kind === 'reordered') {
219
+ // No-op reorder (audit R2-5): drag-onto-self produces from===to.
220
+ // Narrating "moved from position 3 to position 3" is confusing
221
+ // and worse-than-silent for SR users. The drag still consumed
222
+ // pointer focus, so the implicit feedback is "I held + released
223
+ // and nothing changed". Silent is correct. Also DON'T record a
224
+ // command — the stack would fill with self-equal entries.
225
+ if (outcome.from === outcome.to) return;
226
+ announcer.announce(
227
+ narrateReordered(
228
+ outcome.section.type,
229
+ outcome.from,
230
+ outcome.to,
231
+ props.row.sections.length,
232
+ ),
233
+ );
234
+ history.record(reorderSectionCommand({
235
+ rowId: props.row.id,
236
+ sectionId: outcome.section.id,
237
+ from: outcome.from,
238
+ to: outcome.to,
239
+ label: `reorder ${outcome.section.type}`,
240
+ }));
241
+ return;
242
+ }
243
+ if (outcome.kind === 'moved') {
244
+ // Cross-row / cross-zone — Phase 3b/B's headline feature. Narrate
245
+ // with zone slugs when findZone is available so SR users hear the
246
+ // destination zone explicitly ("moved from main … to sidebar …").
247
+ // The dispatcher's `fromTotal` is the source's BEFORE-splice length;
248
+ // `toTotal` is the destination's AFTER-insert length. Both are the
249
+ // right numbers for "position N of M" narration after the move.
250
+ const fromZone = props.findZone?.(outcome.fromRowId);
251
+ const toZone = props.findZone?.(outcome.toRowId);
252
+ if (fromZone && toZone && fromZone !== toZone) {
253
+ announcer.announce(narrateMovedToZone(
254
+ outcome.section.type,
255
+ fromZone, outcome.fromIdx, outcome.fromTotal,
256
+ toZone, outcome.toIdx, outcome.toTotal,
257
+ ));
258
+ } else {
259
+ // Cross-row within the same zone (or zones unknown). Fall back to
260
+ // the row-relative form — still position-based, just doesn't name
261
+ // a zone the user already implicitly knew about.
262
+ announcer.announce(narrateReordered(
263
+ outcome.section.type,
264
+ outcome.fromIdx,
265
+ outcome.toIdx,
266
+ outcome.toTotal,
267
+ ));
268
+ }
269
+ history.record(moveSectionCommand({
270
+ fromRowId: outcome.fromRowId,
271
+ toRowId: outcome.toRowId,
272
+ sectionId: outcome.section.id,
273
+ fromIdx: outcome.fromIdx,
274
+ toIdx: outcome.toIdx,
275
+ label: `move ${outcome.section.type}`,
276
+ }));
277
+ return;
278
+ }
279
+ // noop outcomes (ghost id, empty payload) — silent so we don't narrate
280
+ // "nothing happened" on every accidental drop. The user's pointer
281
+ // feedback is enough.
282
+ }
283
+
284
+ /* ----- Move Up / Move Down — WCAG 2.1.1 non-drag a11y path -------- */
285
+ /*
286
+ * Drag-drop has a notoriously bad SR story; per the editor a11y memory
287
+ * + WCAG 2.1.1, every drag operation needs a non-drag keyboard path.
288
+ * Move Up / Move Down buttons on each section satisfy this — the user
289
+ * Tabs to a section, focuses one of the buttons, presses Enter.
290
+ *
291
+ * Bounds-check returns early with a narrateMoveBlocked announcement
292
+ * so SR users hear WHY the press did nothing (otherwise it reads as a
293
+ * broken control). Mutation flows through row.sections.splice → Vue
294
+ * watcher → auto-save (same path as drag drops).
295
+ */
296
+ function moveSection(section: LayoutSection, direction: 'up' | 'down'): void {
297
+ const idx = props.row.sections.findIndex((s) => s.id === section.id);
298
+ if (idx === -1) return; // section vanished mid-keypress
299
+ const total = props.row.sections.length;
300
+ if (direction === 'up' && idx === 0) {
301
+ announcer.announce(narrateMoveBlocked(section.type, 'up'));
302
+ return;
303
+ }
304
+ if (direction === 'down' && idx === total - 1) {
305
+ announcer.announce(narrateMoveBlocked(section.type, 'down'));
306
+ return;
307
+ }
308
+ const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
309
+ const [moved] = props.row.sections.splice(idx, 1);
310
+ if (!moved) return;
311
+ props.row.sections.splice(targetIdx, 0, moved);
312
+ announcer.announce(narrateReordered(moved.type, idx, targetIdx, total));
313
+ // Phase 3b/B: keyboard reorder records to undo too, so Cmd+Z works
314
+ // identically for drag-driven + keyboard-driven moves. The command
315
+ // captures the absolute positions (not just direction) so a redo
316
+ // after intervening commands still lands correctly.
317
+ history.record(reorderSectionCommand({
318
+ rowId: props.row.id,
319
+ sectionId: moved.id,
320
+ from: idx,
321
+ to: targetIdx,
322
+ label: `move ${moved.type} ${direction}`,
323
+ }));
324
+ }
325
+
326
+ /* ----- Move to zone … — WCAG cross-zone keyboard path -------------- */
327
+ /*
328
+ * Per the keyboard model decision in 163-kickoff-3b-B (user-selected
329
+ * "Move to zone..." button on each section): the popover on each
330
+ * section lists every zone EXCEPT the current one + non-empty zones
331
+ * only (a target zone needs a row to land on; "Add row" is a separate
332
+ * arc).
333
+ *
334
+ * Landing rule: section appends to the END of the FIRST row in the
335
+ * target zone — predictable + Notion-like "go to top of section X".
336
+ * User can refine with Move Up/Down + drag after the move.
337
+ *
338
+ * Mutation matches drag's cross-row path: splice from source row,
339
+ * push to destination, narrate via narrateMovedToZone, record a
340
+ * moveSectionCommand for undo. One round-trip, full a11y parity.
341
+ */
342
+ const availableZones = computed<string[]>(() => {
343
+ if (!props.editable) return [];
344
+ if (!props.findFirstRowInZone) return [];
345
+ return props.zoneSlugs.filter((slug) => {
346
+ if (slug === props.zone) return false;
347
+ // A zone with no rows can't accept a landing target — disable.
348
+ // (Could be enabled when "Add row" lands; defer for now.)
349
+ return props.findFirstRowInZone!(slug) !== null;
350
+ });
351
+ });
352
+
353
+ function moveSectionToZone(section: LayoutSection, targetZone: string): void {
354
+ if (!props.findFirstRowInZone) return;
355
+ const targetRow = props.findFirstRowInZone(targetZone);
356
+ if (!targetRow) return;
357
+ const idx = props.row.sections.findIndex((s) => s.id === section.id);
358
+ if (idx === -1) return;
359
+ const fromTotal = props.row.sections.length;
360
+ const [moved] = props.row.sections.splice(idx, 1);
361
+ if (!moved) return;
362
+ const toIdx = targetRow.sections.length; // append
363
+ targetRow.sections.push(moved);
364
+ // Narrate cross-zone. `fromTotal` is the pre-splice source length;
365
+ // `toTotal` is the post-push destination length — both 1-indexed
366
+ // in the narration template.
367
+ announcer.announce(narrateMovedToZone(
368
+ moved.type,
369
+ props.zone, idx, fromTotal,
370
+ targetZone, toIdx, targetRow.sections.length,
371
+ ));
372
+ history.record(moveSectionCommand({
373
+ fromRowId: props.row.id,
374
+ toRowId: targetRow.id,
375
+ sectionId: moved.id,
376
+ fromIdx: idx,
377
+ toIdx,
378
+ label: `move ${moved.type} to ${targetZone}`,
379
+ }));
380
+ }
381
+
382
+ /* ----- Resize handle wiring (Phase 3c) -------------------------------- */
383
+ /*
384
+ * The row owns the resize orchestration because:
385
+ * - rowRef is the row's DOM element → containerWidth via getBoundingClientRect
386
+ * - row.sections is the array we look up the right neighbour from
387
+ * - the registry lives at the layer level; the row already imports it
388
+ * for the section renderer + can use it for bounds
389
+ *
390
+ * The actual pointer-event listener lives on the section's right-edge
391
+ * handle button (LayoutSection.vue). When that fires, it calls the
392
+ * closure we pass via `onResizeStart` — which here resolves the row's
393
+ * width, finds the neighbour, looks up bounds, then asks
394
+ * useLayoutResize.startResize to take over with full document-level
395
+ * pointer-event handling.
396
+ */
397
+ const resize = useLayoutResize();
398
+ const sectionRegistry = useSectionRegistry();
399
+
400
+ function resizeHandlerForSection(section: LayoutSection): ((e: PointerEvent) => void) | undefined {
401
+ // Public path: nothing. Same gate as the move handlers.
402
+ if (!props.editable) return undefined;
403
+ // Without a draft getter, the composable can't mutate live state for
404
+ // preview — the resize would be visually inert. Skip.
405
+ if (!props.getDraft) return undefined;
406
+ const def = sectionRegistry.get(section.type);
407
+ if (!def) return undefined;
408
+ if (!def.resizable) return undefined;
409
+ // Bind a closure capturing this section's identity + its def's bounds.
410
+ return (e: PointerEvent) => {
411
+ const containerEl = rowRef.value;
412
+ if (!containerEl) return;
413
+ const containerWidth = containerEl.getBoundingClientRect().width;
414
+ // Find the right neighbour from the LIVE sections array — handles
415
+ // any intervening reorders since the closure was built.
416
+ const idx = props.row.sections.findIndex((s) => s.id === section.id);
417
+ if (idx === -1) return;
418
+ const neighbour = idx < props.row.sections.length - 1
419
+ ? props.row.sections[idx + 1]
420
+ : null;
421
+ const neighbourDef = neighbour ? sectionRegistry.get(neighbour.type) : null;
422
+ // Session 166 round-2 audit P1: if the neighbour is `resizable: false`,
423
+ // the operator opted that section out of resize. Absorbing the source
424
+ // section's delta into it would silently resize a fixed-width section
425
+ // — contradicting the intent. Treat it as fixed: set neighbourMin to
426
+ // its CURRENT colSpan so clampResize stops the source at the boundary.
427
+ // (Same trick used by single-value-range sliders that want to model a
428
+ // hard wall on one side.)
429
+ const neighbourFixed = !!neighbour && neighbourDef?.resizable === false;
430
+ const effectiveNeighbourMin = neighbourFixed
431
+ ? (neighbour?.colSpan ?? 1)
432
+ : (neighbourDef?.minColSpan ?? 1);
433
+ resize.startResize({
434
+ rowId: props.row.id,
435
+ sectionId: section.id,
436
+ sectionType: section.type,
437
+ startColSpan: section.colSpan,
438
+ sectionMin: def.minColSpan,
439
+ sectionMax: def.maxColSpan,
440
+ neighbourId: neighbour?.id ?? null,
441
+ neighbourStartColSpan: neighbour?.colSpan ?? 0,
442
+ neighbourMin: effectiveNeighbourMin,
443
+ neighbourMax: neighbourDef?.maxColSpan ?? 12,
444
+ startPointerX: e.clientX,
445
+ pointerId: e.pointerId,
446
+ containerWidth,
447
+ getDraft: props.getDraft!,
448
+ captureEl: e.currentTarget as HTMLElement,
449
+ });
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Should the 12-col guide overlay render? True when a resize is in
455
+ * flight AND it targets THIS row. The overlay is a row-level affordance
456
+ * (12 vertical lines across the full row width) so it lives here,
457
+ * NOT in LayoutSection.
458
+ */
459
+ const showResizeOverlay = computed<boolean>(() => {
460
+ const s = resize.state.value;
461
+ return s.kind === 'resizing' && s.rowId === props.row.id;
462
+ });
463
+
464
+ /** The current snap-line column to bold (1..12). Computed from the
465
+ * resizing section's currentColSpan + its position in the row, so the
466
+ * bolded line lands at the resized section's right edge. */
467
+ const snapLineCol = computed<number | null>(() => {
468
+ const s = resize.state.value;
469
+ if (s.kind !== 'resizing' || s.rowId !== props.row.id) return null;
470
+ // Walk row.sections summing colSpans up to and INCLUDING the resized
471
+ // section. That column index (1..12) is where the section's right
472
+ // edge currently lies — which is what the handle is dragging.
473
+ let cumulative = 0;
474
+ for (const sec of props.row.sections) {
475
+ cumulative += sec.colSpan;
476
+ if (sec.id === s.sectionId) {
477
+ // Cap at 12 — defensive against draft-corruption (sum > 12).
478
+ return Math.min(12, cumulative);
479
+ }
480
+ }
481
+ return null;
482
+ });
483
+
484
+ /* ----- Remove row — Session 164 polish -------------------------------- */
485
+ /*
486
+ * Delegates to the editor page (which has full draft access + handles
487
+ * the confirm dialog for non-empty rows). The row passes its own zone
488
+ * slug + id; the editor mutates + records `removeRowCommand` + narrates.
489
+ *
490
+ * `hasRemoveHandler` is computed instead of inlined `editable && onRemoveRow`
491
+ * in the template because vue-tsc strict narrows withDefaults'd optional
492
+ * function props to "always defined" — TS2774. The computed sidesteps
493
+ * the narrowing (matches the existing pattern for hasMoveTargets).
494
+ * Per feedback-vue-tsc-strict-vs-vitest.
495
+ */
496
+ const hasRemoveHandler = computed<boolean>(() => !!(props.editable && props.onRemoveRow));
497
+ function handleRemoveClick(): void {
498
+ props.onRemoveRow?.(props.zone, props.row.id);
499
+ }
500
+
501
+ /**
502
+ * Phase 3e — row selection. Sections had a selection trigger since 3b
503
+ * (LayoutSection click → onSelect({kind:'section'})); rows had the
504
+ * `--selected` class + `rowIsSelected` computed wired but NO trigger, so
505
+ * the inspector's row branch was unreachable. A dedicated handle button
506
+ * (mirrors the remove button, top-LEFT corner) toggles row selection —
507
+ * a separate button rather than making the whole row clickable avoids the
508
+ * nested-interactive ARIA violation (feedback-nested-aria-button-violation).
509
+ * `@click.stop` keeps the click off the canvas click-outside-to-deselect.
510
+ */
511
+ const hasSelectHandler = computed<boolean>(() => !!(props.editable && props.onSelect));
512
+ function handleRowSelectClick(): void {
513
+ props.onSelect?.(rowIsSelected.value ? null : { kind: 'row', id: props.row.id });
514
+ }
515
+
516
+ /*
517
+ * CRITICAL — public-path provider guard (session 169 P0 hotfix). Mirror of
518
+ * the guard in LayoutSection.vue: `makeDroppable` injects 'VueDnDKitProvider'
519
+ * at setup and throws "DnD provider not found" with no <DnDProvider>
520
+ * ancestor (disabled:true does NOT suppress the inject). The public render
521
+ * path (homepage layout canary + custom pages: <LayoutSlot editable=false>)
522
+ * has no provider, so this crashed the page with a 500. Instantiate the
523
+ * droppable ONLY in editable mode (always inside the editor's
524
+ * <DnDProvider>); editable is static per instance so the conditional call
525
+ * is safe. Public rows use an inert ComputedRef fallback (never a drop
526
+ * target). See feedback-integration-test-full-output-path.
527
+ */
528
+ let isDragOver: ComputedRef<IPlacement | undefined>;
529
+ if (props.editable) {
530
+ const droppable = makeDroppable(
531
+ rowRef,
532
+ {
533
+ disabled: dragDisabled,
534
+ groups: ['section'],
535
+ events: {
536
+ onDrop: handleDrop,
537
+ },
538
+ },
539
+ () => props.row.sections,
540
+ );
541
+ isDragOver = droppable.isDragOver;
542
+ } else {
543
+ isDragOver = computed(() => undefined);
544
+ }
545
+
546
+ /** Exposed for the drop-indicator class binding in the template. */
547
+ const isOver = computed<boolean>(() => isDragOver.value !== undefined);
548
+ </script>
549
+
550
+ <template>
551
+ <!--
552
+ Phase 3b/B: <TransitionGroup> IS the row's outer element. The grid
553
+ container role + makeDroppable ref + selection/drop-over class set
554
+ transfers verbatim. `tag="div"` keeps the rendered HTML identical
555
+ to the pre-FLIP shape. `name` is unset on the public path so no
556
+ transition CSS attaches there (initial mount has no `appear`, so
557
+ public byte-pattern is unchanged).
558
+ -->
559
+ <TransitionGroup
560
+ :ref="setRowRef"
561
+ tag="div"
562
+ :name="flipName"
563
+ class="cpub-layout-row"
564
+ :class="{
565
+ 'cpub-layout-row--editable': editable,
566
+ 'cpub-layout-row--selected': editable && rowIsSelected,
567
+ 'cpub-layout-row--drop-over': editable && isOver,
568
+ }"
569
+ :data-row-id="row.id"
570
+ :data-gap="row.config?.gap ?? 'md'"
571
+ :data-align="row.config?.align ?? 'stretch'"
572
+ :data-padding-y="row.config?.paddingY ?? 'none'"
573
+ :style="row.config?.background ? { background: row.config.background } : {}"
574
+ >
575
+ <!--
576
+ Section rendering delegated to <LayoutSection> so each section
577
+ owns its own makeDraggable template ref. Same per-iteration
578
+ reasoning as the row extraction.
579
+ -->
580
+ <LayoutSectionComponent
581
+ v-for="section in row.sections.filter(sectionVisible)"
582
+ :key="section.id"
583
+ :section="section"
584
+ :row-id="row.id"
585
+ :route="route"
586
+ :zone="zone"
587
+ :editable="editable"
588
+ :is-preview="isPreview"
589
+ :on-select="onSelect"
590
+ :selected-id="selectedId"
591
+ :on-move-up="() => moveSection(section, 'up')"
592
+ :on-move-down="() => moveSection(section, 'down')"
593
+ :available-zones="availableZones"
594
+ :on-move-to-zone="(targetZone: string) => moveSectionToZone(section, targetZone)"
595
+ :on-resize-start="resizeHandlerForSection(section)"
596
+ />
597
+ <!--
598
+ Session 164 polish — remove row × button.
599
+ Keyed child so <TransitionGroup> tracks it (TG requires keyed
600
+ children). The button is position:absolute on the row corner
601
+ so it doesn't take a grid column; FLIP doesn't move it because
602
+ its key is constant. Visible only on row hover or focus + only
603
+ when an onRemoveRow handler is wired (public path stays clean).
604
+ -->
605
+ <!--
606
+ Phase 3e — row select handle. Top-left corner so it doesn't collide
607
+ with the top-right remove button. Toggles row selection → the
608
+ inspector swaps to the row-config form. Keyed for TransitionGroup;
609
+ hidden on the public path (no onSelect handler).
610
+ -->
611
+ <button
612
+ v-if="hasSelectHandler"
613
+ key="cpub-row-select"
614
+ type="button"
615
+ class="cpub-layout-row-select"
616
+ :class="{ 'cpub-layout-row-select--active': rowIsSelected }"
617
+ :aria-label="rowIsSelected ? `Row in ${zone} selected — activate to deselect` : `Select this row in ${zone}`"
618
+ :aria-pressed="rowIsSelected"
619
+ :title="rowIsSelected ? 'Deselect row' : 'Select row'"
620
+ @click.stop="handleRowSelectClick"
621
+ @keydown.space.stop.prevent="handleRowSelectClick"
622
+ @keydown.enter.stop.prevent="handleRowSelectClick"
623
+ >
624
+ <i class="fa-solid fa-grip-lines" aria-hidden="true"></i>
625
+ </button>
626
+ <button
627
+ v-if="hasRemoveHandler"
628
+ key="cpub-row-remove"
629
+ type="button"
630
+ class="cpub-layout-row-remove"
631
+ :aria-label="`Remove this row from ${zone}`"
632
+ :title="`Remove this row from ${zone}`"
633
+ @click.stop="handleRemoveClick"
634
+ @keydown.space.stop.prevent="handleRemoveClick"
635
+ @keydown.enter.stop.prevent="handleRemoveClick"
636
+ >
637
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
638
+ </button>
639
+ <!--
640
+ Phase 3c — 12-col guideline overlay. Shown ONLY while a resize is
641
+ in flight AND it's resizing a section in THIS row. 12 vertical
642
+ lines absolutely positioned across the row's inside; the line at
643
+ `snapLineCol` (the resized section's right edge) bolds to opacity
644
+ 0.7 so the user sees their current snap target.
645
+ Keyed so <TransitionGroup> tracks it without animating in/out
646
+ (constant key; v-if drives mount/unmount). pointer-events:none on
647
+ the wrapper so the overlay can't intercept the resize gesture.
648
+ -->
649
+ <div
650
+ v-if="showResizeOverlay"
651
+ key="cpub-resize-overlay"
652
+ class="cpub-layout-row-resize-overlay"
653
+ aria-hidden="true"
654
+ >
655
+ <span
656
+ v-for="col in 12"
657
+ :key="col"
658
+ class="cpub-layout-row-resize-overlay-line"
659
+ :class="{ 'cpub-layout-row-resize-overlay-line--snap': snapLineCol === col }"
660
+ :style="{ left: `${(col / 12) * 100}%` }"
661
+ ></span>
662
+ </div>
663
+ </TransitionGroup>
664
+ </template>
665
+
666
+ <style scoped>
667
+ /*
668
+ * Phase 3b/A extraction: the .cpub-layout-row + .cpub-layout-section
669
+ * chrome (previously inlined in LayoutSlot.vue's <style scoped>) moves
670
+ * here because Vue scoped styles are component-instance-hashed — rules
671
+ * in LayoutSlot wouldn't reach the markup LayoutRow renders. Keeping
672
+ * them here scopes them correctly to the elements they target.
673
+ * LayoutSlot now only styles its skeleton loader + placeholder.
674
+ */
675
+ .cpub-layout-row {
676
+ display: grid;
677
+ grid-template-columns: repeat(12, 1fr);
678
+ gap: var(--space-4);
679
+ width: 100%;
680
+ }
681
+ .cpub-layout-row[data-gap='none'] { gap: 0; }
682
+ .cpub-layout-row[data-gap='sm'] { gap: var(--space-2); }
683
+ .cpub-layout-row[data-gap='md'] { gap: var(--space-4); }
684
+ .cpub-layout-row[data-gap='lg'] { gap: var(--space-6); }
685
+
686
+ .cpub-layout-row[data-align='center'] { align-items: center; }
687
+ .cpub-layout-row[data-align='start'] { align-items: start; }
688
+ .cpub-layout-row[data-align='stretch'] { align-items: stretch; }
689
+
690
+ .cpub-layout-row[data-padding-y='sm'] { padding-block: var(--space-2); }
691
+ .cpub-layout-row[data-padding-y='md'] { padding-block: var(--space-4); }
692
+ .cpub-layout-row[data-padding-y='lg'] { padding-block: var(--space-6); }
693
+ .cpub-layout-row[data-padding-y='xl'] { padding-block: var(--space-8); }
694
+
695
+ /* ------------------------------------------------------------------ */
696
+ /* Editable-mode chrome — row only. Section chrome moved to */
697
+ /* LayoutSection.vue with the section extraction. */
698
+ /* ------------------------------------------------------------------ */
699
+ .cpub-layout-row--editable {
700
+ position: relative;
701
+ }
702
+ /* Audit-of-audit A2-2: gate min-height to :empty so populated rows
703
+ size to their content (the original min-height was correct for
704
+ empty rows but over-padded compact rows). :empty matches when the
705
+ row has zero child elements, which happens when sections.length===0
706
+ OR all sections are filtered out by sectionVisible. Both cases mean
707
+ "no drop target without help" — exactly when we need to enlarge it. */
708
+ .cpub-layout-row--editable:empty {
709
+ min-height: 64px;
710
+ }
711
+ .cpub-layout-row--editable:hover {
712
+ outline: 1px dashed var(--border);
713
+ outline-offset: 2px;
714
+ }
715
+
716
+ .cpub-layout-row--selected {
717
+ outline: 2px solid var(--accent);
718
+ outline-offset: 2px;
719
+ }
720
+
721
+ /* ------------------------------------------------------------------ */
722
+ /* Phase 3b/A drop-target highlight. Light accent-bg flash when a */
723
+ /* drag is over this row — sharper visual lands in commit G via the */
724
+ /* placement-aware drop indicator. This is the baseline affordance. */
725
+ /* ------------------------------------------------------------------ */
726
+ .cpub-layout-row--drop-over {
727
+ background: color-mix(in srgb, var(--accent) 6%, transparent);
728
+ outline: 2px dashed var(--accent);
729
+ outline-offset: 4px;
730
+ }
731
+
732
+ /* ------------------------------------------------------------------ */
733
+ /* Session 164 — Remove row × button. */
734
+ /* Hover-reveal on the row's top-right corner. Stays at opacity 0 by */
735
+ /* default so it doesn't clutter the canvas; fades in on hover/focus. */
736
+ /* Red on hover (destructive intent) per the design system convention. */
737
+ /* prefers-reduced-motion: skip the fade. */
738
+ /* ------------------------------------------------------------------ */
739
+ .cpub-layout-row-remove {
740
+ position: absolute;
741
+ top: -14px;
742
+ right: -14px;
743
+ width: 28px;
744
+ height: 28px;
745
+ display: inline-flex;
746
+ align-items: center;
747
+ justify-content: center;
748
+ background: var(--surface);
749
+ border: 1px solid var(--border);
750
+ color: var(--text-dim);
751
+ cursor: pointer;
752
+ z-index: 3;
753
+ opacity: 0;
754
+ transition: opacity 100ms ease-out, color var(--transition-default), border-color var(--transition-default);
755
+ font-size: var(--text-sm);
756
+ }
757
+ .cpub-layout-row--editable:hover > .cpub-layout-row-remove,
758
+ .cpub-layout-row-remove:focus-visible,
759
+ .cpub-layout-row--selected > .cpub-layout-row-remove {
760
+ opacity: 1;
761
+ }
762
+ .cpub-layout-row-remove:hover {
763
+ color: var(--red);
764
+ border-color: var(--red);
765
+ background: var(--red-bg, var(--surface));
766
+ }
767
+ .cpub-layout-row-remove:focus-visible {
768
+ outline: 2px solid var(--accent);
769
+ outline-offset: 2px;
770
+ }
771
+ @media (prefers-reduced-motion: reduce) {
772
+ .cpub-layout-row-remove { transition: none; }
773
+ }
774
+
775
+ /* Phase 3e — row select handle. Mirrors the remove button's reveal-on-
776
+ hover/focus/selected behavior; positioned top-LEFT (remove is top-right)
777
+ so both fit on a row corner without overlap. Accent (not red) — it's a
778
+ selection affordance, not destructive. */
779
+ .cpub-layout-row-select {
780
+ position: absolute;
781
+ top: -14px;
782
+ left: -14px;
783
+ width: 28px;
784
+ height: 28px;
785
+ display: inline-flex;
786
+ align-items: center;
787
+ justify-content: center;
788
+ background: var(--surface);
789
+ border: 1px solid var(--border);
790
+ color: var(--text-dim);
791
+ cursor: pointer;
792
+ z-index: 3;
793
+ opacity: 0;
794
+ transition: opacity 100ms ease-out, color var(--transition-default), border-color var(--transition-default);
795
+ font-size: var(--text-xs);
796
+ }
797
+ .cpub-layout-row--editable:hover > .cpub-layout-row-select,
798
+ .cpub-layout-row-select:focus-visible,
799
+ .cpub-layout-row--selected > .cpub-layout-row-select {
800
+ opacity: 1;
801
+ }
802
+ .cpub-layout-row-select:hover,
803
+ .cpub-layout-row-select--active {
804
+ color: var(--accent);
805
+ border-color: var(--accent);
806
+ }
807
+ .cpub-layout-row-select:focus-visible {
808
+ outline: 2px solid var(--accent);
809
+ outline-offset: 2px;
810
+ }
811
+ @media (prefers-reduced-motion: reduce) {
812
+ .cpub-layout-row-select { transition: none; }
813
+ }
814
+
815
+ /* ------------------------------------------------------------------ */
816
+ /* Phase 3b/B FLIP animations — name="cpub-flip" attaches these classes */
817
+ /* during enter/leave/move. Only editable mode wires the name prop, so */
818
+ /* the public path stays animation-free. */
819
+ /* */
820
+ /* Plan §7.11 (visual design system): */
821
+ /* - insertion: transform scale(0.96)→1 + opacity 0→1 over 150ms */
822
+ /* cubic ease-out */
823
+ /* - removal: reverse */
824
+ /* - reorder: FLIP — sections slide via transform from delta */
825
+ /* */
826
+ /* The `*-move` class is what gives reorder its visual FLIP — Vue */
827
+ /* computes the new transform from the position delta + transitions */
828
+ /* via the `transition` property listed below. */
829
+ /* */
830
+ /* prefers-reduced-motion: `transition: none` on all three classes */
831
+ /* disables the animation while preserving the layout mutation. */
832
+ /* (Not display:none — we still want the section to appear in its */
833
+ /* new location, just without the animation.) */
834
+ /* ------------------------------------------------------------------ */
835
+ .cpub-flip-enter-active,
836
+ .cpub-flip-leave-active {
837
+ transition:
838
+ opacity 150ms cubic-bezier(0.2, 0.8, 0.4, 1),
839
+ transform 150ms cubic-bezier(0.2, 0.8, 0.4, 1);
840
+ }
841
+ .cpub-flip-enter-from,
842
+ .cpub-flip-leave-to {
843
+ opacity: 0;
844
+ transform: scale(0.96);
845
+ }
846
+ /* `*-move` covers FLIP-driven reorder (Vue calculates the from→to
847
+ transform; this transition smooths it). */
848
+ .cpub-flip-move {
849
+ transition: transform 150ms cubic-bezier(0.2, 0.8, 0.4, 1);
850
+ }
851
+ /* When an item is leaving, its DOM stays for the duration of the
852
+ leave transition. Take it out of the document flow so other items
853
+ can FLIP into its space WITHOUT waiting for the leave to finish —
854
+ gives a visually-coherent reorder when a section is also being
855
+ removed. */
856
+ .cpub-flip-leave-active {
857
+ position: absolute;
858
+ }
859
+ @media (prefers-reduced-motion: reduce) {
860
+ .cpub-flip-enter-active,
861
+ .cpub-flip-leave-active,
862
+ .cpub-flip-move {
863
+ transition: none;
864
+ }
865
+ .cpub-flip-enter-from,
866
+ .cpub-flip-leave-to {
867
+ /* Skip the scale/opacity prelude so reduced-motion users see the
868
+ section appear/disappear instantly in its final state. */
869
+ opacity: 1;
870
+ transform: none;
871
+ }
872
+ }
873
+
874
+ /* ------------------------------------------------------------------ */
875
+ /* Phase 3c — 12-column guideline overlay during a resize. */
876
+ /* */
877
+ /* Renders 12 faint vertical lines across the row's inside width; the */
878
+ /* line at the resized section's current snap target bolds. Gives the */
879
+ /* admin a visual frame of reference for "where will the snap land" and */
880
+ /* matches Webflow / Framer / Cursor's grid-edit affordance. */
881
+ /* */
882
+ /* pointer-events:none on the wrapper so the overlay can't intercept */
883
+ /* the resize gesture. position:absolute relative to the row (which is */
884
+ /* already position:relative in editable mode). */
885
+ /* */
886
+ /* prefers-reduced-motion: opacity-in transition skipped. */
887
+ /* ------------------------------------------------------------------ */
888
+ .cpub-layout-row-resize-overlay {
889
+ position: absolute;
890
+ inset: 0;
891
+ pointer-events: none;
892
+ /* Above the section content (which is pointer-events:none anyway in
893
+ editable mode) but below the move-buttons cluster (z=2) + the
894
+ resize handle (z=1). Set to 0 so it sits just over content but
895
+ under interactive chrome. The lines themselves are opaque enough
896
+ to read against any background. */
897
+ z-index: 0;
898
+ /* Fade in for sighted users; reduced-motion users see it instantly. */
899
+ animation: cpub-overlay-fade-in 100ms ease-out;
900
+ }
901
+ /* R1-7 audit fix: the overlay is a keyed child of the row's
902
+ <TransitionGroup>, so it INHERITS the cpub-flip-enter/leave classes
903
+ while mounting. Their opacity:0 + scale(0.96) prelude conflicts with
904
+ the overlay's own fade-in animation — for ~150ms the overlay would
905
+ pop to scale 0.96, then snap back. Override to neutralise the flip
906
+ prelude on the overlay specifically; sections + the remove button
907
+ keep their flip animations. */
908
+ .cpub-flip-enter-active.cpub-layout-row-resize-overlay,
909
+ .cpub-flip-leave-active.cpub-layout-row-resize-overlay,
910
+ .cpub-flip-move.cpub-layout-row-resize-overlay {
911
+ transition: none;
912
+ }
913
+ .cpub-flip-enter-from.cpub-layout-row-resize-overlay,
914
+ .cpub-flip-leave-to.cpub-layout-row-resize-overlay {
915
+ opacity: 1;
916
+ transform: none;
917
+ }
918
+ @keyframes cpub-overlay-fade-in {
919
+ from { opacity: 0; }
920
+ to { opacity: 1; }
921
+ }
922
+ .cpub-layout-row-resize-overlay-line {
923
+ position: absolute;
924
+ top: 0;
925
+ bottom: 0;
926
+ width: 1px;
927
+ background: var(--accent);
928
+ opacity: 0.25;
929
+ /* Center the line on its column boundary so the visual lands exactly
930
+ where the snap will. */
931
+ transform: translateX(-0.5px);
932
+ }
933
+ .cpub-layout-row-resize-overlay-line--snap {
934
+ /* The current snap target — bold AND wider so it pops against the
935
+ 12-line backdrop. Three independent signals (color + width +
936
+ opacity) per WCAG 1.4.1. */
937
+ opacity: 0.85;
938
+ width: 2px;
939
+ transform: translateX(-1px);
940
+ }
941
+ @media (prefers-reduced-motion: reduce) {
942
+ .cpub-layout-row-resize-overlay { animation: none; }
943
+ }
944
+ </style>