@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,1028 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * <LayoutSection> — renders ONE section inside a row. Extracted from
4
+ * <LayoutRow> in Phase 3b/A so each section can own its own
5
+ * `makeDraggable` template ref (same per-iteration reasoning as the
6
+ * <LayoutRow> + <AdminLayoutsPaletteTile> extractions before it).
7
+ *
8
+ * Public path (editable=false): renders the section as before — same
9
+ * .cpub-layout-section class, same data-* attrs, no drag, no tabindex.
10
+ * Editable path: tabindex='0', click→select, keyboard activate, the
11
+ * 'cursor: grab' contract, makeDraggable wired with a section-instance
12
+ * envelope that the row's dispatcher (commit 356e367) handles as a
13
+ * within-row reorder.
14
+ *
15
+ * Mutations after drop flow through the row's makeDroppable onDrop
16
+ * → dispatchSectionDrop, which mutates row.sections in place. No save
17
+ * call from here; the editor's deep watcher picks it up.
18
+ */
19
+ import { ref, computed, onBeforeUnmount, nextTick } from 'vue';
20
+ import type { ComputedRef } from 'vue';
21
+ import { makeDraggable, type IPlacement } from '@vue-dnd-kit/core';
22
+ import type { LayoutSection } from '../composables/useLayout';
23
+ import { useSectionRegistry } from '../sections/registry';
24
+ import type { EditorSelection } from '../composables/useLayoutEditor';
25
+ import type { SectionInstanceDragPayload } from '../composables/useLayoutDrag';
26
+ import { useLayoutResize } from '../composables/useLayoutResize';
27
+
28
+ const props = withDefaults(defineProps<{
29
+ section: LayoutSection;
30
+ /** The id of the row hosting this section — needed in the drag
31
+ * payload so the dispatcher can distinguish within-row reorder
32
+ * from cross-row move (deferred to 3b/B). */
33
+ rowId: string;
34
+ route: string;
35
+ zone: string;
36
+ editable?: boolean;
37
+ isPreview?: boolean;
38
+ onSelect?: (selection: EditorSelection) => void;
39
+ selectedId?: EditorSelection | null;
40
+ /**
41
+ * Move Up — WCAG 2.1.1 non-drag keyboard path. The parent (LayoutRow)
42
+ * passes a closure that mutates row.sections; this component only
43
+ * fires the click. Absence = button hidden (defensive: editable=true
44
+ * without the callbacks means a parent forgot to wire them, NOT an
45
+ * intentional "no move" state).
46
+ */
47
+ onMoveUp?: () => void;
48
+ onMoveDown?: () => void;
49
+ /**
50
+ * Phase 3b/B — "Move to zone…" keyboard cross-zone path. The list
51
+ * of OTHER zones the section can move to (current zone filtered out
52
+ * + zones with no rows filtered out by the parent). Empty / undefined
53
+ * → button hidden, no popover.
54
+ *
55
+ * Per the kickoff design pick (see session log): chosen over
56
+ * focusable-zone-header + Cmd+Shift+Arrow chord because discoverability
57
+ * matters more than economy — a single FAQ-able answer to "how do I
58
+ * move with the keyboard" is the goal.
59
+ */
60
+ availableZones?: string[];
61
+ /** Click handler — invoked with the target zone slug. The parent
62
+ * (LayoutRow) handles the splice + narration + history record. */
63
+ onMoveToZone?: (targetZone: string) => void;
64
+ /**
65
+ * Phase 3c — resize handle pointerdown handler. The closure lives on
66
+ * the parent <LayoutRow> because that's where the row's DOM element +
67
+ * sibling sections (neighbour lookup) + registry-derived bounds are
68
+ * naturally accessible. Absence = no handle rendered (parent decided
69
+ * the section isn't resizable OR this is the public render path).
70
+ *
71
+ * Receives the raw PointerEvent so `useLayoutResize.startResize` can
72
+ * capture pointer + read clientX. The handler is responsible for
73
+ * `e.preventDefault()` so the gesture doesn't bubble into the
74
+ * section's dnd-kit drag pickup or its `@click.stop` selection.
75
+ */
76
+ onResizeStart?: (e: PointerEvent) => void;
77
+ }>(), {
78
+ editable: false,
79
+ isPreview: false,
80
+ onSelect: undefined,
81
+ selectedId: null,
82
+ onMoveUp: undefined,
83
+ onMoveDown: undefined,
84
+ availableZones: () => [],
85
+ onMoveToZone: undefined,
86
+ onResizeStart: undefined,
87
+ });
88
+
89
+ const sectionRegistry = useSectionRegistry();
90
+
91
+ /* ----- colSpan + propMap resolution (moved with the section) ------- */
92
+
93
+ function resolveColSpan(viewport: 'lg' | 'md' | 'sm'): number {
94
+ const s = props.section;
95
+ if (viewport === 'lg') return s.responsive?.lg ?? s.colSpan;
96
+ if (viewport === 'md') return s.responsive?.md ?? s.responsive?.lg ?? s.colSpan;
97
+ return s.responsive?.sm ?? 12;
98
+ }
99
+
100
+ const sectionProps = computed<Record<string, unknown>>(() => {
101
+ const def = sectionRegistry.get(props.section.type);
102
+ if (!def) return {};
103
+ const standardProps = {
104
+ config: props.section.config,
105
+ meta: {
106
+ route: props.route,
107
+ zone: props.zone,
108
+ isPreview: props.isPreview,
109
+ effectiveColSpan: resolveColSpan('lg'),
110
+ sectionId: props.section.id,
111
+ },
112
+ };
113
+ return def.propMap ? def.propMap(standardProps) : standardProps;
114
+ });
115
+
116
+ /* ----- Selection ---------------------------------------------------- */
117
+
118
+ const isSelected = computed<boolean>(() => {
119
+ const sel = props.selectedId;
120
+ return !!sel && sel.kind === 'section' && sel.id === props.section.id;
121
+ });
122
+
123
+ function activate(): void {
124
+ if (!props.editable) return;
125
+ props.onSelect?.({ kind: 'section', id: props.section.id });
126
+ }
127
+
128
+ /* ----- makeDraggable ----------------------------------------------- */
129
+ /*
130
+ * disabled: !editable so the composable is registered unconditionally
131
+ * (Vue rules-of-hooks) but inert on the public path.
132
+ *
133
+ * groups: ['section'] matches both the palette tile + the row drop
134
+ * zone. dnd-kit's group-matching ensures a section can't drop into
135
+ * the palette or a non-section drop zone.
136
+ *
137
+ * payload returns a section-instance envelope — the dispatcher knows
138
+ * to splice-remove + splice-insert for sameList reorder, or noop for
139
+ * cross-row drops in 3b/A (cross-zone arrives in 3b/B).
140
+ */
141
+ const sectionRef = ref<HTMLElement | null>(null);
142
+ const dragDisabled = computed<boolean>(() => !props.editable);
143
+
144
+ /*
145
+ * CRITICAL — public-path provider guard (session 169 P0 hotfix).
146
+ *
147
+ * `makeDraggable` calls `inject('VueDnDKitProvider')` at setup and THROWS
148
+ * "DnD provider not found" when there's no <DnDProvider> ancestor.
149
+ * `disabled: true` does NOT suppress that inject (verified against
150
+ * @vue-dnd-kit/core 2.4.6). The public render path — the homepage layout
151
+ * canary + custom pages render <LayoutSlot> with editable=false and have
152
+ * NO provider ancestor — so calling makeDraggable there crashed the whole
153
+ * page with a 500 (live incident: commonpub.io homepage down after the
154
+ * sessions 163-168 deploy; the unit tests masked it by vi.mock-ing the
155
+ * whole dnd-kit module — see feedback-integration-test-full-output-path).
156
+ *
157
+ * Fix: instantiate the drag machinery ONLY in editable mode, which always
158
+ * renders inside the editor's <DnDProvider>. `editable` is static for an
159
+ * instance's lifetime (a section is never toggled public<->editor without
160
+ * remounting), so this conditional composable call is safe under Vue's
161
+ * setup-once model. Public instances use inert ComputedRef fallbacks —
162
+ * their drag state is always idle anyway.
163
+ */
164
+ let isDragOver: ComputedRef<IPlacement | undefined>;
165
+ let isDragging: ComputedRef<boolean>;
166
+ if (props.editable) {
167
+ const draggable = makeDraggable(
168
+ sectionRef,
169
+ {
170
+ groups: ['section'],
171
+ disabled: dragDisabled,
172
+ },
173
+ () => [
174
+ 0,
175
+ [
176
+ {
177
+ kind: 'section-instance',
178
+ section: props.section,
179
+ fromRowId: props.rowId,
180
+ } satisfies SectionInstanceDragPayload,
181
+ ],
182
+ ],
183
+ );
184
+ isDragOver = draggable.isDragOver;
185
+ isDragging = draggable.isDragging;
186
+ } else {
187
+ isDragOver = computed(() => undefined);
188
+ isDragging = computed(() => false);
189
+ }
190
+
191
+ /**
192
+ * Session 164 polish — placement-aware drop indicator (plan §7.4).
193
+ *
194
+ * When a drag is held over THIS section, dnd-kit's `isDragOver` carries
195
+ * an `IPlacement` describing which side the cursor is on. We translate
196
+ * that into a 'before' / 'after' / null tri-state, then bind two CSS
197
+ * classes that paint a 2px accent line on the section's left or right
198
+ * edge — showing the admin EXACTLY where the drop will land.
199
+ *
200
+ * Rows are horizontal, so `left` and `right` are the primary signals.
201
+ * Session 164 audit R2-2: dnd-kit's pointer math falls back to `top` /
202
+ * `bottom` flags whenever the hovered element's bounding rect is taller
203
+ * than wide (e.g. a section that wrapped to two grid lines, or any
204
+ * future vertical row). `useLayoutDrag.computeInsertIndex` already
205
+ * honors them — the indicator MUST match or it lies about where the
206
+ * drop will land. Mirror that logic here.
207
+ *
208
+ * No animation framework: box-shadow + 100ms opacity transition. The
209
+ * indicator vanishes the moment the cursor leaves the section. FLIP
210
+ * transitions on the section list use `transform`, which doesn't
211
+ * conflict with box-shadow. prefers-reduced-motion disables the fade.
212
+ */
213
+ const dropIndicatorSide = computed<'before' | 'after' | null>(() => {
214
+ const placement = isDragOver.value;
215
+ if (!placement) return null;
216
+ if (placement.left || placement.top) return 'before';
217
+ if (placement.right || placement.bottom) return 'after';
218
+ return null;
219
+ });
220
+
221
+ /* ----- Move to zone … popover (Phase 3b/B) ------------------------- */
222
+ /*
223
+ * Disclosure pattern: button toggles a small inline menu listing the
224
+ * available target zones. Single-select (one click → move + close).
225
+ * Esc closes; click-outside closes; focus returns to the trigger on
226
+ * close so keyboard users don't lose context.
227
+ *
228
+ * Why not a dropdown library? The list is 1-3 items (we have 3 zones
229
+ * total: full-width / main / sidebar). A 5-line inline popover keeps
230
+ * bundle + maintenance overhead at zero.
231
+ */
232
+ const moveMenuOpen = ref<boolean>(false);
233
+ const moveMenuTrigger = ref<HTMLButtonElement | null>(null);
234
+ const moveMenuPanel = ref<HTMLElement | null>(null);
235
+
236
+ function toggleMoveMenu(): void {
237
+ moveMenuOpen.value = !moveMenuOpen.value;
238
+ // When opening, move focus into the panel so keyboard users can
239
+ // immediately Tab through the zone options. Done in nextTick so the
240
+ // panel is rendered first.
241
+ if (moveMenuOpen.value) {
242
+ void nextTick().then(() => {
243
+ const firstBtn = moveMenuPanel.value?.querySelector<HTMLButtonElement>('button');
244
+ firstBtn?.focus();
245
+ });
246
+ }
247
+ }
248
+
249
+ function closeMoveMenu(): void {
250
+ moveMenuOpen.value = false;
251
+ // Return focus to the trigger so keyboard navigation stays predictable.
252
+ moveMenuTrigger.value?.focus();
253
+ }
254
+
255
+ function chooseMoveTarget(zone: string): void {
256
+ props.onMoveToZone?.(zone);
257
+ moveMenuOpen.value = false;
258
+ // After move, the section's DOM may be re-keyed under a new row
259
+ // (Vue's v-for keying). Don't try to focus the trigger — it may
260
+ // already be unmounted. Focus management for the moved section's
261
+ // new location is the editor's responsibility (out of scope for
262
+ // 3b/B; selection follows the section via the existing select
263
+ // callback in a future polish).
264
+ }
265
+
266
+ function onMoveMenuKey(e: KeyboardEvent): void {
267
+ if (e.key === 'Escape') {
268
+ e.preventDefault();
269
+ closeMoveMenu();
270
+ }
271
+ }
272
+
273
+ /* R1 audit P2 fix: keyboard users who Tab past the last menu item
274
+ * would leave focus elsewhere on the page while the popover stayed
275
+ * open. focusout fires whenever focus leaves the panel; relatedTarget
276
+ * is the new focus destination. If that destination is OUTSIDE both
277
+ * the panel AND the trigger button, close. (Inside the trigger is
278
+ * fine — clicking trigger again toggles via its own handler.) */
279
+ function onMoveMenuFocusOut(e: FocusEvent): void {
280
+ const next = e.relatedTarget as Node | null;
281
+ if (!next) {
282
+ // Focus moved to nothing (e.g. clicking outside the document).
283
+ // Treat as outside.
284
+ moveMenuOpen.value = false;
285
+ return;
286
+ }
287
+ if (moveMenuPanel.value?.contains(next)) return;
288
+ if (moveMenuTrigger.value?.contains(next)) return;
289
+ moveMenuOpen.value = false;
290
+ }
291
+
292
+ /* Click-outside dismissal. Attached to document on open, removed on
293
+ * close + unmount. The condition `!panel.contains(target) && target !==
294
+ * trigger` allows clicks on the trigger itself to handle the toggle
295
+ * (otherwise the toggle would close, then the click handler would
296
+ * re-open — flicker). */
297
+ function onDocumentPointerDown(e: PointerEvent): void {
298
+ if (!moveMenuOpen.value) return;
299
+ const target = e.target as Node | null;
300
+ if (!target) return;
301
+ if (moveMenuPanel.value?.contains(target)) return;
302
+ if (moveMenuTrigger.value?.contains(target)) return;
303
+ moveMenuOpen.value = false;
304
+ }
305
+ if (typeof window !== 'undefined') {
306
+ document.addEventListener('pointerdown', onDocumentPointerDown);
307
+ }
308
+ onBeforeUnmount(() => {
309
+ if (typeof window !== 'undefined') {
310
+ document.removeEventListener('pointerdown', onDocumentPointerDown);
311
+ }
312
+ });
313
+
314
+ const hasMoveTargets = computed<boolean>(() =>
315
+ !!props.onMoveToZone && props.availableZones.length > 0,
316
+ );
317
+
318
+ /* ----- Resize handle wiring (Phase 3c) --------------------------------- */
319
+ /*
320
+ * Handle visibility: editable + parent passed a handler. The parent
321
+ * (LayoutRow) only passes a handler when the section's registry def
322
+ * has `resizable: true` AND the row is wider than its mobile breakpoint
323
+ * (plan §7.5 — resize is disabled on < 768px). Section-side check is
324
+ * thin: render IFF the prop is present. CSS hides the handle at < 768px
325
+ * defensively in case a layout author passes the handler anyway.
326
+ *
327
+ * The pointerdown attribute is captured WITH passive=false (default for
328
+ * `.passive` modifier absence) so we can preventDefault and consume
329
+ * the gesture before dnd-kit's listener on the root element fires.
330
+ */
331
+ const resize = useLayoutResize();
332
+ const hasResizeHandle = computed<boolean>(
333
+ () => props.editable && typeof props.onResizeStart === 'function',
334
+ );
335
+
336
+ /** Is THIS section currently being resized? Drives the live pill + the
337
+ * "dim move buttons during resize" rule below. */
338
+ const isResizing = computed<boolean>(() => {
339
+ const s = resize.state.value;
340
+ return s.kind === 'resizing' && s.sectionId === props.section.id;
341
+ });
342
+
343
+ /** Phase 3c polish (Path B #9): is THIS section being DRAGGED by dnd-kit
344
+ * (section reorder gesture)? When true, the resize handle is hidden so
345
+ * the cursor doesn't lie ("col-resize" over a section that's mid-drag).
346
+ * isDragging is exposed by dnd-kit's makeDraggable; it's a `Ref<boolean>`
347
+ * matching the existing isDragOver shape we already destructure. */
348
+ const isSectionDragging = computed<boolean>(() => isDragging.value === true);
349
+
350
+ /** Is THIS section the right-neighbour absorbing the resize? Drives the
351
+ * dimmed neighbour pill so the user can see both spans update together. */
352
+ const isResizeNeighbour = computed<boolean>(() => {
353
+ const s = resize.state.value;
354
+ return s.kind === 'resizing' && s.neighbourId === props.section.id;
355
+ });
356
+
357
+ /** Span text the pill renders. During a resize, follows the live
358
+ * state; otherwise echoes the section's own colSpan so the pill stays
359
+ * meaningful as a static badge when selected. */
360
+ const liveSpanText = computed<string>(() => {
361
+ const s = resize.state.value;
362
+ if (s.kind === 'resizing' && s.sectionId === props.section.id) {
363
+ return `${s.currentColSpan}/12`;
364
+ }
365
+ if (s.kind === 'resizing' && s.neighbourId === props.section.id) {
366
+ return `${s.neighbourCurrentColSpan}/12`;
367
+ }
368
+ return `${props.section.colSpan}/12`;
369
+ });
370
+
371
+ /** Constraint-snap label — only shown while resizing + a bound was hit.
372
+ * Mirrors `narrateResizeBlocked`'s wording in compact visual form. */
373
+ const constraintLabel = computed<string | null>(() => {
374
+ const s = resize.state.value;
375
+ if (s.kind !== 'resizing') return null;
376
+ if (s.sectionId !== props.section.id) return null;
377
+ if (s.constraintHit === null) return null;
378
+ if (s.constraintHit === 'section-min') return `🔒 min ${s.constraintBound}/12`;
379
+ if (s.constraintHit === 'section-max') return `🔒 max ${s.constraintBound}/12`;
380
+ return `🔒 next at ${s.constraintBound}/12`;
381
+ });
382
+
383
+ /** Direct pointerdown wrapper — stops bubbling so the section's own
384
+ * drag-pickup (dnd-kit) + click (selection) don't fire. */
385
+ function onHandlePointerDown(e: PointerEvent): void {
386
+ if (!props.onResizeStart) return;
387
+ // Only respond to primary button (mouse) OR any touch/pen. Right-click
388
+ // on the handle shouldn't start a resize.
389
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
390
+ e.stopPropagation();
391
+ e.preventDefault();
392
+ props.onResizeStart(e);
393
+ }
394
+
395
+ /* ----- Visibility -------------------------------------------------- */
396
+ /* hideAt is applied via CSS data-* attrs; the parent already filters
397
+ enabled + visibility.roles + visibility.features. */
398
+ </script>
399
+
400
+ <template>
401
+ <div
402
+ ref="sectionRef"
403
+ class="cpub-layout-section"
404
+ :class="{
405
+ 'cpub-layout-section--editable': editable,
406
+ 'cpub-layout-section--selected': editable && isSelected,
407
+ 'cpub-layout-section--drop-before': editable && dropIndicatorSide === 'before',
408
+ 'cpub-layout-section--drop-after': editable && dropIndicatorSide === 'after',
409
+ }"
410
+ :data-section-id="section.id"
411
+ :data-section-type="section.type"
412
+ :data-hide-sm="section.visibility?.hideAt?.includes('sm') ? 'true' : 'false'"
413
+ :data-hide-md="section.visibility?.hideAt?.includes('md') ? 'true' : 'false'"
414
+ :data-hide-lg="section.visibility?.hideAt?.includes('lg') ? 'true' : 'false'"
415
+ :style="{
416
+ '--cpub-section-cols-sm': resolveColSpan('sm'),
417
+ '--cpub-section-cols-md': resolveColSpan('md'),
418
+ '--cpub-section-cols-lg': resolveColSpan('lg'),
419
+ }"
420
+ :tabindex="editable ? 0 : undefined"
421
+ :aria-label="editable
422
+ ? (isSelected ? `Selected: ${section.type} section` : `Select ${section.type} section`)
423
+ : undefined"
424
+ @click.stop="activate"
425
+ @keydown.enter.prevent="activate"
426
+ @keydown.space.prevent.stop="activate"
427
+ >
428
+ <component
429
+ v-if="sectionRegistry.has(section.type)"
430
+ :is="sectionRegistry.get(section.type)!.component"
431
+ v-bind="sectionProps"
432
+ />
433
+ <div
434
+ v-else
435
+ class="cpub-layout-section-placeholder"
436
+ :aria-label="`Unregistered section type: ${section.type}`"
437
+ >
438
+ <code>{{ section.type }}</code>
439
+ <span class="cpub-layout-section-placeholder-hint">section type not registered</span>
440
+ </div>
441
+
442
+ <!--
443
+ Phase 3b/A: WCAG 2.1.1 Level A non-drag keyboard path. Two
444
+ buttons in the section's top-right corner. Always visible in
445
+ editable mode (per feedback-visual-editor-ux-patterns: SR users
446
+ need an always-discoverable alternative; revealing on hover hides
447
+ it from keyboard users + assistive tech). Sized 28×28 minimum
448
+ per the same memory (WCAG 2.5.8's 24×24 is the bare floor, not
449
+ the design target).
450
+ `@click.stop` prevents the click bubbling to the section's own
451
+ click handler (which would set selection); the move IS the user's
452
+ intent, not selection. `@keydown.space.stop` prevents the dnd-kit
453
+ keyboard sensor from interpreting Space-on-button as a drag
454
+ pickup of the section.
455
+ -->
456
+ <div v-if="editable && (onMoveUp || onMoveDown || hasMoveTargets)" class="cpub-layout-section-moves">
457
+ <button
458
+ v-if="onMoveUp"
459
+ type="button"
460
+ class="cpub-layout-section-move"
461
+ :aria-label="`Move ${section.type} up`"
462
+ @click.stop="onMoveUp"
463
+ @keydown.space.stop
464
+ @keydown.enter.stop
465
+ >
466
+ <i class="fa-solid fa-chevron-up" aria-hidden="true"></i>
467
+ </button>
468
+ <button
469
+ v-if="onMoveDown"
470
+ type="button"
471
+ class="cpub-layout-section-move"
472
+ :aria-label="`Move ${section.type} down`"
473
+ @click.stop="onMoveDown"
474
+ @keydown.space.stop
475
+ @keydown.enter.stop
476
+ >
477
+ <i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
478
+ </button>
479
+ <!--
480
+ Phase 3b/B — "Move to zone…" disclosure. Renders only when the
481
+ parent provided a non-empty availableZones list (current zone
482
+ excluded, zones with zero rows excluded). aria-haspopup='menu'
483
+ + aria-expanded so screen readers announce the disclosure state;
484
+ aria-controls links to the panel id for assistive tech
485
+ traversal.
486
+ -->
487
+ <button
488
+ v-if="hasMoveTargets"
489
+ ref="moveMenuTrigger"
490
+ type="button"
491
+ class="cpub-layout-section-move"
492
+ :aria-label="`Move ${section.type} to another zone`"
493
+ aria-haspopup="menu"
494
+ :aria-expanded="moveMenuOpen ? 'true' : 'false'"
495
+ :aria-controls="`cpub-move-menu-${section.id}`"
496
+ @click.stop="toggleMoveMenu"
497
+ @keydown.space.stop.prevent="toggleMoveMenu"
498
+ @keydown.enter.stop.prevent="toggleMoveMenu"
499
+ >
500
+ <i class="fa-solid fa-arrows-up-down-left-right" aria-hidden="true"></i>
501
+ </button>
502
+ <div
503
+ v-if="moveMenuOpen"
504
+ :id="`cpub-move-menu-${section.id}`"
505
+ ref="moveMenuPanel"
506
+ class="cpub-layout-section-move-menu"
507
+ role="menu"
508
+ :aria-label="`Move ${section.type} to zone`"
509
+ @click.stop="(e: Event) => e.stopPropagation()"
510
+ @keydown="onMoveMenuKey"
511
+ @focusout="onMoveMenuFocusOut"
512
+ >
513
+ <button
514
+ v-for="zone in availableZones"
515
+ :key="zone"
516
+ type="button"
517
+ role="menuitem"
518
+ class="cpub-layout-section-move-menu-item"
519
+ @click.stop="chooseMoveTarget(zone)"
520
+ >
521
+ <i class="fa-solid fa-arrow-right-long" aria-hidden="true"></i>
522
+ <span>{{ zone }}</span>
523
+ </button>
524
+ </div>
525
+ </div>
526
+
527
+ <!--
528
+ Phase 3c — right-edge resize handle.
529
+ Renders only when the parent (LayoutRow) passes onResizeStart —
530
+ that's the parent's signal that the section's registry def is
531
+ `resizable: true` AND the row is wider than the mobile breakpoint
532
+ (CSS further hides at < 768px defensively).
533
+
534
+ The button is the SAME DOM subtree as the move-buttons cluster
535
+ but sits at the right edge instead of top-right. dnd-kit's
536
+ pointerdown is on the OUTER .cpub-layout-section root; this
537
+ handle's pointerdown handler stops propagation so the section's
538
+ drag pickup doesn't fire while resizing.
539
+
540
+ `aria-label` includes both intent + current state ("Resize hero
541
+ section, currently 8 of 12 columns") so SR users hear the live
542
+ span without depending on the visual pill. State-in-name pattern
543
+ per feedback-aria-selected-needs-role.
544
+ -->
545
+ <button
546
+ v-if="hasResizeHandle"
547
+ type="button"
548
+ class="cpub-layout-section-resize-handle"
549
+ :class="{
550
+ 'cpub-layout-section-resize-handle--active': isResizing,
551
+ 'cpub-layout-section-resize-handle--hidden-during-drag': isSectionDragging,
552
+ }"
553
+ :aria-label="`Resize ${section.type} section, currently ${section.colSpan} of 12 columns. Hold and drag, or use Shift plus Arrow Left or Right while focused on this section.`"
554
+ :title="`Drag to resize · ${section.colSpan}/12`"
555
+ @pointerdown="onHandlePointerDown"
556
+ @click.stop
557
+ @keydown.space.stop
558
+ @keydown.enter.stop
559
+ >
560
+ <!-- Two parallel lines mimic the convention from Figma / Webflow /
561
+ Framer / Linear: vertical grip indicating "draggable edge". -->
562
+ <i class="fa-solid fa-grip-lines-vertical" aria-hidden="true"></i>
563
+ </button>
564
+
565
+ <!--
566
+ Phase 3c — live span pill. Shown while the section is selected OR
567
+ involved in an in-flight resize. Three-state visual:
568
+ - selected only: subtle outline-style badge "8/12"
569
+ - resizing (this section): accent-filled, follows live span
570
+ - neighbour during resize: dim variant, shows neighbour's live span
571
+ Sighted users get the same fact SR users hear via narrateResize.
572
+ -->
573
+ <div
574
+ v-if="editable && (isSelected || isResizing || isResizeNeighbour)"
575
+ class="cpub-layout-section-span-pill"
576
+ :class="{
577
+ 'cpub-layout-section-span-pill--active': isResizing,
578
+ 'cpub-layout-section-span-pill--neighbour': isResizeNeighbour,
579
+ }"
580
+ aria-hidden="true"
581
+ >
582
+ {{ liveSpanText }}
583
+ </div>
584
+
585
+ <!--
586
+ Phase 3c — constraint snap label. Shown ONLY while THIS section is
587
+ being resized AND a bound was hit. Provides the three independent
588
+ signals plan §7.5 + WCAG 1.4.1 require: outline color change (the
589
+ handle's --active state), lock icon ("🔒"), text ("min 3/12").
590
+ Floats just below the span pill so colour-blind / sighted users
591
+ have all three at once.
592
+
593
+ `aria-hidden="true"`: this label is a VISUAL cue only. The audio
594
+ channel is already covered by the announcer's
595
+ narrateResizeBlocked (assertive), fired from useLayoutResize.
596
+ Adding `aria-live="polite"` here would double-narrate the bound
597
+ to screen readers (audit R1-1).
598
+ -->
599
+ <div
600
+ v-if="constraintLabel"
601
+ class="cpub-layout-section-constraint-label"
602
+ aria-hidden="true"
603
+ >
604
+ {{ constraintLabel }}
605
+ </div>
606
+ </div>
607
+ </template>
608
+
609
+ <style scoped>
610
+ /*
611
+ * Section chrome — moved here from LayoutRow because Vue scoped styles
612
+ * hash by component instance + LayoutRow rendering <LayoutSection>
613
+ * components wouldn't reach their .cpub-layout-section markup.
614
+ */
615
+ .cpub-layout-section {
616
+ grid-column: span var(--cpub-section-cols-lg, 12);
617
+ min-width: 0;
618
+ }
619
+ @media (max-width: 1024px) {
620
+ .cpub-layout-section { grid-column: span var(--cpub-section-cols-md, var(--cpub-section-cols-lg, 12)); }
621
+ }
622
+ @media (max-width: 640px) {
623
+ .cpub-layout-section { grid-column: span var(--cpub-section-cols-sm, 12); }
624
+ }
625
+
626
+ .cpub-layout-section[data-hide-sm='true'] { @media (max-width: 640px) { display: none; } }
627
+ .cpub-layout-section[data-hide-md='true'] { @media (min-width: 641px) and (max-width: 1024px) { display: none; } }
628
+ .cpub-layout-section[data-hide-lg='true'] { @media (min-width: 1025px) { display: none; } }
629
+
630
+ .cpub-layout-section-placeholder {
631
+ padding: var(--space-4);
632
+ background: var(--surface2);
633
+ border: 1px dashed var(--border2);
634
+ font-family: var(--font-mono);
635
+ font-size: var(--text-sm);
636
+ color: var(--text-dim);
637
+ text-align: center;
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: 4px;
641
+ align-items: center;
642
+ }
643
+ .cpub-layout-section-placeholder code { color: var(--accent); }
644
+ .cpub-layout-section-placeholder-hint {
645
+ font-size: var(--text-xs);
646
+ color: var(--text-faint);
647
+ }
648
+
649
+ /* ------------------------------------------------------------------ */
650
+ /* Editable-mode chrome — same treatment as before the extraction but */
651
+ /* WITH the `cursor: grab` contract finally lit up (makeDraggable is */
652
+ /* wired in this commit, so the cursor is no longer a UI lie). */
653
+ /* ------------------------------------------------------------------ */
654
+ .cpub-layout-section--editable {
655
+ position: relative;
656
+ cursor: grab;
657
+ }
658
+ .cpub-layout-section--editable:active {
659
+ cursor: grabbing;
660
+ }
661
+
662
+ /*
663
+ * Session 164 polish — inert content in editable mode (fixes the
664
+ * pre-existing 3a "clicking Hero CTA navigates away from the editor"
665
+ * bug + every variant of it across all 21 sections).
666
+ *
667
+ * The section's outer wrapper handles selection (click) + drag pickup
668
+ * (pointerdown via makeDraggable). The inner content — rendered by
669
+ * <component :is="..."> from the registry — frequently contains
670
+ * <NuxtLink>s (Hero CTA, FeedItem cards, etc) that would otherwise
671
+ * navigate the admin AWAY from /admin/layouts/[id] on any click.
672
+ *
673
+ * Pointer-events: none on the rendered content makes it transparent
674
+ * to mouse events; clicks fall through to the section's outer wrapper
675
+ * (selection). The moves cluster + popover stay interactive via their
676
+ * own ":not()" carve-out. The drag pickup still works because dnd-kit
677
+ * binds to the OUTER element, not inner content.
678
+ *
679
+ * One CSS rule replaces the alternative — drilling `isPreview` through
680
+ * propMap on all 21 sections + adding navigation guards inside each.
681
+ * (Per session 163's "reuse existing components" memory: the layout
682
+ * engine is an ARRANGER for existing components; we shouldn't fork
683
+ * each section just for editable-mode behavior.)
684
+ *
685
+ * Text selection inside the preview is also blocked — fine for v1; the
686
+ * admin uses the inspector (Phase 3e) to edit copy, not in-canvas
687
+ * direct manipulation. (Phase 3f's inline-edit affordance can carve
688
+ * out specific zones if needed.)
689
+ */
690
+ /* Session 166 round-2 audit P0: the resize handle (Phase 3c) is a
691
+ * direct child of .cpub-layout-section--editable too. Without an
692
+ * explicit :not(), this cascade makes it hit-test-invisible — clicks
693
+ * + pointerdowns drop to the section's outer wrapper instead of
694
+ * firing on the handle. Tests don't catch it (jsdom dispatchEvent
695
+ * bypasses pointer-events hit testing); only a real browser does.
696
+ * Adding the carve-out matches the established "interactive editor
697
+ * chrome opts out" pattern (.cpub-layout-section-moves did the same
698
+ * thing).
699
+ *
700
+ * The span pill + constraint label DO want pointer-events:none
701
+ * (they're aria-hidden visual badges that shouldn't intercept
702
+ * clicks on the section content beneath), so they're NOT carved out
703
+ * here — each sets pointer-events:none explicitly below for clarity. */
704
+ .cpub-layout-section--editable > *:not(.cpub-layout-section-moves):not(.cpub-layout-section-resize-handle) {
705
+ pointer-events: none;
706
+ }
707
+ .cpub-layout-section--editable:hover {
708
+ outline: 1px dashed var(--border2);
709
+ outline-offset: -1px;
710
+ }
711
+ .cpub-layout-section--editable:focus-visible {
712
+ outline: 2px solid var(--accent);
713
+ outline-offset: -1px;
714
+ }
715
+
716
+ .cpub-layout-section--editable::after {
717
+ content: attr(data-section-type);
718
+ position: absolute;
719
+ top: 0;
720
+ left: 0;
721
+ padding: var(--space-1) var(--space-2);
722
+ background: var(--surface2);
723
+ color: var(--text-dim);
724
+ font-family: var(--font-mono);
725
+ font-size: var(--text-xs);
726
+ text-transform: uppercase;
727
+ letter-spacing: var(--tracking-wide);
728
+ border: 1px solid var(--border2);
729
+ border-top: 0;
730
+ border-left: 0;
731
+ pointer-events: none;
732
+ opacity: 0;
733
+ transition: opacity 100ms ease-out;
734
+ z-index: 1;
735
+ }
736
+ .cpub-layout-section--editable:hover::after { opacity: 1; }
737
+ @media (prefers-reduced-motion: reduce) {
738
+ .cpub-layout-section--editable::after { transition: none; }
739
+ }
740
+
741
+ .cpub-layout-section--selected {
742
+ outline: 2px solid var(--accent);
743
+ outline-offset: -1px;
744
+ }
745
+ .cpub-layout-section--selected::after {
746
+ background: var(--accent);
747
+ color: var(--surface);
748
+ border-color: var(--accent);
749
+ opacity: 1;
750
+ }
751
+
752
+ /* ------------------------------------------------------------------ */
753
+ /* Session 164 — placement-aware drop indicators (plan §7.4). */
754
+ /* 2px accent line on left/right edge during drag-over. Box-shadow */
755
+ /* keeps the section's layout box unchanged + plays well with FLIP's */
756
+ /* transform animations. 100ms fade matches the existing chrome timing. */
757
+ /* ------------------------------------------------------------------ */
758
+ .cpub-layout-section--drop-before {
759
+ box-shadow: -3px 0 0 0 var(--accent);
760
+ transition: box-shadow 100ms ease-out;
761
+ }
762
+ .cpub-layout-section--drop-after {
763
+ box-shadow: 3px 0 0 0 var(--accent);
764
+ transition: box-shadow 100ms ease-out;
765
+ }
766
+ @media (prefers-reduced-motion: reduce) {
767
+ .cpub-layout-section--drop-before,
768
+ .cpub-layout-section--drop-after {
769
+ transition: none;
770
+ }
771
+ }
772
+
773
+ /* ------------------------------------------------------------------ */
774
+ /* Move Up / Move Down — non-drag a11y path (WCAG 2.1.1 Level A). */
775
+ /* Top-right corner mirror of the type-label badge (top-left). */
776
+ /* 28×28 touch targets per feedback-visual-editor-ux-patterns. */
777
+ /* Always visible in editable mode — discoverability for keyboard + */
778
+ /* SR users is the entire point of this control. */
779
+ /* ------------------------------------------------------------------ */
780
+ .cpub-layout-section-moves {
781
+ position: absolute;
782
+ /* Inset 2px so the buttons don't visually kiss the section's
783
+ 2px --selected outline (audit R4-4). */
784
+ top: 2px;
785
+ right: 2px;
786
+ display: flex;
787
+ gap: 1px;
788
+ z-index: 2;
789
+ pointer-events: auto;
790
+ }
791
+ .cpub-layout-section-move {
792
+ display: inline-flex;
793
+ align-items: center;
794
+ justify-content: center;
795
+ width: 28px;
796
+ height: 28px;
797
+ background: var(--surface2);
798
+ color: var(--text-dim);
799
+ border: 1px solid var(--border2);
800
+ border-top: 0;
801
+ border-right: 0;
802
+ cursor: pointer;
803
+ font-size: var(--text-sm);
804
+ }
805
+ .cpub-layout-section-move:hover {
806
+ background: var(--surface);
807
+ color: var(--text);
808
+ }
809
+ .cpub-layout-section-move:focus-visible {
810
+ outline: 2px solid var(--accent);
811
+ outline-offset: 1px;
812
+ /* Bring the focused button to the top so the outline isn't clipped
813
+ by the section's own outline. */
814
+ z-index: 3;
815
+ }
816
+
817
+ /* ------------------------------------------------------------------ */
818
+ /* Phase 3b/B — "Move to zone…" popover. Anchored below the trigger */
819
+ /* button group. Same surface/border tokens as the move buttons so it */
820
+ /* reads as a single chrome cluster. */
821
+ /* ------------------------------------------------------------------ */
822
+ .cpub-layout-section-move-menu {
823
+ position: absolute;
824
+ top: 30px; /* below the 28px button row + 2px gap */
825
+ right: 0;
826
+ display: flex;
827
+ flex-direction: column;
828
+ background: var(--surface);
829
+ border: 1px solid var(--border);
830
+ min-width: 140px;
831
+ /* Sits above sibling sections; the section-moves cluster is z-index:2
832
+ so the menu needs higher. */
833
+ z-index: 4;
834
+ }
835
+ .cpub-layout-section-move-menu-item {
836
+ display: inline-flex;
837
+ align-items: center;
838
+ gap: var(--space-2);
839
+ padding: var(--space-2) var(--space-3);
840
+ background: transparent;
841
+ border: 0;
842
+ color: var(--text);
843
+ font-family: var(--font-mono);
844
+ font-size: var(--text-xs);
845
+ text-transform: uppercase;
846
+ letter-spacing: var(--tracking-wide);
847
+ cursor: pointer;
848
+ text-align: left;
849
+ }
850
+ .cpub-layout-section-move-menu-item:hover {
851
+ background: var(--accent-bg);
852
+ color: var(--accent);
853
+ }
854
+ .cpub-layout-section-move-menu-item:focus-visible {
855
+ outline: 2px solid var(--accent);
856
+ outline-offset: -2px;
857
+ }
858
+ .cpub-layout-section-move-menu-item i { color: var(--text-dim); }
859
+ .cpub-layout-section-move-menu-item:hover i { color: var(--accent); }
860
+
861
+ /* ------------------------------------------------------------------ */
862
+ /* Phase 3c — Resize handle on the section's right edge. */
863
+ /* Slim vertical strip (4px wide), centered on the section's right */
864
+ /* border. Touch target is enlarged via padding on the inner button */
865
+ /* so the visible affordance stays minimal but WCAG 2.5.8 (24×24) */
866
+ /* is satisfied. col-resize cursor establishes the contract. */
867
+ /* */
868
+ /* Hover/selection reveals; default is 0 opacity so the canvas isn't */
869
+ /* visually noisy when nothing's selected. Visible during a resize-in- */
870
+ /* flight via the --active modifier (the gesture's own owning section). */
871
+ /* */
872
+ /* < 768px: hidden per plan §7.5. The inspector slider becomes the */
873
+ /* colSpan path on small viewports (Phase 3e ships it; for now the */
874
+ /* handle's just absent on mobile + the move buttons still work). */
875
+ /* ------------------------------------------------------------------ */
876
+ .cpub-layout-section-resize-handle {
877
+ position: absolute;
878
+ /* Centered on the section's right border. -2px so the 4px-wide handle
879
+ sits half-in/half-out — reads as "the border itself is the grip". */
880
+ top: 50%;
881
+ right: -2px;
882
+ transform: translateY(-50%);
883
+ /* Visible affordance is 4×56 (slim strip). Inner padding bumps the
884
+ hit area to ≥24×24 per WCAG without bloating the visual. */
885
+ width: 4px;
886
+ height: 56px;
887
+ /* Pad the click target — inset shadow doesn't grow visually; the
888
+ element's box-sizing makes click area = width + padding-x*2. */
889
+ padding: 0 12px;
890
+ background: var(--accent);
891
+ border: 0;
892
+ cursor: col-resize;
893
+ /* Above section content so the user can grab it even with overlapping
894
+ hover affordances; below the move-buttons cluster (z=2) + the move
895
+ popover (z=4) so disclosure UIs win conflicts. */
896
+ z-index: 1;
897
+ /* Hover/focus/active reveal — opacity 0 by default so the canvas is
898
+ visually quiet when nothing's selected. */
899
+ opacity: 0;
900
+ transition: opacity 100ms ease-out;
901
+ /* The icon's contained inside the inner 4px strip — center it. */
902
+ display: inline-flex;
903
+ align-items: center;
904
+ justify-content: center;
905
+ color: var(--surface);
906
+ font-size: 10px;
907
+ }
908
+ .cpub-layout-section-resize-handle i {
909
+ /* The grip lines icon — visible only when handle is. Keeps the
910
+ touch target generous without taking visual space. */
911
+ line-height: 1;
912
+ pointer-events: none;
913
+ }
914
+ /* Reveal on the section's hover, selection, or focus-within (keyboard
915
+ user tabbed to a child) — the union covers all input modes. */
916
+ .cpub-layout-section--editable:hover > .cpub-layout-section-resize-handle,
917
+ .cpub-layout-section--selected > .cpub-layout-section-resize-handle,
918
+ .cpub-layout-section--editable:focus-within > .cpub-layout-section-resize-handle,
919
+ .cpub-layout-section-resize-handle--active,
920
+ .cpub-layout-section-resize-handle:focus-visible {
921
+ opacity: 1;
922
+ }
923
+ .cpub-layout-section-resize-handle:focus-visible {
924
+ outline: 2px solid var(--accent);
925
+ outline-offset: 2px;
926
+ }
927
+ /* During an active resize, fatten the strip so the visual matches the
928
+ "I'm gripping this" gesture + adds the third independent signal
929
+ (alongside color + lock icon) that constraint-snap relies on. */
930
+ .cpub-layout-section-resize-handle--active {
931
+ width: 6px;
932
+ right: -3px;
933
+ }
934
+ /* Phase 3c polish (Path B #9): hide the handle while dnd-kit is
935
+ * dragging the section. Without this, the col-resize cursor flashes
936
+ * over the handle during drag pickup → user momentarily thinks they
937
+ * need to grab the handle to drag. The handle re-appears the moment
938
+ * the drag ends. pointer-events:none here is belt-and-suspenders —
939
+ * opacity:0 alone already removes the visual; pointer-events:none
940
+ * ensures the touch target can't interfere if the dragged section
941
+ * temporarily overlaps the handle's hit area. */
942
+ .cpub-layout-section-resize-handle--hidden-during-drag {
943
+ opacity: 0 !important;
944
+ pointer-events: none !important;
945
+ }
946
+ @media (prefers-reduced-motion: reduce) {
947
+ .cpub-layout-section-resize-handle { transition: none; }
948
+ }
949
+ /* < 768px: hide the handle per plan §7.5. Colspan changes happen via
950
+ the inspector slider on mobile (deferred to Phase 3e — keyboard
951
+ path via Shift+Arrow still works in the meantime). */
952
+ @media (max-width: 768px) {
953
+ .cpub-layout-section-resize-handle { display: none; }
954
+ }
955
+
956
+ /* ------------------------------------------------------------------ */
957
+ /* Phase 3c — Live span pill. */
958
+ /* Anchored top-right INSIDE the section (under the move-buttons */
959
+ /* cluster). Three states: */
960
+ /* - default (selected only): outline-style "8/12" badge */
961
+ /* - --active (this section is being resized): accent-filled */
962
+ /* - --neighbour (this section is absorbing the resize delta): dim */
963
+ /* ------------------------------------------------------------------ */
964
+ .cpub-layout-section-span-pill {
965
+ position: absolute;
966
+ /* Below the moves cluster so they don't overlap. moves is at top:2 +
967
+ 28px tall + 2 gap = 32. Pill sits at top:36 with a bit of breathing
968
+ room. */
969
+ top: 36px;
970
+ right: 2px;
971
+ padding: 2px var(--space-2);
972
+ background: var(--surface2);
973
+ border: 1px solid var(--border2);
974
+ color: var(--text-dim);
975
+ font-family: var(--font-mono);
976
+ font-size: var(--text-xs);
977
+ text-transform: uppercase;
978
+ letter-spacing: var(--tracking-wide);
979
+ /* Above the rendered section content (which is pointer-events:none)
980
+ but below the moves cluster. */
981
+ z-index: 2;
982
+ pointer-events: none;
983
+ /* Compactness: pill should read as ONE word "8/12" not a multi-line
984
+ wrap on narrow sections. */
985
+ white-space: nowrap;
986
+ }
987
+ .cpub-layout-section-span-pill--active {
988
+ background: var(--accent);
989
+ border-color: var(--accent);
990
+ color: var(--surface);
991
+ /* Subtle scale to draw the eye during the active resize. Skipped
992
+ under prefers-reduced-motion. */
993
+ transform: scale(1.05);
994
+ transition: transform 100ms ease-out;
995
+ }
996
+ .cpub-layout-section-span-pill--neighbour {
997
+ /* Dim variant — clearly visible but not competing with the active
998
+ pill's accent. Mirrors plan §7.5 "neighbour's pill 4/12, dimmed". */
999
+ opacity: 0.65;
1000
+ }
1001
+ @media (prefers-reduced-motion: reduce) {
1002
+ .cpub-layout-section-span-pill--active { transition: none; transform: none; }
1003
+ }
1004
+
1005
+ /* ------------------------------------------------------------------ */
1006
+ /* Phase 3c — Constraint-snap label. */
1007
+ /* Below the active pill, shows the bound the user pushed against. The */
1008
+ /* lock emoji + bound number give two more independent signals beyond */
1009
+ /* the handle's color change → three total per WCAG 1.4.1. */
1010
+ /* ------------------------------------------------------------------ */
1011
+ .cpub-layout-section-constraint-label {
1012
+ position: absolute;
1013
+ /* Below the span pill (which is at top:36 + 2+2*2+font-size ~24 tall
1014
+ = ~62). 64 leaves a 2px gap. */
1015
+ top: 64px;
1016
+ right: 2px;
1017
+ padding: 2px var(--space-2);
1018
+ background: var(--red, var(--accent));
1019
+ color: var(--surface);
1020
+ font-family: var(--font-mono);
1021
+ font-size: var(--text-xs);
1022
+ text-transform: uppercase;
1023
+ letter-spacing: var(--tracking-wide);
1024
+ z-index: 2;
1025
+ pointer-events: none;
1026
+ white-space: nowrap;
1027
+ }
1028
+ </style>