@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.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/package.json +11 -8
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- 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>
|