@commonpub/layer 0.23.3 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
@@ -0,0 +1,1110 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * /admin/layouts/[id] — editor shell (Phase 3a.3 + 3a.5).
4
+ *
5
+ * Three-column orchestrator with a sticky top toolbar:
6
+ * - Toolbar (3a.5): back-link + name + state + viewport segmented
7
+ * control + save indicator + Save/Publish buttons
8
+ * - Palette (left) — every registered section, grouped by category
9
+ * - Canvas (center) — <LayoutSlot :editable previewOverride=draft>
10
+ * - Inspector (right) — page-meta form (3a.4); section/row forms
11
+ * arrive alongside drag-drop in 3b/3f
12
+ *
13
+ * State lives in `useLayoutEditor(id)` — draft + original + dirty +
14
+ * save/publish/refresh/discard. Auto-save (3a.6) is wired through
15
+ * `useLayoutAutoSave` watching `editor.dirty`.
16
+ */
17
+ import type { LayoutRecord } from '@commonpub/server';
18
+ import { PublishStepError } from '../../../composables/useLayoutEditor';
19
+ import { useLayoutAnnouncer, narrateUndo, narrateRedo, narrateUndoEmpty, narrateRedoEmpty, narrateRowAdded, narrateRowRemoved } from '../../../composables/useLayoutAnnouncer';
20
+ import { useLayoutHistory, addRowCommand, removeRowCommand } from '../../../composables/useLayoutHistory';
21
+ import { useLayoutHotkeys } from '../../../composables/useLayoutHotkeys';
22
+ import { DnDProvider } from '@vue-dnd-kit/core';
23
+ import { useSectionRegistry } from '../../../sections/registry';
24
+ import { useLayoutResize } from '../../../composables/useLayoutResize';
25
+
26
+ definePageMeta({
27
+ layout: 'admin',
28
+ middleware: ['auth', 'admin-layouts'],
29
+ });
30
+
31
+ const route = useRoute();
32
+ const toast = useToast();
33
+ const id = computed<string>(() => String(route.params.id));
34
+
35
+ const editor = useLayoutEditor(id.value);
36
+ const history = useLayoutHistory();
37
+
38
+ // Phase 3b/B + 3d: window-level keyboard shortcuts. The composable
39
+ // attaches on mount + detaches on unmount; input/textarea/contenteditable
40
+ // focus skips so the browser's native text editing wins. Phase 3d adds:
41
+ // - Backspace / Delete = remove the selected section
42
+ // - Cmd/Ctrl+D = duplicate the selected section
43
+ // - ? = open the keyboard shortcuts help overlay
44
+ const helpOpen = ref<boolean>(false);
45
+ const sectionRegistry = useSectionRegistry();
46
+
47
+ /**
48
+ * Phase 3c — bounds lookup for the Shift+Arrow keyboard resize. Walks
49
+ * the live draft to find the section's host row + its right neighbour,
50
+ * then reads the registry for min/max colSpan. Returning null silences
51
+ * the binding for unresizable / unregistered sections.
52
+ *
53
+ * Lives at the editor page level (vs inside useLayoutHotkeys directly)
54
+ * because the closure needs the LIVE editor.draft + the registry —
55
+ * passing both as closures from setup mirrors the existing
56
+ * `getDraft` / `getSelection` pattern in `useLayoutHotkeys` and
57
+ * keeps the same semantic shape the LayoutRow's
58
+ * `resizeHandlerForSection` already uses.
59
+ */
60
+ function lookupResizeBounds(sectionId: string) {
61
+ const draft = editor.draft.value;
62
+ if (!draft) return null;
63
+ // Walk to find host row + index. Inline rather than importing
64
+ // findSectionLocation to keep the search self-contained at the editor
65
+ // page level + return the precise row id.
66
+ for (const zone of draft.zones) {
67
+ for (const row of zone.rows) {
68
+ const idx = row.sections.findIndex((s) => s.id === sectionId);
69
+ if (idx === -1) continue;
70
+ const section = row.sections[idx];
71
+ if (!section) continue;
72
+ const def = sectionRegistry.get(section.type);
73
+ if (!def || !def.resizable) return null;
74
+ const neighbourSection = idx < row.sections.length - 1
75
+ ? row.sections[idx + 1]
76
+ : null;
77
+ const neighbourDef = neighbourSection
78
+ ? sectionRegistry.get(neighbourSection.type)
79
+ : null;
80
+ // Session 166 round-2 audit P1: mirror LayoutRow's
81
+ // resizable:false-neighbour rule. If the neighbour explicitly
82
+ // opted out of resize, treat it as fixed-width (effective min =
83
+ // current colSpan) so the keyboard path can't shrink it either.
84
+ const neighbourFixed = !!neighbourSection && neighbourDef?.resizable === false;
85
+ const effectiveNeighbourMin = neighbourFixed
86
+ ? (neighbourSection?.colSpan ?? 1)
87
+ : (neighbourDef?.minColSpan ?? 1);
88
+ return {
89
+ sectionType: section.type,
90
+ rowId: row.id,
91
+ sectionMin: def.minColSpan,
92
+ sectionMax: def.maxColSpan,
93
+ neighbour: neighbourSection
94
+ ? {
95
+ sectionId: neighbourSection.id,
96
+ min: effectiveNeighbourMin,
97
+ max: neighbourDef?.maxColSpan ?? 12,
98
+ }
99
+ : null,
100
+ };
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ useLayoutHotkeys({
107
+ getDraft: () => editor.draft.value,
108
+ getSelection: () => editor.selectedId.value,
109
+ setSelection: (sel) => editor.select(sel),
110
+ onShowHelp: () => { helpOpen.value = true; },
111
+ lookupResizeBounds,
112
+ });
113
+
114
+ // Toolbar undo / redo emit handlers — wire to the same history singleton
115
+ // the hotkey uses + the same announcer narration. Tooltip text comes
116
+ // from `history.lastLabel` / `nextLabel` so the user can see WHICH
117
+ // command they're about to undo without taking action.
118
+ function onToolbarUndo(): void {
119
+ const draft = editor.draft.value;
120
+ if (!draft) return;
121
+ const ann = useLayoutAnnouncer();
122
+ const cmd = history.undo(draft);
123
+ ann.announcePolite(cmd ? narrateUndo(cmd.label) : narrateUndoEmpty());
124
+ }
125
+ function onToolbarRedo(): void {
126
+ const draft = editor.draft.value;
127
+ if (!draft) return;
128
+ const ann = useLayoutAnnouncer();
129
+ const cmd = history.redo(draft);
130
+ ann.announcePolite(cmd ? narrateRedo(cmd.label) : narrateRedoEmpty());
131
+ }
132
+
133
+ /**
134
+ * Session 164 polish — "+ Add row" handler. Closes the v1 blocker
135
+ * where a fresh layout (or one with an empty zone) had no drop target.
136
+ *
137
+ * Mutates draft directly so the existing dirty watcher fires +
138
+ * auto-save schedules. Records to history so Cmd+Z removes the row
139
+ * (the addRowCommand's invert handles this). Narrates via assertive
140
+ * channel — "Row added" is a state change like drag/drop, not
141
+ * informational like undo.
142
+ */
143
+ function onAddRow(zoneSlug: string): void {
144
+ const draft = editor.draft.value;
145
+ if (!draft) return;
146
+ const zone = draft.zones.find((z) => z.zone === zoneSlug);
147
+ if (!zone) return;
148
+ const newRow = {
149
+ id: crypto.randomUUID(),
150
+ order: zone.rows.length,
151
+ config: null,
152
+ sections: [],
153
+ };
154
+ const position = zone.rows.length;
155
+ zone.rows.push(newRow);
156
+ const ann = useLayoutAnnouncer();
157
+ ann.announce(narrateRowAdded(zoneSlug, position, zone.rows.length));
158
+ history.record(addRowCommand({
159
+ zoneSlug,
160
+ position,
161
+ row: newRow,
162
+ label: `add row to ${zoneSlug}`,
163
+ }));
164
+ }
165
+
166
+ /**
167
+ * Session 164 polish — Remove row. Confirm before removing rows with
168
+ * sections (destructive intent: section data goes away, only restorable
169
+ * via Cmd+Z within the same editor session). Empty rows skip confirm —
170
+ * they were the Add Row button's leftover dashed placeholder; removing
171
+ * is a recovery action, not a destruction.
172
+ */
173
+ function onRemoveRow(zoneSlug: string, rowId: string): void {
174
+ const draft = editor.draft.value;
175
+ if (!draft) return;
176
+ const zone = draft.zones.find((z) => z.zone === zoneSlug);
177
+ if (!zone) return;
178
+ const idx = zone.rows.findIndex((r) => r.id === rowId);
179
+ if (idx === -1) return;
180
+ const row = zone.rows[idx];
181
+ if (!row) return;
182
+ if (row.sections.length > 0) {
183
+ const sectionWord = row.sections.length === 1 ? 'section' : 'sections';
184
+ const ok = window.confirm(
185
+ `Remove this row and its ${row.sections.length} ${sectionWord}? `
186
+ + `Cmd+Z restores it within this session.`,
187
+ );
188
+ if (!ok) return;
189
+ }
190
+ const position = idx;
191
+ // Capture the row's full state BEFORE splice so the command's invert
192
+ // can restore sections + config too.
193
+ const rowClone = JSON.parse(JSON.stringify(row));
194
+ zone.rows.splice(idx, 1);
195
+ const ann = useLayoutAnnouncer();
196
+ ann.announce(narrateRowRemoved(zoneSlug));
197
+ history.record(removeRowCommand({
198
+ zoneSlug,
199
+ position,
200
+ row: rowClone,
201
+ label: `remove row from ${zoneSlug}`,
202
+ }));
203
+ // Clear selection if the removed row contained the currently-selected
204
+ // section/row (it's no longer in draft).
205
+ const sel = editor.selectedId.value;
206
+ if (sel) {
207
+ if (sel.kind === 'row' && sel.id === rowId) editor.clearSelection();
208
+ else if (sel.kind === 'section' && rowClone.sections.some((s: { id: string }) => s.id === sel.id)) {
209
+ editor.clearSelection();
210
+ }
211
+ }
212
+ }
213
+
214
+ // Palette + inspector visibility — persists per-admin via cookie so the
215
+ // admin's last layout (e.g. "I always work with inspector hidden, palette
216
+ // visible") sticks across sessions. Session 161 user-reported squish fix.
217
+ const chrome = useEditorChrome();
218
+
219
+ // SSR-prime: fetch the layout via useFetch (hydration-safe), then
220
+ // hand it to the editor composable. The composable also exposes
221
+ // refresh() for client-only re-fetches (after publish, etc).
222
+ const { data: initial, error } = await useFetch<LayoutRecord>(
223
+ `/api/admin/layouts/${id.value}`,
224
+ );
225
+ if (initial.value) {
226
+ editor.original.value = initial.value;
227
+ editor.draft.value = JSON.parse(JSON.stringify(initial.value));
228
+ }
229
+
230
+ // Phase 3b/B: clear undo history at seed time. The history singleton is
231
+ // module-scoped, so opening a different layout could otherwise inherit
232
+ // the previous editor's stack — Cmd+Z would undo into the wrong draft.
233
+ // Same rule applies on refresh + save success (handled below via watch).
234
+ history.clear();
235
+
236
+ useSeoMeta({
237
+ title: () => `Edit: ${editor.draft.value?.name ?? 'Layout'} — Admin — ${useSiteName()}`,
238
+ });
239
+
240
+ // Viewport preview state — purely UI; doesn't mutate the layout.
241
+ const viewport = ref<'mobile' | 'tablet' | 'desktop'>('desktop');
242
+
243
+ // Conflict modal visibility — flips true when save() returns 409.
244
+ const conflictOpen = ref<boolean>(false);
245
+
246
+ // R4 audit P1 fix: unsaved-edit guards. Without these, the user can
247
+ // navigate (back button, sidebar nav, typed URL) between the last edit
248
+ // and the 1500ms debounce firing → silent data loss. visibilitychange
249
+ // flush handles Cmd+Tab/minimize but NOT in-app navigation.
250
+ //
251
+ // Three guards layered for the unique failure modes each covers:
252
+ // 1. onBeforeRouteLeave — fires on Nuxt navigation (sidebar links,
253
+ // router.push, NuxtLink). Confirms with the user; if they cancel,
254
+ // navigation aborts and they stay on the editor.
255
+ // 2. beforeunload — fires on tab close, reload, or external nav.
256
+ // Modern browsers ignore the message string and show their generic
257
+ // prompt; setting preventDefault is enough to trigger it. Does
258
+ // NOT fire on iOS Safari.
259
+ // 3. pagehide → editor.flushBeacon() — session 162 P2.3. The only
260
+ // event that fires reliably on tab-close + bfcache eviction +
261
+ // iOS Safari. Sends the unsaved draft via fetch(keepalive:true)
262
+ // so it survives page teardown when the user closes the tab
263
+ // inside the auto-save debounce window.
264
+ function onBeforeUnload(e: BeforeUnloadEvent): void {
265
+ if (editor.dirty.value) {
266
+ e.preventDefault();
267
+ // Some browsers still read returnValue; set for compatibility.
268
+ e.returnValue = '';
269
+ }
270
+ }
271
+ function onPageHide(): void {
272
+ // Fire-and-forget — the page may be teardowning RIGHT NOW. The
273
+ // beacon's keepalive flag is what makes the request survive.
274
+ editor.flushBeacon();
275
+ }
276
+ onMounted(() => {
277
+ if (typeof window !== 'undefined') {
278
+ window.addEventListener('beforeunload', onBeforeUnload);
279
+ window.addEventListener('pagehide', onPageHide);
280
+ }
281
+ });
282
+ onBeforeUnmount(() => {
283
+ if (typeof window !== 'undefined') {
284
+ window.removeEventListener('beforeunload', onBeforeUnload);
285
+ window.removeEventListener('pagehide', onPageHide);
286
+ }
287
+ // R4 P2 (session 161): cancel any in-flight save. Without this, a save
288
+ // started before unmount lands afterward as an "orphan" PUT — which can
289
+ // cause stale 409s the next time the user opens the editor in another
290
+ // tab (server bumped updatedAt; client's cached If-Match is stale).
291
+ editor.abort();
292
+ // Session 163 deep audit: useLayoutAnnouncer is a module-scope
293
+ // singleton with a 1.2s auto-clear setTimeout. Without explicit
294
+ // clear() on unmount, the message + pending timer leak across editor
295
+ // mounts — closing layout A while a Move announcement is mid-cycle
296
+ // would show that stale message on the next editor open. Two agents
297
+ // independently caught this (Agent A + Agent B).
298
+ useLayoutAnnouncer().clear();
299
+ // Session 166 R3-6 audit: useLayoutResize is also a module-scope
300
+ // singleton. If the user navigates away mid-drag (e.g. unsaved-edit
301
+ // guard accepts), the resize state stays 'resizing' across editor
302
+ // mounts. The composable's defensive recovery in startResize would
303
+ // commit-and-replace, but until then the state is stale + the
304
+ // document handlers are still attached. Explicit cancel matches the
305
+ // announcer pattern + closes the leak fully.
306
+ useLayoutResize().cancelResize();
307
+ });
308
+ onBeforeRouteLeave((_to, _from, next) => {
309
+ if (!editor.dirty.value) return next();
310
+ const ok = window.confirm(
311
+ 'You have unsaved changes that haven’t auto-saved yet. Leave anyway?',
312
+ );
313
+ return next(ok);
314
+ });
315
+
316
+ // Auto-save: watches editor.dirty, debounces 1.5s, calls editor.save().
317
+ // Composable handles unmount cleanup. Session 162 P2.5: pause when the
318
+ // conflict rate exceeds the threshold (3 in 60s) so we stop banging
319
+ // the server while the user reconciles with the other editor; the
320
+ // banner below surfaces this state with a Resume button.
321
+ useLayoutAutoSave({
322
+ dirty: editor.dirty,
323
+ save: () => editor.save(),
324
+ debounceMs: 1500,
325
+ paused: computed(() => editor.conflictThrashing.value),
326
+ });
327
+
328
+ // Surface conflicts from any save (manual or auto) as the modal —
329
+ // EXCEPT when we've already crossed into thrashing. At that point the
330
+ // banner is the single reconciliation surface; the modal on top would
331
+ // be redundant (same actions, more visual noise).
332
+ //
333
+ // Session 165 round 5 — dual-modal coordination: if the user has the
334
+ // keyboard-shortcuts help overlay open and a 409 fires (e.g. auto-save
335
+ // debounce landed on a conflict mid-read), close help so the conflict
336
+ // resolution dialog owns focus exclusively. Both modals have focus
337
+ // traps; without this, the briefly-overlapping mount window would
338
+ // ping-pong focus between them. The topmost-only guard inside each
339
+ // modal's trap covers the brief mount window; this closes the window
340
+ // fully by making the lower modal go away.
341
+ watch(editor.status, (status) => {
342
+ if (status === 'conflict' && !editor.conflictThrashing.value) {
343
+ conflictOpen.value = true;
344
+ helpOpen.value = false;
345
+ }
346
+ });
347
+
348
+ /*
349
+ * Phase 3b/B + R2 audit P1 fix: history clears when the SERVER baseline
350
+ * changes (refresh, save success) AND the local draft matches that
351
+ * baseline (dirty=false). One watcher, one gate.
352
+ *
353
+ * Why dirty-gated: the original status-transition approach cleared on
354
+ * every saving→saved transition, but that's wrong when the user undid
355
+ * MID-SAVE. Concretely: save kicks off at t=1500ms; user undos at
356
+ * t=1800ms; save completes at t=2200ms (with the pre-undo snapshot
357
+ * persisted). After save: original = pre-undo state; draft = post-undo
358
+ * state; dirty = TRUE. Clearing history here would nuke the redo branch
359
+ * for an undo that hasn't actually been persisted yet. The dirty gate
360
+ * ensures we only clear when the LOCAL state ALSO matches the server.
361
+ *
362
+ * Why one watcher instead of two: save() reassigns original AND
363
+ * transitions status; refresh() reassigns original AND resets status to
364
+ * 'idle'. Both flow through the original-change. The previous dual-
365
+ * watcher setup fired clear() twice on save (idempotent but indicates
366
+ * architectural confusion).
367
+ *
368
+ * discard() doesn't change original (just draft); its explicit
369
+ * history.clear() call in onDiscard covers that path.
370
+ */
371
+ watch(() => editor.original.value, (newOriginal, oldOriginal) => {
372
+ if (oldOriginal === null) return; // initial seed; handled at await time
373
+ if (newOriginal === oldOriginal) return;
374
+ // The server baseline changed. If the local draft has un-saved edits
375
+ // on top of it, keep history so the user can still undo them. If
376
+ // draft is in lockstep with original, clear (saved baseline is the
377
+ // new ground truth per plan §7.14).
378
+ if (!editor.dirty.value) {
379
+ history.clear();
380
+ }
381
+ });
382
+
383
+ // And: if thrashing trips while the modal is open (the 3rd conflict
384
+ // arrives mid-modal), close the modal so the banner is the only surface.
385
+ // Focus the banner's safe recommended action AFTER the modal unmounts +
386
+ // banner mounts — without this, the previously-focused modal button
387
+ // disappears and focus falls back to <body>, stranding keyboard users.
388
+ // Only steal focus when the modal WAS open; otherwise the banner's
389
+ // role="alert" announces it without disrupting wherever the user was.
390
+ const thrashPrimaryBtn = ref<HTMLButtonElement | null>(null);
391
+ watch(() => editor.conflictThrashing.value, async (thrashing) => {
392
+ if (!thrashing) return;
393
+ if (!conflictOpen.value) return;
394
+ conflictOpen.value = false;
395
+ await nextTick();
396
+ thrashPrimaryBtn.value?.focus();
397
+ });
398
+
399
+ function onResumeAutoSave(): void {
400
+ editor.clearConflictHistory();
401
+ toast.success('Auto-save resumed');
402
+ }
403
+
404
+ function onPageMetaUpdate(value: LayoutRecord['pageMeta']): void {
405
+ if (!editor.draft.value) return;
406
+ editor.draft.value.pageMeta = value;
407
+ }
408
+ function onNameUpdate(value: string): void {
409
+ if (!editor.draft.value) return;
410
+ editor.draft.value.name = value;
411
+ }
412
+
413
+ /**
414
+ * Phase 3e — section/row config edits from the inspector. Locate the
415
+ * target by id in the live draft + replace its `config` object. The deep
416
+ * `draft` watcher fires → `dirty` flips → the existing 1.5s auto-save
417
+ * debounce persists (single-flight save untouched). Config edits are not
418
+ * yet recorded to the undo stack — plan §7.14's `edit-section-config` op
419
+ * is a follow-up (Phase 3f); page-meta edits have the same gap today.
420
+ */
421
+ function onSectionConfigUpdate(payload: { id: string; config: Record<string, unknown> }): void {
422
+ const draft = editor.draft.value;
423
+ if (!draft) return;
424
+ for (const zone of draft.zones) {
425
+ for (const row of zone.rows) {
426
+ const section = row.sections.find((s) => s.id === payload.id);
427
+ if (section) {
428
+ section.config = payload.config;
429
+ return;
430
+ }
431
+ }
432
+ }
433
+ }
434
+ function onRowConfigUpdate(payload: { id: string; config: Record<string, unknown> }): void {
435
+ const draft = editor.draft.value;
436
+ if (!draft) return;
437
+ for (const zone of draft.zones) {
438
+ const row = zone.rows.find((r) => r.id === payload.id);
439
+ if (row) {
440
+ row.config = payload.config;
441
+ return;
442
+ }
443
+ }
444
+ }
445
+
446
+ async function onSave(): Promise<void> {
447
+ try {
448
+ await editor.save();
449
+ toast.success('Layout saved');
450
+ } catch (err) {
451
+ const e = err as { statusCode?: number; statusMessage?: string };
452
+ if (e.statusCode === 409) {
453
+ // Modal is already open via the status watcher; no toast (modal is louder).
454
+ return;
455
+ }
456
+ toast.error(e.statusMessage ?? 'Save failed');
457
+ }
458
+ }
459
+
460
+ function onDiscard(): void {
461
+ // R4 audit P2 fix: surfaces discard() to the UI. Confirms first since
462
+ // discard is destructive (loses unsaved edits).
463
+ if (!editor.dirty.value) return;
464
+ if (!confirm('Discard all unsaved changes? This cannot be undone.')) return;
465
+ editor.discard();
466
+ // Phase 3b/B: discard replaces `draft` with a clone of `original`. The
467
+ // commands in the past stack reference sections + positions that may
468
+ // not exist in the discarded-from state; an undo would re-apply
469
+ // operations that "discard" effectively rolled back. The confirm
470
+ // dialog already warned this is destructive — undo across discard
471
+ // would be more surprising, not less. So clear.
472
+ history.clear();
473
+ toast.success('Unsaved changes discarded');
474
+ }
475
+
476
+ async function onPublish(): Promise<void> {
477
+ if (!confirm('Publish this layout? The current draft replaces the live version.')) return;
478
+ try {
479
+ await editor.publish();
480
+ toast.success('Layout published');
481
+ } catch (err) {
482
+ // Session 162 P2.7: surface WHICH step failed so the admin knows
483
+ // whether their changes are safely saved or lost. Generic
484
+ // "Publish failed" hid the save-succeeded-publish-failed case.
485
+ if (err instanceof PublishStepError) {
486
+ const causeMsg = (err.cause as { statusMessage?: string })?.statusMessage;
487
+ switch (err.step) {
488
+ case 'save':
489
+ toast.error(causeMsg
490
+ ? `Could not save your edits (${causeMsg}). Nothing was published.`
491
+ : 'Could not save your edits. Nothing was published.');
492
+ return;
493
+ case 'publish':
494
+ toast.error(
495
+ 'Your changes are saved as a draft, but publish failed. ' +
496
+ 'Try Publish again — the saved draft is durable.',
497
+ );
498
+ return;
499
+ case 'refresh':
500
+ // The publish succeeded on the server; only the local view
501
+ // is stale. The next save / publish picks up correctly; a
502
+ // reload syncs immediately.
503
+ toast.show(
504
+ 'Published — but the editor view is stale. Reload to sync.',
505
+ );
506
+ return;
507
+ }
508
+ }
509
+ const e = err as { statusMessage?: string };
510
+ toast.error(e.statusMessage ?? 'Publish failed');
511
+ }
512
+ }
513
+
514
+ async function onConflictRefresh(): Promise<void> {
515
+ conflictOpen.value = false;
516
+ try {
517
+ await editor.refresh();
518
+ // Refresh = explicit reconciliation (admin took the other version).
519
+ // Clear the throttle so auto-save resumes; if cascade really
520
+ // persists, the rolling-window will trip again on its own.
521
+ editor.clearConflictHistory();
522
+ toast.success('Refreshed — server state loaded');
523
+ } catch (err) {
524
+ const e = err as { statusMessage?: string };
525
+ toast.error(e.statusMessage ?? 'Refresh failed');
526
+ }
527
+ }
528
+
529
+ async function onConflictForceSave(): Promise<void> {
530
+ conflictOpen.value = false;
531
+ try {
532
+ await editor.save({ force: true });
533
+ // Force save = explicit reconciliation (admin overwrote with their
534
+ // version). Same rationale as Refresh — resume auto-save.
535
+ editor.clearConflictHistory();
536
+ toast.success('Layout force-saved');
537
+ } catch (err) {
538
+ const e = err as { statusMessage?: string };
539
+ toast.error(e.statusMessage ?? 'Force save failed');
540
+ }
541
+ }
542
+ </script>
543
+
544
+ <template>
545
+ <div class="cpub-admin-layouts-editor">
546
+ <div v-if="error" class="cpub-admin-layouts-editor-error">
547
+ <i class="fa-solid fa-circle-exclamation"></i>
548
+ <p>Failed to load layout. <NuxtLink to="/admin/layouts">Back to layouts</NuxtLink></p>
549
+ </div>
550
+
551
+ <template v-else>
552
+ <AdminLayoutsToolbar
553
+ :layout-name="editor.draft.value?.name ?? ''"
554
+ :state="editor.draft.value?.state ?? 'draft'"
555
+ :viewport="viewport"
556
+ :save-status="editor.status.value"
557
+ :dirty="editor.dirty.value"
558
+ :error-message="editor.errorMessage.value"
559
+ :last-saved-at="editor.original.value?.updatedAt ?? null"
560
+ :can-undo="history.canUndo.value"
561
+ :can-redo="history.canRedo.value"
562
+ :undo-label="history.lastLabel.value"
563
+ :redo-label="history.nextLabel.value"
564
+ @update:viewport="viewport = $event"
565
+ @save="onSave"
566
+ @publish="onPublish"
567
+ @discard="onDiscard"
568
+ @undo="onToolbarUndo"
569
+ @redo="onToolbarRedo"
570
+ />
571
+
572
+ <!--
573
+ Session 162 P2.5: conflict-thrash banner. Shows when 3+ saves
574
+ have 409'd within the last 60s — auto-save is now paused so the
575
+ page stops banging the server while the admin reconciles. The
576
+ existing AdminLayoutsConflictModal handles the per-conflict UX;
577
+ this banner is the layer above, addressing the cascade pattern.
578
+ role="alert" + aria-live="assertive" so screen readers announce
579
+ the pause immediately (it changes the editor's autosave contract).
580
+
581
+ Audit fix: the three actions promised in the body copy
582
+ (Refresh / Force save / Resume) all render as inline buttons so
583
+ the admin can reconcile without first triggering a save to surface
584
+ the modal. Reuses the same handlers as the conflict modal.
585
+ -->
586
+ <div
587
+ v-if="editor.conflictThrashing.value"
588
+ class="cpub-admin-layouts-editor-thrash"
589
+ role="alert"
590
+ aria-live="assertive"
591
+ aria-atomic="true"
592
+ >
593
+ <i class="fa-solid fa-triangle-exclamation cpub-admin-layouts-editor-thrash-icon" aria-hidden="true"></i>
594
+ <div class="cpub-admin-layouts-editor-thrash-body">
595
+ <strong>Auto-save paused</strong>
596
+ <span>
597
+ Three of your recent saves collided with another admin's
598
+ edits. Reload their version (recommended) — your edits will
599
+ be lost. Overwriting their changes is destructive and final.
600
+ </span>
601
+ </div>
602
+ <!--
603
+ Button hierarchy matches AdminLayoutsConflictModal verbatim
604
+ (session 160 R1 audit established this discipline): primary
605
+ accent = SAFE recommended action, neutral default = middle
606
+ option, danger red = destructive action LAST in tab order so
607
+ keyboard users don't land on it.
608
+ Banner-specific: "Resume auto-save" replaces the modal's
609
+ "Keep editing here" — same neutral level, different semantic
610
+ (banner's middle option turns auto-save back on without
611
+ reconciliation; modal's middle option closes the modal).
612
+ -->
613
+ <div class="cpub-admin-layouts-editor-thrash-actions">
614
+ <button
615
+ ref="thrashPrimaryBtn"
616
+ type="button"
617
+ class="cpub-admin-layouts-editor-thrash-btn cpub-admin-layouts-editor-thrash-btn--primary"
618
+ @click="onConflictRefresh"
619
+ >
620
+ <i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i>
621
+ Reload their version
622
+ </button>
623
+ <button
624
+ type="button"
625
+ class="cpub-admin-layouts-editor-thrash-btn"
626
+ @click="onResumeAutoSave"
627
+ >
628
+ <i class="fa-solid fa-play" aria-hidden="true"></i>
629
+ Resume auto-save
630
+ </button>
631
+ <button
632
+ type="button"
633
+ class="cpub-admin-layouts-editor-thrash-btn cpub-admin-layouts-editor-thrash-btn--danger"
634
+ @click="onConflictForceSave"
635
+ >
636
+ <i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i>
637
+ Overwrite their changes
638
+ </button>
639
+ </div>
640
+ </div>
641
+
642
+ <!--
643
+ Round-3 audit fix: phone (≤640px) sees a single banner instead
644
+ of the editor. Drag-drop on a 375px viewport is user-hostile
645
+ regardless of how well-designed — matches docs/plans/layout-and-pages.md §7.7.
646
+ Note: the @media rule uses `max-width: 640px` (inclusive), so
647
+ a viewport at exactly 640px sees the banner — comment matches.
648
+ -->
649
+ <div class="cpub-admin-layouts-editor-phone-only">
650
+ <i class="fa-solid fa-display cpub-admin-layouts-editor-phone-icon" aria-hidden="true"></i>
651
+ <h2>Use a larger screen</h2>
652
+ <p>The layout editor needs a tablet or desktop viewport (640px or wider).</p>
653
+ <NuxtLink to="/admin/layouts" class="cpub-admin-layouts-editor-phone-back">← Back to Layouts</NuxtLink>
654
+ </div>
655
+
656
+ <!--
657
+ Phase 3b/A: DnDProvider is the drag-drop root. ONE provider per
658
+ editor; ALL draggables (palette tiles) + droppables (rows / zones)
659
+ must be inside this subtree so dnd-kit's collision detection +
660
+ keyboard sensor can see them as a single namespace.
661
+ Wraps palette + canvas + inspector together so drag-from-palette
662
+ → drop-on-canvas works without crossing a provider boundary.
663
+ Per the package's external API verified at session 162 close:
664
+ - keyboard sensor auto-attaches to document on mount (Space/Arrow/Esc)
665
+ - `previewTo='body'` teleports the drag preview to <body> so it
666
+ escapes any overflow:hidden ancestor + stays above the chrome
667
+ Click-outside the body clears selection (the inspector then
668
+ falls back to the page-meta form per §7.9 dispatch pattern).
669
+ -->
670
+ <!--
671
+ Phase 3b/A: SR narration channel — a singleton aria-live region
672
+ that <LayoutSection> + <LayoutRow> mirror drag/drop + Move
673
+ Up/Down events into. dnd-kit ships no announcer OOTB; this
674
+ closes the WCAG 2.1.1 gap. Mounted ONCE outside the
675
+ DnDProvider so it survives the inner reactivity churn.
676
+ -->
677
+ <AdminLayoutsAnnouncer />
678
+
679
+ <DnDProvider
680
+ preview-to="body"
681
+ class="cpub-admin-layouts-editor-dnd"
682
+ @click.self="editor.clearSelection"
683
+ >
684
+ <div
685
+ class="cpub-admin-layouts-editor-body"
686
+ :class="{
687
+ 'cpub-admin-layouts-editor-body--palette-hidden': chrome.paletteHidden.value,
688
+ 'cpub-admin-layouts-editor-body--inspector-hidden': chrome.inspectorHidden.value,
689
+ }"
690
+ >
691
+ <!-- Tablet/phone collapse: canvas FIRST so the surface admin came
692
+ for is immediately visible; palette + inspector stack below.
693
+ (Pre-audit ordering put palette first → admin had to scroll
694
+ past 17 tiles to reach the canvas.)
695
+ v-show on palette + inspector (not v-if) preserves component
696
+ state — scroll position, focused field — across hide/show. -->
697
+ <AdminLayoutsCanvas
698
+ :layout="editor.draft.value"
699
+ :viewport="viewport"
700
+ :on-select="editor.select"
701
+ :selected-id="editor.selectedId.value"
702
+ :on-add-row="onAddRow"
703
+ :on-remove-row="onRemoveRow"
704
+ />
705
+ <AdminLayoutsPalette v-show="!chrome.paletteHidden.value" />
706
+ <AdminLayoutsInspector
707
+ v-show="!chrome.inspectorHidden.value"
708
+ :draft="editor.draft.value"
709
+ :selection="editor.selectedId.value"
710
+ @update:page-meta="onPageMetaUpdate"
711
+ @update:name="onNameUpdate"
712
+ @update:section-config="onSectionConfigUpdate"
713
+ @update:row-config="onRowConfigUpdate"
714
+ />
715
+
716
+ <!--
717
+ Session 164 polish: edge tab toggles for palette + inspector.
718
+ Move-on-collapse pattern (Notion / Linear / Cursor): when the
719
+ panel is visible the tab sits at the panel's outer edge; when
720
+ the panel is collapsed the tab sits at the screen edge,
721
+ inviting expansion. The chevron icon tells the direction.
722
+
723
+ Placed INSIDE editor-body (which is position:relative) so
724
+ absolute positioning anchors to it. v-show on the panels
725
+ preserves their state across toggles; the tabs themselves
726
+ are always visible in editable mode.
727
+
728
+ Hidden on mobile/tablet (< 1024px) where the body falls
729
+ back to a single column DOM-order stack — the toggles
730
+ would float over content with no panel to collapse.
731
+ -->
732
+ <button
733
+ type="button"
734
+ class="cpub-admin-layouts-editor-edge-tab cpub-admin-layouts-editor-edge-tab--left"
735
+ :class="{ 'cpub-admin-layouts-editor-edge-tab--collapsed': chrome.paletteHidden.value }"
736
+ :aria-label="chrome.paletteHidden.value ? 'Show sections panel' : 'Hide sections panel'"
737
+ :aria-pressed="!chrome.paletteHidden.value"
738
+ :title="chrome.paletteHidden.value ? 'Show sections panel' : 'Hide sections panel'"
739
+ @click="chrome.togglePalette"
740
+ >
741
+ <i :class="chrome.paletteHidden.value ? 'fa-solid fa-angles-right' : 'fa-solid fa-angles-left'" aria-hidden="true"></i>
742
+ </button>
743
+ <button
744
+ type="button"
745
+ class="cpub-admin-layouts-editor-edge-tab cpub-admin-layouts-editor-edge-tab--right"
746
+ :class="{ 'cpub-admin-layouts-editor-edge-tab--collapsed': chrome.inspectorHidden.value }"
747
+ :aria-label="chrome.inspectorHidden.value ? 'Show inspector panel' : 'Hide inspector panel'"
748
+ :aria-pressed="!chrome.inspectorHidden.value"
749
+ :title="chrome.inspectorHidden.value ? 'Show inspector panel' : 'Hide inspector panel'"
750
+ @click="chrome.toggleInspector"
751
+ >
752
+ <i :class="chrome.inspectorHidden.value ? 'fa-solid fa-angles-left' : 'fa-solid fa-angles-right'" aria-hidden="true"></i>
753
+ </button>
754
+ </div>
755
+ </DnDProvider>
756
+
757
+ <AdminLayoutsConflictModal
758
+ :open="conflictOpen"
759
+ :message="editor.errorMessage.value"
760
+ @refresh="onConflictRefresh"
761
+ @force-save="onConflictForceSave"
762
+ @close="conflictOpen = false"
763
+ />
764
+ <!-- Phase 3d.3 — keyboard shortcut help overlay. Opens on `?`
765
+ via useLayoutHotkeys.onShowHelp; Esc / backdrop click / Close
766
+ button dismiss. Read-only; no editor state mutation. -->
767
+ <AdminLayoutsHelpOverlay
768
+ :open="helpOpen"
769
+ @close="helpOpen = false"
770
+ />
771
+ </template>
772
+ </div>
773
+ </template>
774
+
775
+ <style scoped>
776
+ .cpub-admin-layouts-editor {
777
+ display: flex;
778
+ flex-direction: column;
779
+ /* The admin layout (.admin-main) wraps us in `padding: var(--space-6)`,
780
+ which would inset the editor + cause its 100vh-based height to
781
+ overflow the viewport. Suck up to the parent padding edges so the
782
+ editor reads as full-bleed inside the admin chrome. */
783
+ margin: calc(var(--space-6) * -1);
784
+ height: calc(100vh - var(--admin-topbar-height, 56px));
785
+ min-height: 600px;
786
+ }
787
+
788
+ .cpub-admin-layouts-editor-error {
789
+ display: flex;
790
+ flex-direction: column;
791
+ align-items: center;
792
+ gap: var(--space-3);
793
+ padding: var(--space-8);
794
+ color: var(--text-dim);
795
+ }
796
+ .cpub-admin-layouts-editor-error a { color: var(--accent); text-decoration: underline; }
797
+
798
+ /* Session 162 P2.5 conflict-thrash banner. Audit fix: the original
799
+ --warning token didn't exist in the theme system → fell back to
800
+ surface2 which read as a neutral box, not alert. Now uses the
801
+ established --yellow-bg / --yellow-border tokens (defined on every
802
+ theme — base.css line 70-71 + all variants) that other "attention"
803
+ surfaces in the layer use. Sits between toolbar + body so it's
804
+ visible regardless of canvas scroll. */
805
+ .cpub-admin-layouts-editor-thrash {
806
+ display: flex;
807
+ align-items: center;
808
+ gap: var(--space-3);
809
+ padding: var(--space-3) var(--space-4);
810
+ background: var(--yellow-bg);
811
+ color: var(--text);
812
+ border-bottom: var(--border-width-default) solid var(--yellow-border);
813
+ flex-shrink: 0;
814
+ }
815
+ .cpub-admin-layouts-editor-thrash-icon {
816
+ color: var(--yellow);
817
+ font-size: var(--text-lg);
818
+ flex-shrink: 0;
819
+ }
820
+ .cpub-admin-layouts-editor-thrash-body {
821
+ display: flex;
822
+ flex-direction: column;
823
+ gap: 2px;
824
+ flex: 1;
825
+ min-width: 0;
826
+ }
827
+ .cpub-admin-layouts-editor-thrash-body strong {
828
+ font-family: var(--font-mono);
829
+ font-size: var(--text-xs);
830
+ text-transform: uppercase;
831
+ letter-spacing: var(--tracking-wide);
832
+ }
833
+ .cpub-admin-layouts-editor-thrash-body span {
834
+ font-size: var(--text-sm);
835
+ color: var(--text-dim);
836
+ }
837
+ .cpub-admin-layouts-editor-thrash-actions {
838
+ display: flex;
839
+ gap: var(--space-2);
840
+ flex-shrink: 0;
841
+ }
842
+ .cpub-admin-layouts-editor-thrash-btn {
843
+ padding: var(--space-1) var(--space-3);
844
+ background: var(--surface);
845
+ border: var(--border-width-default) solid var(--border);
846
+ color: var(--text);
847
+ font-family: var(--font-mono);
848
+ font-size: var(--text-xs);
849
+ text-transform: uppercase;
850
+ letter-spacing: var(--tracking-wide);
851
+ cursor: pointer;
852
+ }
853
+ .cpub-admin-layouts-editor-thrash-btn:hover { background: var(--surface2); }
854
+ .cpub-admin-layouts-editor-thrash-btn:focus-visible {
855
+ outline: 2px solid var(--accent);
856
+ outline-offset: 2px;
857
+ }
858
+ /* Hierarchy matches AdminLayoutsConflictModal's btn--primary +
859
+ btn--danger so the cognitive model for resolving a conflict is the
860
+ same whether the admin meets the modal first or the cascade banner
861
+ first (session 162 audit-on-audit fix). */
862
+ .cpub-admin-layouts-editor-thrash-btn--primary {
863
+ background: var(--accent);
864
+ border-color: var(--accent);
865
+ color: var(--surface);
866
+ }
867
+ .cpub-admin-layouts-editor-thrash-btn--primary:hover { filter: brightness(1.1); background: var(--accent); }
868
+ .cpub-admin-layouts-editor-thrash-btn--danger {
869
+ color: var(--red);
870
+ border-color: var(--red);
871
+ }
872
+ .cpub-admin-layouts-editor-thrash-btn--danger:hover {
873
+ background: var(--red);
874
+ color: var(--surface);
875
+ }
876
+
877
+ @media (max-width: 1024px) {
878
+ /* Wrap the action buttons under the body on tablet/mobile so they
879
+ don't squish the message. */
880
+ .cpub-admin-layouts-editor-thrash {
881
+ flex-wrap: wrap;
882
+ align-items: flex-start;
883
+ }
884
+ .cpub-admin-layouts-editor-thrash-body { flex-basis: 100%; }
885
+ .cpub-admin-layouts-editor-thrash-actions { flex-basis: 100%; justify-content: flex-end; }
886
+ }
887
+
888
+ /* Phase 3b/A — DnDProvider sits between the editor wrapper and the
889
+ body grid. Without explicit dimensions it would collapse and the
890
+ body grid loses its height. Mirrors the body's flex behavior so
891
+ the provider is layout-transparent. */
892
+ .cpub-admin-layouts-editor-dnd {
893
+ display: flex;
894
+ flex-direction: column;
895
+ flex: 1;
896
+ min-height: 0;
897
+ }
898
+ .cpub-admin-layouts-editor-dnd > .cpub-admin-layouts-editor-body {
899
+ flex: 1;
900
+ min-height: 0;
901
+ }
902
+
903
+ .cpub-admin-layouts-editor-body {
904
+ display: grid;
905
+ /* DOM order: canvas, palette, inspector. CSS grid-template-areas
906
+ places them visually palette / canvas / inspector at >=1024px. */
907
+ grid-template-columns: 280px 1fr 320px;
908
+ grid-template-areas: 'palette canvas inspector';
909
+ flex: 1;
910
+ min-height: 0;
911
+ /* Session 164: positions the edge-tab toggles anchored to the body's
912
+ left/right boundaries. The tabs use absolute positioning relative
913
+ to this container. */
914
+ position: relative;
915
+ }
916
+ .cpub-admin-layouts-editor-body > :nth-child(1) { grid-area: canvas; } /* canvas (1st in DOM) */
917
+ .cpub-admin-layouts-editor-body > :nth-child(2) { grid-area: palette; } /* palette (2nd in DOM) */
918
+ .cpub-admin-layouts-editor-body > :nth-child(3) { grid-area: inspector; } /* inspector (3rd in DOM) */
919
+
920
+ /* Session 161: hide-palette / hide-inspector grid reflow. Removes the
921
+ panel column entirely (vs display:none on the child, which would
922
+ leave the grid column reserved as empty space). v-show on the panel
923
+ element keeps it in the DOM so component state (scroll, focus,
924
+ active field) survives toggling. */
925
+ .cpub-admin-layouts-editor-body--palette-hidden {
926
+ grid-template-columns: 1fr 320px;
927
+ grid-template-areas: 'canvas inspector';
928
+ }
929
+ .cpub-admin-layouts-editor-body--inspector-hidden {
930
+ grid-template-columns: 280px 1fr;
931
+ grid-template-areas: 'palette canvas';
932
+ }
933
+ .cpub-admin-layouts-editor-body--palette-hidden.cpub-admin-layouts-editor-body--inspector-hidden {
934
+ grid-template-columns: 1fr;
935
+ grid-template-areas: 'canvas';
936
+ }
937
+
938
+ @media (max-width: 1280px) {
939
+ .cpub-admin-layouts-editor-body { grid-template-columns: 240px 1fr 280px; }
940
+ .cpub-admin-layouts-editor-body--palette-hidden { grid-template-columns: 1fr 280px; }
941
+ .cpub-admin-layouts-editor-body--inspector-hidden { grid-template-columns: 240px 1fr; }
942
+ .cpub-admin-layouts-editor-body--palette-hidden.cpub-admin-layouts-editor-body--inspector-hidden { grid-template-columns: 1fr; }
943
+ }
944
+
945
+ @media (max-width: 1024px) {
946
+ /* On tablet, fall back to DOM-order single column (canvas first,
947
+ palette next, inspector last) — admin sees the editing surface
948
+ immediately without scrolling past the palette. v1 doesn't ship
949
+ bottom-sheet behavior (Phase 6a). */
950
+ .cpub-admin-layouts-editor-body {
951
+ grid-template-columns: 1fr;
952
+ grid-template-areas: none;
953
+ }
954
+ .cpub-admin-layouts-editor-body > * { grid-area: auto; }
955
+ }
956
+
957
+ /* ------------------------------------------------------------------ */
958
+ /* Session 164 polish: panel edge-tab toggles. */
959
+ /* */
960
+ /* Replaces the toolbar palette/inspector buttons (user-reported as */
961
+ /* non-obvious). Tabs sit at the panel/canvas boundary when the panel */
962
+ /* is visible, and AT the screen edge when the panel is collapsed — */
963
+ /* the icon (« / ») tells the direction. */
964
+ /* */
965
+ /* 280px on the left aligns to palette's grid column width; 320px on */
966
+ /* the right aligns to inspector's. The --collapsed modifier moves the */
967
+ /* tab to the screen edge (left:0 or right:0). */
968
+ /* */
969
+ /* Hidden on <1024px viewport: at tablet/phone the body falls back to a */
970
+ /* DOM-order single column stack; floating edge tabs would overlay the */
971
+ /* stacked panels meaninglessly. */
972
+ /* ------------------------------------------------------------------ */
973
+ .cpub-admin-layouts-editor-edge-tab {
974
+ position: absolute;
975
+ top: 50%;
976
+ transform: translateY(-50%);
977
+ /* Session 164 audit R1-1: bumped from 18px → 28px to clear WCAG 2.5.8
978
+ AA minimum target size (24×24) with a small design buffer.
979
+ Matches the 28×28 button convention used for cpub-layout-section-move
980
+ per feedback-visual-editor-ux-patterns. Height stays at 56px (2× width)
981
+ so the tab still reads as a vertical edge handle, not a square button. */
982
+ width: 28px;
983
+ height: 56px;
984
+ display: inline-flex;
985
+ align-items: center;
986
+ justify-content: center;
987
+ background: var(--surface);
988
+ border: 1px solid var(--border);
989
+ color: var(--text-dim);
990
+ cursor: pointer;
991
+ /* Above the canvas + panel content but below modals + announcer */
992
+ z-index: 5;
993
+ transition: left 200ms ease-out, right 200ms ease-out, background var(--transition-default), color var(--transition-default);
994
+ /* Compact icon size matches the slim handle silhouette. The 28px
995
+ touch surface is what WCAG cares about — the chevron centers inside. */
996
+ font-size: 10px;
997
+ }
998
+ .cpub-admin-layouts-editor-edge-tab:hover {
999
+ background: var(--surface2);
1000
+ color: var(--accent);
1001
+ border-color: var(--accent);
1002
+ }
1003
+ .cpub-admin-layouts-editor-edge-tab:focus-visible {
1004
+ outline: 2px solid var(--accent);
1005
+ outline-offset: 2px;
1006
+ color: var(--accent);
1007
+ }
1008
+
1009
+ .cpub-admin-layouts-editor-edge-tab--left {
1010
+ /* Sit at the right edge of the palette (which is 280px wide). The
1011
+ -14px offset centers the 28px-wide tab ON the boundary so half is
1012
+ in the palette + half in the canvas — reads as "the boundary
1013
+ itself is the toggle". (Was -9px when the tab was 18px wide.) */
1014
+ left: calc(280px - 14px);
1015
+ }
1016
+ .cpub-admin-layouts-editor-edge-tab--right {
1017
+ right: calc(320px - 14px);
1018
+ }
1019
+ .cpub-admin-layouts-editor-edge-tab--left.cpub-admin-layouts-editor-edge-tab--collapsed {
1020
+ /* Collapsed: snap to the screen edge so the admin sees an obvious
1021
+ "click here to bring it back" affordance. */
1022
+ left: 0;
1023
+ }
1024
+ .cpub-admin-layouts-editor-edge-tab--right.cpub-admin-layouts-editor-edge-tab--collapsed {
1025
+ right: 0;
1026
+ }
1027
+
1028
+ /* Mirror the breakpoint reduction at <=1280px so the tabs follow the
1029
+ narrower panel widths (240 / 280 from the body media query). The
1030
+ -14px offset is the 28px-wide tab's half-width, same logic as the
1031
+ 1025px+ case above (was -9px when the tab was 18px wide). */
1032
+ @media (max-width: 1280px) {
1033
+ .cpub-admin-layouts-editor-edge-tab--left { left: calc(240px - 14px); }
1034
+ .cpub-admin-layouts-editor-edge-tab--right { right: calc(280px - 14px); }
1035
+ .cpub-admin-layouts-editor-edge-tab--left.cpub-admin-layouts-editor-edge-tab--collapsed { left: 0; }
1036
+ .cpub-admin-layouts-editor-edge-tab--right.cpub-admin-layouts-editor-edge-tab--collapsed { right: 0; }
1037
+ }
1038
+
1039
+ /* Tablet/phone: hide. The single-column DOM stack already gives admin
1040
+ direct access to each section without needing collapse affordances. */
1041
+ @media (max-width: 1024px) {
1042
+ .cpub-admin-layouts-editor-edge-tab { display: none; }
1043
+ /* Session 164 audit R3-3: force panels visible regardless of the
1044
+ cookie-persisted desktop-collapse state. At tablet/phone the body
1045
+ falls back to a DOM-order single-column stack — the desktop
1046
+ 'collapsed' state has no useful meaning when there's no grid column
1047
+ to remove, but `chrome.paletteHidden` / `chrome.inspectorHidden`
1048
+ still drive v-show on the panel components, leaving an admin who
1049
+ collapsed on desktop with NO way to re-show on tablet (the edge
1050
+ tabs are hidden by the rule above; the toolbar toggles were
1051
+ removed in the 164 polish). Override v-show's inline display:none
1052
+ with `flex !important` (panels natively use display:flex column —
1053
+ 'block' would break their internal layout). Scoped :deep() because
1054
+ the .cpub-admin-layouts-{palette,inspector} root classes live in
1055
+ child components. */
1056
+ :deep(.cpub-admin-layouts-palette),
1057
+ :deep(.cpub-admin-layouts-inspector) {
1058
+ display: flex !important;
1059
+ }
1060
+ }
1061
+
1062
+ /* prefers-reduced-motion: kill the slide transition so the tab snaps
1063
+ to its new position immediately. Plan §7.11 + WCAG 2.3.3. */
1064
+ @media (prefers-reduced-motion: reduce) {
1065
+ .cpub-admin-layouts-editor-edge-tab { transition: none; }
1066
+ }
1067
+
1068
+ /* Phone (<640px) — show a "use a larger screen" banner and HIDE the
1069
+ editor body entirely. Drag-drop on 375px is user-hostile per the
1070
+ plan §7.7. */
1071
+ .cpub-admin-layouts-editor-phone-only {
1072
+ display: none;
1073
+ flex-direction: column;
1074
+ align-items: center;
1075
+ gap: var(--space-3);
1076
+ padding: var(--space-8) var(--space-4);
1077
+ text-align: center;
1078
+ }
1079
+ .cpub-admin-layouts-editor-phone-only h2 {
1080
+ font-size: var(--text-lg);
1081
+ margin: 0;
1082
+ color: var(--text);
1083
+ }
1084
+ .cpub-admin-layouts-editor-phone-only p {
1085
+ margin: 0;
1086
+ color: var(--text-dim);
1087
+ max-width: 32ch;
1088
+ }
1089
+ .cpub-admin-layouts-editor-phone-icon {
1090
+ font-size: var(--text-3xl);
1091
+ color: var(--text-faint);
1092
+ }
1093
+ .cpub-admin-layouts-editor-phone-back {
1094
+ display: inline-flex;
1095
+ align-items: center;
1096
+ gap: var(--space-1);
1097
+ margin-top: var(--space-2);
1098
+ color: var(--accent);
1099
+ text-decoration: underline;
1100
+ font-family: var(--font-mono);
1101
+ font-size: var(--text-xs);
1102
+ text-transform: uppercase;
1103
+ letter-spacing: var(--tracking-wide);
1104
+ }
1105
+
1106
+ @media (max-width: 640px) {
1107
+ .cpub-admin-layouts-editor-phone-only { display: flex; }
1108
+ .cpub-admin-layouts-editor-body { display: none; }
1109
+ }
1110
+ </style>