@commonpub/layer 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +10 -7
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -0,0 +1,346 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Help overlay — keyboard shortcut reference modal. Phase 3d.3 +
4
+ * session 165 deep audit R3-B (focus trap).
5
+ *
6
+ * Opens on `?` (Shift+/) per the convention Linear / GitHub / Notion
7
+ * / Figma all share. Read-only: lists every keyboard shortcut grouped
8
+ * by category. Close via Esc, backdrop click, or the Close button.
9
+ *
10
+ * Focus trap: when focus leaves the dialog while it's open, snap it
11
+ * back to the Close button. Implemented via a `focusin` listener on
12
+ * document. The check `dialog.contains(target)` allows focus to move
13
+ * freely WITHIN the dialog (future-proof: works even if more
14
+ * focusables are added later) but rejects focus outside. Closes WCAG
15
+ * ARIA Dialog pattern (focus shouldn't escape an open modal).
16
+ *
17
+ * Cross-platform key rendering: shows ⌘ (Cmd) for the META modifier in
18
+ * each chord. Mac users see "⌘ Z"; the visible glyph is the same
19
+ * keyboard pictograph their OS uses everywhere. Windows/Linux users
20
+ * read "⌘ Z" and pattern-match to Ctrl — every cross-platform editor
21
+ * (Notion / VS Code / Linear) uses this convention rather than
22
+ * runtime-sniffing the platform (which is brittle on iPad with a
23
+ * Magic Keyboard, on Linux desktops claiming to be a Mac, etc).
24
+ */
25
+ import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
26
+
27
+ const props = defineProps<{
28
+ /** Show/hide the modal. The parent (the editor page) owns this
29
+ * ref + flips it on `?` keypress. */
30
+ open: boolean;
31
+ }>();
32
+
33
+ const emit = defineEmits<{
34
+ (e: 'close'): void;
35
+ }>();
36
+
37
+ /* ------------------------------------------------------------------ */
38
+ /* Hotkey table — single source of truth for the rendered chords. */
39
+ /* If a new binding is added to useLayoutHotkeys, ADD A ROW HERE too. */
40
+ /* ------------------------------------------------------------------ */
41
+ interface Hotkey {
42
+ /** The chord, rendered as one or more `<kbd>` elements joined with
43
+ * a `+` separator at view-time. */
44
+ chord: string[];
45
+ /** Short prose description. Plain English, no jargon. */
46
+ description: string;
47
+ }
48
+ interface HotkeyGroup {
49
+ title: string;
50
+ rows: Hotkey[];
51
+ }
52
+
53
+ const groups: HotkeyGroup[] = [
54
+ {
55
+ title: 'Edit',
56
+ rows: [
57
+ { chord: ['Backspace'], description: 'Remove the selected section. Press Command+Z to restore.' },
58
+ { chord: ['Delete'], description: 'Remove the selected section (alias for Backspace).' },
59
+ { chord: ['⌘', 'D'], description: 'Duplicate the selected section. Selection moves to the new copy.' },
60
+ { chord: ['Shift', '←'], description: 'Shrink the selected section by 1 column. Right neighbour absorbs the gap; stops at the section’s minimum.' },
61
+ { chord: ['Shift', '→'], description: 'Grow the selected section by 1 column. Right neighbour shrinks; stops at its minimum.' },
62
+ ],
63
+ },
64
+ {
65
+ title: 'History',
66
+ rows: [
67
+ { chord: ['⌘', 'Z'], description: 'Undo the last change. Stack holds the most recent 50 operations.' },
68
+ { chord: ['⌘', 'Shift', 'Z'], description: 'Redo. Cancelled by any new action — Notion/Linear convention.' },
69
+ ],
70
+ },
71
+ // (Move group deliberately omitted — session 165 deep audit R1-A.
72
+ // Move Up / Move Down / Move-to-zone are visible buttons in the
73
+ // section's top-right cluster, discoverable via Tab. They're not
74
+ // hidden keyboard shortcuts; listing them here as "Tab + Enter"
75
+ // chord rows was misleading — the chord didn't disambiguate the
76
+ // three different button targets.)
77
+ {
78
+ title: 'View',
79
+ rows: [
80
+ { chord: ['?'], description: 'Show this overlay.' },
81
+ { chord: ['Esc'], description: 'Close this overlay; close popovers; clear selection.' },
82
+ ],
83
+ },
84
+ ];
85
+
86
+ /* ------------------------------------------------------------------ */
87
+ /* Focus on close button at open-time (matches conflict modal). */
88
+ /* ------------------------------------------------------------------ */
89
+ const closeBtn = ref<HTMLButtonElement | null>(null);
90
+ const dialogEl = ref<HTMLElement | null>(null);
91
+ watch(
92
+ () => props.open,
93
+ async (isOpen) => {
94
+ if (isOpen) {
95
+ await nextTick();
96
+ closeBtn.value?.focus();
97
+ }
98
+ },
99
+ { immediate: true },
100
+ );
101
+
102
+ /* Esc closes — global keydown so backdrop focus (or accidental loss
103
+ * of focus to <body>) still dismisses. The :open guard makes this a
104
+ * no-op when closed. */
105
+ function onKeydown(e: KeyboardEvent): void {
106
+ if (props.open && e.key === 'Escape') {
107
+ e.preventDefault();
108
+ emit('close');
109
+ }
110
+ }
111
+
112
+ /* Focus trap (session 165 deep audit R3-B). When the modal is open and
113
+ * focus moves to an element OUTSIDE the dialog (Tab past the close
114
+ * button, programmatic focus from underlying page, etc), snap focus
115
+ * back to the close button. The `dialog.contains(target)` check
116
+ * future-proofs the trap: any new focusable inside the dialog is
117
+ * naturally allowed.
118
+ *
119
+ * Why focusin (vs keydown Tab): catches BOTH Tab and programmatic
120
+ * focus changes, including ones the editor page might trigger via
121
+ * other composables. The check fires AFTER focus has moved, so the
122
+ * snap-back is observable (a tiny focus blip on the outside element),
123
+ * but it's the most robust approach in jsdom + browsers.
124
+ *
125
+ * Topmost-only guard (session 165 round 5): if a later-mounted dialog
126
+ * is on top of us, let THAT dialog's trap own focus. Without this guard
127
+ * two simultaneously-open modals' traps would ping-pong focus. Parent
128
+ * coordination in [id].vue closes us when ConflictModal opens, so this
129
+ * guard is belt-and-suspenders — necessary only for the brief window
130
+ * where both are mounted before the watcher fires. */
131
+ function isTopmostDialog(): boolean {
132
+ if (typeof document === 'undefined') return false;
133
+ if (!dialogEl.value) return false;
134
+ const all = document.querySelectorAll('[role="dialog"], [role="alertdialog"]');
135
+ return all[all.length - 1] === dialogEl.value;
136
+ }
137
+ function onFocusIn(e: FocusEvent): void {
138
+ if (!props.open) return;
139
+ if (!isTopmostDialog()) return;
140
+ const target = e.target as Node | null;
141
+ if (!target) return;
142
+ const dlg = dialogEl.value;
143
+ if (!dlg) return;
144
+ if (dlg.contains(target)) return;
145
+ closeBtn.value?.focus();
146
+ }
147
+ onMounted(() => {
148
+ if (typeof window !== 'undefined') {
149
+ window.addEventListener('keydown', onKeydown);
150
+ document.addEventListener('focusin', onFocusIn);
151
+ }
152
+ });
153
+ onBeforeUnmount(() => {
154
+ if (typeof window !== 'undefined') {
155
+ window.removeEventListener('keydown', onKeydown);
156
+ document.removeEventListener('focusin', onFocusIn);
157
+ }
158
+ });
159
+ </script>
160
+
161
+ <template>
162
+ <Teleport to="body">
163
+ <div
164
+ v-if="open"
165
+ class="cpub-admin-layouts-help-backdrop"
166
+ role="presentation"
167
+ @click.self="emit('close')"
168
+ >
169
+ <div
170
+ ref="dialogEl"
171
+ class="cpub-admin-layouts-help-modal"
172
+ role="dialog"
173
+ aria-modal="true"
174
+ aria-labelledby="cpub-admin-layouts-help-title"
175
+ >
176
+ <header class="cpub-admin-layouts-help-header">
177
+ <h2 id="cpub-admin-layouts-help-title" class="cpub-admin-layouts-help-title">
178
+ Keyboard shortcuts
179
+ </h2>
180
+ <button
181
+ ref="closeBtn"
182
+ type="button"
183
+ class="cpub-admin-layouts-help-close"
184
+ aria-label="Close keyboard shortcuts"
185
+ @click="emit('close')"
186
+ >
187
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
188
+ </button>
189
+ </header>
190
+ <div class="cpub-admin-layouts-help-body">
191
+ <section
192
+ v-for="group in groups"
193
+ :key="group.title"
194
+ class="cpub-admin-layouts-help-group"
195
+ >
196
+ <h3 class="cpub-admin-layouts-help-group-title">{{ group.title }}</h3>
197
+ <dl class="cpub-admin-layouts-help-list">
198
+ <template v-for="row in group.rows" :key="row.description">
199
+ <dt class="cpub-admin-layouts-help-chord">
200
+ <template v-for="(part, i) in row.chord" :key="i">
201
+ <kbd>{{ part }}</kbd>
202
+ <span v-if="i < row.chord.length - 1" class="cpub-admin-layouts-help-plus" aria-hidden="true">+</span>
203
+ </template>
204
+ </dt>
205
+ <dd class="cpub-admin-layouts-help-desc">{{ row.description }}</dd>
206
+ </template>
207
+ </dl>
208
+ </section>
209
+ </div>
210
+ <footer class="cpub-admin-layouts-help-footer">
211
+ <p class="cpub-admin-layouts-help-hint">
212
+ ⌘ stands for the Command key on macOS and the Ctrl key on Windows/Linux.
213
+ </p>
214
+ </footer>
215
+ </div>
216
+ </div>
217
+ </Teleport>
218
+ </template>
219
+
220
+ <style scoped>
221
+ .cpub-admin-layouts-help-backdrop {
222
+ position: fixed;
223
+ inset: 0;
224
+ background: var(--color-surface-overlay, rgba(0, 0, 0, 0.5));
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: center;
228
+ z-index: 1000;
229
+ padding: var(--space-4);
230
+ }
231
+
232
+ .cpub-admin-layouts-help-modal {
233
+ background: var(--surface);
234
+ border: var(--border-width-default) solid var(--border);
235
+ box-shadow: var(--shadow-lg);
236
+ max-width: 560px;
237
+ width: 100%;
238
+ max-height: 90vh;
239
+ display: flex;
240
+ flex-direction: column;
241
+ }
242
+
243
+ .cpub-admin-layouts-help-header {
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: space-between;
247
+ gap: var(--space-3);
248
+ padding: var(--space-4);
249
+ border-bottom: 1px solid var(--border2);
250
+ }
251
+ .cpub-admin-layouts-help-title {
252
+ font-size: var(--text-lg);
253
+ font-weight: var(--font-weight-bold);
254
+ margin: 0;
255
+ }
256
+ .cpub-admin-layouts-help-close {
257
+ /* 28×28 touch target — matches the section-move buttons + edge-tab
258
+ buttons elsewhere in the editor (WCAG 2.5.8 AA = 24×24 floor). */
259
+ width: 28px;
260
+ height: 28px;
261
+ display: inline-flex;
262
+ align-items: center;
263
+ justify-content: center;
264
+ background: var(--surface);
265
+ border: 1px solid var(--border2);
266
+ color: var(--text-dim);
267
+ cursor: pointer;
268
+ font-size: var(--text-sm);
269
+ }
270
+ .cpub-admin-layouts-help-close:hover { background: var(--surface2); color: var(--text); }
271
+ .cpub-admin-layouts-help-close:focus-visible {
272
+ outline: 2px solid var(--accent);
273
+ outline-offset: 2px;
274
+ }
275
+
276
+ .cpub-admin-layouts-help-body {
277
+ padding: var(--space-4);
278
+ overflow-y: auto;
279
+ display: flex;
280
+ flex-direction: column;
281
+ gap: var(--space-4);
282
+ }
283
+
284
+ .cpub-admin-layouts-help-group-title {
285
+ font-family: var(--font-mono);
286
+ font-size: var(--text-xs);
287
+ text-transform: uppercase;
288
+ letter-spacing: var(--tracking-wide);
289
+ color: var(--text-dim);
290
+ margin: 0 0 var(--space-2) 0;
291
+ border-bottom: 1px solid var(--border2);
292
+ padding-bottom: var(--space-1);
293
+ }
294
+
295
+ .cpub-admin-layouts-help-list {
296
+ display: grid;
297
+ grid-template-columns: minmax(120px, max-content) 1fr;
298
+ column-gap: var(--space-4);
299
+ row-gap: var(--space-2);
300
+ margin: 0;
301
+ }
302
+
303
+ .cpub-admin-layouts-help-chord {
304
+ display: inline-flex;
305
+ align-items: center;
306
+ gap: var(--space-1);
307
+ margin: 0;
308
+ }
309
+ .cpub-admin-layouts-help-chord kbd {
310
+ display: inline-flex;
311
+ align-items: center;
312
+ justify-content: center;
313
+ min-width: 1.6em;
314
+ padding: 0 var(--space-1);
315
+ height: 1.6em;
316
+ background: var(--surface2);
317
+ border: 1px solid var(--border);
318
+ border-bottom-width: 2px;
319
+ color: var(--text);
320
+ font-family: var(--font-mono);
321
+ font-size: var(--text-xs);
322
+ text-transform: none;
323
+ letter-spacing: 0;
324
+ }
325
+ .cpub-admin-layouts-help-plus {
326
+ color: var(--text-faint);
327
+ font-size: var(--text-xs);
328
+ }
329
+
330
+ .cpub-admin-layouts-help-desc {
331
+ margin: 0;
332
+ color: var(--text);
333
+ font-size: var(--text-sm);
334
+ line-height: 1.5;
335
+ }
336
+
337
+ .cpub-admin-layouts-help-footer {
338
+ padding: var(--space-3) var(--space-4);
339
+ border-top: 1px solid var(--border2);
340
+ }
341
+ .cpub-admin-layouts-help-hint {
342
+ font-size: var(--text-xs);
343
+ color: var(--text-dim);
344
+ margin: 0;
345
+ }
346
+ </style>
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Inspector — right column of the editor shell. 3-way dispatcher on the
4
+ * editor's current selection (Phase 3e):
5
+ * - nothing selected → page-meta form (<AdminLayoutsInspectorPage>)
6
+ * - section selected → section config auto-form (<AdminLayoutsInspectorSection>)
7
+ * - row selected → row config auto-form (<AdminLayoutsInspectorRow>)
8
+ *
9
+ * The selected section/row are resolved from `draft` by id so the
10
+ * inspector stays a pure function of (draft, selection) — no duplicated
11
+ * selection state. A stale selection (id no longer in draft, e.g. another
12
+ * admin deleted it) falls back to the page-meta form.
13
+ *
14
+ * Config edits bubble as `update:section-config` / `update:row-config`
15
+ * with `{ id, config }`; the editor page mutates the draft (auto-save
16
+ * picks up the dirty flag). Page-meta + name edits keep their existing
17
+ * emits unchanged.
18
+ */
19
+ import type { LayoutRecord, LayoutSectionResolved, LayoutRowResolved } from '@commonpub/server';
20
+ import type { EditorSelection } from '../../../composables/useLayoutEditor';
21
+
22
+ const props = defineProps<{
23
+ draft: LayoutRecord | null;
24
+ selection: EditorSelection;
25
+ }>();
26
+
27
+ const emit = defineEmits<{
28
+ (e: 'update:page-meta', value: LayoutRecord['pageMeta']): void;
29
+ (e: 'update:name', value: string): void;
30
+ (e: 'update:section-config', value: { id: string; config: Record<string, unknown> }): void;
31
+ (e: 'update:row-config', value: { id: string; config: Record<string, unknown> }): void;
32
+ }>();
33
+
34
+ /** Locate the selected section in the draft (or null if absent/stale). */
35
+ const selectedSection = computed<LayoutSectionResolved | null>(() => {
36
+ const sel = props.selection;
37
+ if (!sel || sel.kind !== 'section' || !props.draft) return null;
38
+ for (const zone of props.draft.zones) {
39
+ for (const row of zone.rows) {
40
+ const found = row.sections.find((s) => s.id === sel.id);
41
+ if (found) return found;
42
+ }
43
+ }
44
+ return null;
45
+ });
46
+
47
+ /** Locate the selected row in the draft (or null if absent/stale). */
48
+ const selectedRow = computed<LayoutRowResolved | null>(() => {
49
+ const sel = props.selection;
50
+ if (!sel || sel.kind !== 'row' || !props.draft) return null;
51
+ for (const zone of props.draft.zones) {
52
+ const found = zone.rows.find((r) => r.id === sel.id);
53
+ if (found) return found;
54
+ }
55
+ return null;
56
+ });
57
+
58
+ /** Which branch the dispatcher renders — also drives the header hint. */
59
+ const mode = computed<'loading' | 'page' | 'section' | 'row'>(() => {
60
+ if (!props.draft) return 'loading';
61
+ if (selectedSection.value) return 'section';
62
+ if (selectedRow.value) return 'row';
63
+ return 'page';
64
+ });
65
+
66
+ const hint = computed<string>(() => {
67
+ switch (mode.value) {
68
+ case 'section': return 'Editing the selected section. Click empty canvas to edit page meta.';
69
+ case 'row': return 'Editing the selected row. Click empty canvas to edit page meta.';
70
+ default: return 'Page meta. Select a section or row to edit its content.';
71
+ }
72
+ });
73
+
74
+ function onPageMetaUpdate(value: LayoutRecord['pageMeta']): void {
75
+ emit('update:page-meta', value);
76
+ }
77
+ function onNameUpdate(value: string): void {
78
+ emit('update:name', value);
79
+ }
80
+ function onSectionConfigUpdate(config: Record<string, unknown>): void {
81
+ const s = selectedSection.value;
82
+ if (s) emit('update:section-config', { id: s.id, config });
83
+ }
84
+ function onRowConfigUpdate(config: Record<string, unknown>): void {
85
+ const r = selectedRow.value;
86
+ if (r) emit('update:row-config', { id: r.id, config });
87
+ }
88
+ </script>
89
+
90
+ <template>
91
+ <aside class="cpub-admin-layouts-inspector" aria-label="Inspector">
92
+ <header class="cpub-admin-layouts-inspector-header">
93
+ <h2 class="cpub-admin-layouts-inspector-title">Inspector</h2>
94
+ <p class="cpub-admin-layouts-inspector-hint">{{ hint }}</p>
95
+ </header>
96
+
97
+ <div v-if="mode === 'loading'" class="cpub-admin-layouts-inspector-loading">
98
+ <i class="fa-solid fa-circle-notch fa-spin"></i>
99
+ <span>Loading…</span>
100
+ </div>
101
+
102
+ <AdminLayoutsInspectorSection
103
+ v-else-if="mode === 'section' && selectedSection"
104
+ :section="selectedSection"
105
+ @update:config="onSectionConfigUpdate"
106
+ />
107
+
108
+ <AdminLayoutsInspectorRow
109
+ v-else-if="mode === 'row' && selectedRow"
110
+ :row="selectedRow"
111
+ @update:config="onRowConfigUpdate"
112
+ />
113
+
114
+ <AdminLayoutsInspectorPage
115
+ v-else-if="draft"
116
+ :draft="draft"
117
+ @update:page-meta="onPageMetaUpdate"
118
+ @update:name="onNameUpdate"
119
+ />
120
+ </aside>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .cpub-admin-layouts-inspector {
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: var(--space-4);
128
+ padding: var(--space-4);
129
+ background: var(--surface);
130
+ border-left: var(--border-width-default) solid var(--border);
131
+ overflow-y: auto;
132
+ height: 100%;
133
+ }
134
+
135
+ .cpub-admin-layouts-inspector-header { border-bottom: 1px solid var(--border2); padding-bottom: var(--space-3); }
136
+ .cpub-admin-layouts-inspector-title {
137
+ font-family: var(--font-mono);
138
+ font-size: var(--text-xs);
139
+ text-transform: uppercase;
140
+ letter-spacing: var(--tracking-widest);
141
+ color: var(--text-dim);
142
+ margin: 0 0 var(--space-1) 0;
143
+ font-weight: var(--font-weight-semibold);
144
+ }
145
+ .cpub-admin-layouts-inspector-hint {
146
+ font-size: var(--text-xs);
147
+ color: var(--text-faint);
148
+ margin: 0;
149
+ }
150
+
151
+ .cpub-admin-layouts-inspector-loading {
152
+ display: flex; align-items: center; gap: var(--space-2);
153
+ padding: var(--space-4);
154
+ color: var(--text-faint);
155
+ font-size: var(--text-sm);
156
+ }
157
+ </style>