@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,149 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * <AdminLayoutsPaletteTile> — a single section in the palette.
4
+ *
5
+ * Phase 3b/A: each tile owns its own `makeDraggable` template ref so
6
+ * the palette can host N tiles, each a drag source. Mirrors the
7
+ * <LayoutRow> per-iteration pattern: dnd-kit composables run per
8
+ * component setup; the natural fit is one component instance per drag
9
+ * source.
10
+ *
11
+ * The tile drags a `paletteDragPayload(def)` envelope (kind:
12
+ * 'palette-section-spec'). On drop into a row, the row's makeDroppable
13
+ * onDrop hands the payload to `dispatchSectionDrop`, which mints a
14
+ * fresh section via `createSectionFromSpec`. Source + drop handler
15
+ * stay in lockstep through the shared `useLayoutDrag` module.
16
+ *
17
+ * Visual identical to the inline tile this replaces — same class names,
18
+ * same data attributes, same hover treatment. Only addition: cursor:
19
+ * grab when wired (per `feedback-visual-editor-ux-patterns` cursor-as-
20
+ * contract — grab is a PROMISE the drag actually works, and now it does).
21
+ */
22
+ import { ref } from 'vue';
23
+ import { makeDraggable } from '@vue-dnd-kit/core';
24
+ import type { SectionDefinition } from '@commonpub/ui';
25
+ import { paletteDragPayload } from '../../../composables/useLayoutDrag';
26
+
27
+ const props = defineProps<{
28
+ section: SectionDefinition;
29
+ }>();
30
+
31
+ const tileRef = ref<HTMLElement | null>(null);
32
+
33
+ // dnd-kit's payload factory takes [index, items]. A palette tile is
34
+ // effectively a single-item list (you can't "rearrange items within a
35
+ // palette tile") so index=0 + items=[envelope] is the canonical shape.
36
+ //
37
+ // The factory is invoked per drag-tick, so referencing `props.section`
38
+ // directly is fine — `paletteDragPayload` shallow-clones defaultConfig
39
+ // into the envelope so subsequent drags are independent.
40
+ makeDraggable(
41
+ tileRef,
42
+ {
43
+ groups: ['section'],
44
+ },
45
+ () => [0, [paletteDragPayload(props.section)]],
46
+ );
47
+ </script>
48
+
49
+ <template>
50
+ <li
51
+ ref="tileRef"
52
+ class="cpub-admin-layouts-palette-tile"
53
+ :data-section-type="section.type"
54
+ :data-section-status="section.status ?? 'stable'"
55
+ :aria-label="`Drag to insert ${section.name} (${section.type}) section`"
56
+ tabindex="0"
57
+ >
58
+ <i :class="['cpub-admin-layouts-palette-tile-icon', section.icon]"></i>
59
+ <div class="cpub-admin-layouts-palette-tile-body">
60
+ <span class="cpub-admin-layouts-palette-tile-name">{{ section.name }}</span>
61
+ <span class="cpub-admin-layouts-palette-tile-desc">{{ section.description }}</span>
62
+ </div>
63
+ <span
64
+ v-if="section.status === 'beta'"
65
+ class="cpub-admin-layouts-palette-tile-badge"
66
+ >beta</span>
67
+ </li>
68
+ </template>
69
+
70
+ <style scoped>
71
+ /*
72
+ * Tile chrome — visual treatment moves here from AdminLayoutsPalette
73
+ * (Vue scoped styles are component-instance hashed). The palette's
74
+ * own scoped styles handle the surrounding layout (group titles,
75
+ * scroll container) — these handle the tile body itself.
76
+ */
77
+ .cpub-admin-layouts-palette-tile {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: var(--space-3);
81
+ padding: var(--space-2) var(--space-3);
82
+ background: var(--surface2);
83
+ border: 1px solid var(--border2);
84
+ user-select: none;
85
+ /*
86
+ * cursor: grab. The drag is now wired — this isn't a UI lie. Per
87
+ * `feedback-visual-editor-ux-patterns` cursor-as-contract:
88
+ * `grab` reads "you can pick me up". A tile WITHOUT makeDraggable
89
+ * should stay `cursor: default`; this one has it, so the cursor
90
+ * promises something true.
91
+ */
92
+ cursor: grab;
93
+ }
94
+ .cpub-admin-layouts-palette-tile:active {
95
+ /* `cursor: grabbing` during active drag — second half of the
96
+ cursor-as-contract pattern. Pointer-state-driven (CSS :active fires
97
+ on mousedown) is fine since dnd-kit's activation defaults are
98
+ pointer-down + small distance. */
99
+ cursor: grabbing;
100
+ }
101
+ .cpub-admin-layouts-palette-tile:hover {
102
+ border-color: var(--border);
103
+ background: var(--surface);
104
+ }
105
+ .cpub-admin-layouts-palette-tile:focus-visible {
106
+ /* Keyboard-only focus ring — matches the editor's accent treatment
107
+ elsewhere. WCAG 2.4.7 Focus Visible (AA). */
108
+ outline: 2px solid var(--accent);
109
+ outline-offset: 2px;
110
+ }
111
+ .cpub-admin-layouts-palette-tile[data-section-status='deprecated'] { opacity: 0.5; }
112
+
113
+ .cpub-admin-layouts-palette-tile-icon {
114
+ flex: 0 0 auto;
115
+ width: 20px;
116
+ height: 20px;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ color: var(--text-dim);
121
+ }
122
+
123
+ .cpub-admin-layouts-palette-tile-body {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 1px;
127
+ flex: 1;
128
+ min-width: 0;
129
+ }
130
+ .cpub-admin-layouts-palette-tile-name { font-size: var(--text-sm); color: var(--text); }
131
+ .cpub-admin-layouts-palette-tile-desc {
132
+ font-size: var(--text-xs);
133
+ color: var(--text-faint);
134
+ white-space: nowrap;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ }
138
+
139
+ .cpub-admin-layouts-palette-tile-badge {
140
+ font-family: var(--font-mono);
141
+ font-size: 10px;
142
+ text-transform: uppercase;
143
+ letter-spacing: var(--tracking-wide);
144
+ padding: 1px var(--space-1);
145
+ background: var(--accent-bg, var(--surface));
146
+ color: var(--accent);
147
+ border: 1px solid var(--accent);
148
+ }
149
+ </style>
@@ -0,0 +1,483 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Editor toolbar (Phase 3a.5).
4
+ *
5
+ * Sticky top strip: breadcrumb back to /admin/layouts + layout name +
6
+ * state pill + viewport segmented control + save-status text +
7
+ * Save (manual) + Publish buttons.
8
+ *
9
+ * Viewport is a UI-only concern (translates to a max-width cap on the
10
+ * canvas) — the actual responsive resolution happens at runtime via
11
+ * the section's `responsive.{sm,md,lg}` colSpans. Picking "mobile"
12
+ * here doesn't mutate the layout; it just previews the reflow.
13
+ *
14
+ * Save indicator strings match docs/plans/layout-and-pages.md §7.13:
15
+ * - 'Saved' (saved, no dirt)
16
+ * - 'Saving…' (in flight)
17
+ * - 'Unsaved changes' (dirty, idle)
18
+ * - 'Save failed' (last save errored)
19
+ * - 'Conflict' (last save returned 409 — caller surfaces a modal)
20
+ */
21
+
22
+ import { onMounted, onBeforeUnmount, ref } from 'vue';
23
+
24
+ const props = defineProps<{
25
+ /** Layout name (read from draft so renames show live). */
26
+ layoutName: string;
27
+ /** Draft state pill — draft vs published. */
28
+ state: 'draft' | 'published';
29
+ /** Current viewport — drives the segmented control's pressed state. */
30
+ viewport: 'mobile' | 'tablet' | 'desktop';
31
+ /** Save status — drives the indicator text + color. */
32
+ saveStatus: 'idle' | 'saving' | 'saved' | 'error' | 'conflict';
33
+ /** True when draft != original. */
34
+ dirty: boolean;
35
+ /** Friendly error text (only shown when saveStatus==='error'). */
36
+ errorMessage: string | null;
37
+ /** ISO timestamp of the last successful save — drives "Saved · 2m ago". */
38
+ lastSavedAt?: string | null;
39
+ /** Phase 3b/B — undo/redo button enablement + tooltip labels. */
40
+ canUndo?: boolean;
41
+ canRedo?: boolean;
42
+ /** Label of the command that undo/redo will apply — shown as the
43
+ * button's title attribute ("Undo: move hero"). null when stack
44
+ * is empty (button is disabled then). */
45
+ undoLabel?: string | null;
46
+ redoLabel?: string | null;
47
+ }>();
48
+
49
+ // Relative-time string for the save indicator. Updates every 30s so the
50
+ // "Saved · 5s ago" indicator stays current without manual refresh.
51
+ // Per UX research synthesis (session 160 audit): the relative time IS
52
+ // the trust signal — a bare "Saved" without context erodes user
53
+ // confidence ("did my LAST edit save, or one from earlier?").
54
+ const now = ref<number>(Date.now());
55
+ let nowTimer: ReturnType<typeof setInterval> | null = null;
56
+ onMounted(() => {
57
+ nowTimer = setInterval(() => { now.value = Date.now(); }, 30_000);
58
+ });
59
+ onBeforeUnmount(() => {
60
+ if (nowTimer) clearInterval(nowTimer);
61
+ });
62
+
63
+ function relativeTime(iso: string | null | undefined, ref: number): string {
64
+ if (!iso) return '';
65
+ const t = new Date(iso).getTime();
66
+ if (Number.isNaN(t)) return '';
67
+ const deltaSec = Math.max(0, Math.round((ref - t) / 1000));
68
+ if (deltaSec < 5) return 'just now';
69
+ if (deltaSec < 60) return `${deltaSec}s ago`;
70
+ const m = Math.round(deltaSec / 60);
71
+ if (m < 60) return `${m}m ago`;
72
+ const h = Math.round(m / 60);
73
+ if (h < 24) return `${h}h ago`;
74
+ return new Date(iso).toLocaleDateString();
75
+ }
76
+
77
+ const savedAgo = computed<string>(() => relativeTime(props.lastSavedAt, now.value));
78
+
79
+ /**
80
+ * Effective state for the pill — adopts the Strapi 3-state model:
81
+ * - 'draft': never been published
82
+ * - 'published': live, no pending changes
83
+ * - 'modified': live, has un-published draft edits
84
+ * The Modified state is the one most CMSs hide. Surfacing it explicitly
85
+ * gives the admin a clear mental model of "what's live vs what's drafted."
86
+ * Per UX research synthesis (session 160 audit).
87
+ */
88
+ const effectiveState = computed<'draft' | 'published' | 'modified'>(() => {
89
+ if (props.state === 'published' && props.dirty) return 'modified';
90
+ return props.state;
91
+ });
92
+
93
+ const STATE_LABELS: Record<'draft' | 'published' | 'modified', string> = {
94
+ draft: 'draft',
95
+ published: 'published',
96
+ modified: 'modified',
97
+ };
98
+
99
+ /** Publish button copy adapts to context per the Strapi model. */
100
+ const publishLabel = computed<string>(() => {
101
+ if (effectiveState.value === 'modified') return 'Publish changes';
102
+ if (effectiveState.value === 'published') return 'Republish';
103
+ return 'Publish';
104
+ });
105
+
106
+ /** Publish is disabled when there's nothing meaningful to publish — i.e.,
107
+ * the layout is already live and there are no draft edits. (Republish
108
+ * still works, but only via the explicit menu in a later phase.) */
109
+ const publishDisabled = computed<boolean>(() => {
110
+ return props.saveStatus === 'saving' || effectiveState.value === 'published';
111
+ });
112
+
113
+ const emit = defineEmits<{
114
+ (e: 'update:viewport', value: 'mobile' | 'tablet' | 'desktop'): void;
115
+ (e: 'save'): void;
116
+ (e: 'publish'): void;
117
+ (e: 'discard'): void;
118
+ (e: 'undo'): void;
119
+ (e: 'redo'): void;
120
+ }>();
121
+
122
+ /* Phase 3b/B — undo/redo button copy. Title attribute shows the
123
+ * specific label of the next command ("Undo: move hero") — this is
124
+ * the discoverable counterpart to the hotkey + a confirmation that the
125
+ * stack actually holds something meaningful before the user clicks. */
126
+ const undoTitle = computed<string>(() =>
127
+ props.canUndo && props.undoLabel ? `Undo: ${props.undoLabel} (Cmd+Z)` : 'Undo (Cmd+Z)',
128
+ );
129
+ const redoTitle = computed<string>(() =>
130
+ props.canRedo && props.redoLabel ? `Redo: ${props.redoLabel} (Cmd+Shift+Z)` : 'Redo (Cmd+Shift+Z)',
131
+ );
132
+
133
+ const indicatorText = computed<string>(() => {
134
+ if (props.saveStatus === 'saving') return 'Saving…';
135
+ if (props.saveStatus === 'conflict') return 'Conflict';
136
+ if (props.saveStatus === 'error') return props.errorMessage ?? 'Save failed';
137
+ if (props.dirty) return 'Unsaved changes';
138
+ // Saved + relative time — "Saved · 2m ago" is the trust signal
139
+ if (props.lastSavedAt) return savedAgo.value ? `Saved · ${savedAgo.value}` : 'Saved';
140
+ if (props.saveStatus === 'saved') return 'Saved';
141
+ return '';
142
+ });
143
+
144
+ const indicatorTone = computed<'neutral' | 'pending' | 'success' | 'error'>(() => {
145
+ if (props.saveStatus === 'saving') return 'pending';
146
+ if (props.saveStatus === 'error' || props.saveStatus === 'conflict') return 'error';
147
+ if (props.saveStatus === 'saved' && !props.dirty) return 'success';
148
+ return 'neutral';
149
+ });
150
+
151
+ const VIEWPORTS: Array<{ value: 'mobile' | 'tablet' | 'desktop'; icon: string; label: string }> = [
152
+ { value: 'mobile', icon: 'fa-solid fa-mobile-screen', label: 'Mobile' },
153
+ { value: 'tablet', icon: 'fa-solid fa-tablet-screen-button', label: 'Tablet' },
154
+ { value: 'desktop', icon: 'fa-solid fa-display', label: 'Desktop' },
155
+ ];
156
+ </script>
157
+
158
+ <template>
159
+ <header class="cpub-admin-layouts-toolbar" role="toolbar" aria-label="Editor toolbar">
160
+ <NuxtLink to="/admin/layouts" class="cpub-admin-layouts-toolbar-back">
161
+ <i class="fa-solid fa-chevron-left"></i>
162
+ <span>Layouts</span>
163
+ </NuxtLink>
164
+
165
+ <div class="cpub-admin-layouts-toolbar-title">
166
+ <span class="cpub-admin-layouts-toolbar-name">{{ layoutName || '—' }}</span>
167
+ <span
168
+ class="cpub-admin-layouts-toolbar-state"
169
+ :data-state="effectiveState"
170
+ >{{ STATE_LABELS[effectiveState] }}</span>
171
+ </div>
172
+
173
+ <!--
174
+ Session 164 polish: palette/inspector toggles MOVED to edge tabs on the
175
+ panels themselves (see pages/admin/layouts/[id].vue body). The toolbar
176
+ previously hosted these buttons, but the placement was non-obvious —
177
+ collapsing made it unclear where to re-open. The edge tabs at the
178
+ panel/canvas boundary follow the Notion/Linear convention: when
179
+ expanded they sit at the panel's outer edge; when collapsed they sit
180
+ at the screen edge inviting expansion. The icon (« / ») tells the
181
+ direction.
182
+ -->
183
+ <div
184
+ class="cpub-admin-layouts-toolbar-viewport"
185
+ role="radiogroup"
186
+ aria-label="Preview viewport"
187
+ >
188
+ <button
189
+ v-for="vp in VIEWPORTS"
190
+ :key="vp.value"
191
+ type="button"
192
+ class="cpub-admin-layouts-toolbar-viewport-btn"
193
+ :aria-label="vp.label"
194
+ :aria-pressed="viewport === vp.value"
195
+ :data-active="viewport === vp.value"
196
+ @click="emit('update:viewport', vp.value)"
197
+ >
198
+ <i :class="vp.icon"></i>
199
+ </button>
200
+ </div>
201
+
202
+ <!--
203
+ Phase 3b/B — undo / redo. Plan §7.12 toolbar mockup shows '⤺ ⤻'
204
+ between viewport and save indicator. Tooltip carries the next
205
+ command's specific label ("Undo: move hero") so the discoverable
206
+ affordance answers "what will Cmd+Z do?" without taking action.
207
+ Disabled state when stack is empty in that direction; keyboard
208
+ hotkeys (Cmd+Z / Cmd+Shift+Z) live independently in useLayoutHotkeys.
209
+ -->
210
+ <div
211
+ class="cpub-admin-layouts-toolbar-history"
212
+ role="group"
213
+ aria-label="Undo and redo"
214
+ >
215
+ <button
216
+ type="button"
217
+ class="cpub-admin-layouts-toolbar-panel-btn"
218
+ :aria-label="undoTitle"
219
+ :title="undoTitle"
220
+ :disabled="!canUndo"
221
+ @click="emit('undo')"
222
+ >
223
+ <i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
224
+ </button>
225
+ <button
226
+ type="button"
227
+ class="cpub-admin-layouts-toolbar-panel-btn"
228
+ :aria-label="redoTitle"
229
+ :title="redoTitle"
230
+ :disabled="!canRedo"
231
+ @click="emit('redo')"
232
+ >
233
+ <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
234
+ </button>
235
+ </div>
236
+
237
+ <div
238
+ class="cpub-admin-layouts-toolbar-indicator"
239
+ :data-tone="indicatorTone"
240
+ role="status"
241
+ aria-live="polite"
242
+ >
243
+ <i v-if="saveStatus === 'saving'" class="fa-solid fa-circle-notch fa-spin"></i>
244
+ <i v-else-if="saveStatus === 'saved' && !dirty" class="fa-solid fa-check"></i>
245
+ <i v-else-if="saveStatus === 'error' || saveStatus === 'conflict'" class="fa-solid fa-triangle-exclamation"></i>
246
+ <span>{{ indicatorText }}</span>
247
+ </div>
248
+
249
+ <div class="cpub-admin-layouts-toolbar-actions">
250
+ <!-- R4 audit P2 fix: Discard button wires useLayoutEditor.discard().
251
+ Enabled only when dirty; emits 'discard' for the parent page to
252
+ confirm + invoke. Previously discard() was implemented but
253
+ unwired — admin's only revert path was page refresh. -->
254
+ <button
255
+ type="button"
256
+ class="cpub-admin-layouts-toolbar-btn"
257
+ :disabled="!dirty || saveStatus === 'saving'"
258
+ @click="emit('discard')"
259
+ title="Discard unsaved changes"
260
+ >
261
+ <i class="fa-solid fa-rotate-left"></i>
262
+ <span>Discard</span>
263
+ </button>
264
+ <button
265
+ type="button"
266
+ class="cpub-admin-layouts-toolbar-btn"
267
+ :disabled="!dirty || saveStatus === 'saving'"
268
+ @click="emit('save')"
269
+ >
270
+ <i class="fa-solid fa-floppy-disk"></i>
271
+ <span>Save</span>
272
+ </button>
273
+ <button
274
+ type="button"
275
+ class="cpub-admin-layouts-toolbar-btn cpub-admin-layouts-toolbar-btn--primary"
276
+ :disabled="publishDisabled"
277
+ @click="emit('publish')"
278
+ >
279
+ <i class="fa-solid fa-cloud-arrow-up"></i>
280
+ <span>{{ publishLabel }}</span>
281
+ </button>
282
+ </div>
283
+ </header>
284
+ </template>
285
+
286
+ <style scoped>
287
+ .cpub-admin-layouts-toolbar {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: var(--space-4);
291
+ padding: var(--space-2) var(--space-4);
292
+ background: var(--surface);
293
+ border-bottom: var(--border-width-default) solid var(--border);
294
+ flex: 0 0 auto;
295
+ }
296
+
297
+ .cpub-admin-layouts-toolbar-back {
298
+ display: inline-flex;
299
+ align-items: center;
300
+ gap: var(--space-1);
301
+ color: var(--text-dim);
302
+ text-decoration: none;
303
+ font-family: var(--font-mono);
304
+ font-size: var(--text-xs);
305
+ text-transform: uppercase;
306
+ letter-spacing: var(--tracking-wide);
307
+ }
308
+ .cpub-admin-layouts-toolbar-back:hover { color: var(--accent); }
309
+ .cpub-admin-layouts-toolbar-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
310
+
311
+ .cpub-admin-layouts-toolbar-title {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ gap: var(--space-2);
315
+ flex: 1;
316
+ min-width: 0;
317
+ }
318
+ .cpub-admin-layouts-toolbar-name {
319
+ font-size: var(--text-base);
320
+ color: var(--text);
321
+ overflow: hidden;
322
+ text-overflow: ellipsis;
323
+ white-space: nowrap;
324
+ }
325
+ .cpub-admin-layouts-toolbar-state {
326
+ display: inline-block;
327
+ padding: 2px var(--space-2);
328
+ font-family: var(--font-mono);
329
+ font-size: 10px;
330
+ text-transform: uppercase;
331
+ letter-spacing: var(--tracking-wide);
332
+ border: 1px solid var(--border2);
333
+ color: var(--text-dim);
334
+ }
335
+ .cpub-admin-layouts-toolbar-state[data-state='published'] {
336
+ color: var(--accent);
337
+ border-color: var(--accent);
338
+ }
339
+ /* "modified" pill: yellow border + tint background, but theme-safe
340
+ text color (var(--text)) for WCAG contrast. The raw --yellow token
341
+ (#f59e0b) is 2.07:1 on white — fails both AA text (4.5:1) and
342
+ non-text UI (3:1). Pairing border+tint with --text gives the visual
343
+ signal (warning) without the contrast failure. Per session 160
344
+ audit catch. */
345
+ .cpub-admin-layouts-toolbar-state[data-state='modified'] {
346
+ color: var(--text);
347
+ background: var(--yellow-bg);
348
+ border-color: var(--yellow);
349
+ }
350
+
351
+ /* Panel toggles (palette + inspector hide/show — session 161 canvas-squish fix).
352
+ Same 28×28 minimum target as viewport buttons (WCAG 2.5.8 AA buffer).
353
+ Visually similar but bordered alone (not grouped) so they don't read as
354
+ part of the viewport segmented control. */
355
+ .cpub-admin-layouts-toolbar-panel-btn {
356
+ display: inline-flex;
357
+ align-items: center;
358
+ justify-content: center;
359
+ min-width: 28px;
360
+ min-height: 28px;
361
+ padding: var(--space-1) var(--space-2);
362
+ background: transparent;
363
+ border: 1px solid var(--border2);
364
+ color: var(--text-dim);
365
+ cursor: pointer;
366
+ font-size: var(--text-sm);
367
+ transition: color var(--transition-default), border-color var(--transition-default), background var(--transition-default);
368
+ }
369
+ .cpub-admin-layouts-toolbar-panel-btn:hover {
370
+ color: var(--accent);
371
+ border-color: var(--accent);
372
+ background: var(--accent-bg);
373
+ }
374
+ .cpub-admin-layouts-toolbar-panel-btn:focus-visible {
375
+ outline: 2px solid var(--accent);
376
+ outline-offset: 2px;
377
+ }
378
+ /* When the panel is hidden (aria-pressed=false), give the button a
379
+ subtle "active" tint so the admin sees it's holding a non-default
380
+ state. Pressed=true means "the panel is visible", which IS default,
381
+ so no tint needed. */
382
+ .cpub-admin-layouts-toolbar-panel-btn[aria-pressed='false'] {
383
+ color: var(--accent);
384
+ border-color: var(--accent);
385
+ background: var(--accent-bg);
386
+ }
387
+ /* Disabled state for undo/redo when stack is empty in that direction.
388
+ The aria-pressed treatment above is for toggle buttons; history
389
+ buttons have no pressed state, so the disabled rule wins on overlap. */
390
+ .cpub-admin-layouts-toolbar-panel-btn:disabled {
391
+ opacity: 0.4;
392
+ cursor: not-allowed;
393
+ color: var(--text-dim);
394
+ border-color: var(--border2);
395
+ background: transparent;
396
+ }
397
+
398
+ /* Group the undo + redo buttons visually so they read as a pair (matches
399
+ the viewport segmented control's grouping discipline). */
400
+ .cpub-admin-layouts-toolbar-history {
401
+ display: inline-flex;
402
+ gap: var(--space-1);
403
+ }
404
+
405
+ .cpub-admin-layouts-toolbar-viewport {
406
+ display: inline-flex;
407
+ background: var(--surface2);
408
+ border: 1px solid var(--border2);
409
+ }
410
+ .cpub-admin-layouts-toolbar-viewport-btn {
411
+ display: inline-flex;
412
+ align-items: center;
413
+ justify-content: center;
414
+ /* WCAG 2.5.8 AA — 24×24 minimum target. We use 28×28 (the GitHub
415
+ Primer "small" segmented-control spec) as a buffer + comfort
416
+ adjustment. The visible icon stays ~14px; padding pads the hit area. */
417
+ min-width: 28px;
418
+ min-height: 28px;
419
+ padding: var(--space-1) var(--space-2);
420
+ background: transparent;
421
+ border: 0;
422
+ color: var(--text-dim);
423
+ cursor: pointer;
424
+ font-size: var(--text-sm);
425
+ }
426
+ .cpub-admin-layouts-toolbar-viewport-btn:hover { color: var(--text); }
427
+ .cpub-admin-layouts-toolbar-viewport-btn:focus-visible {
428
+ outline: 2px solid var(--accent);
429
+ outline-offset: -2px;
430
+ }
431
+ .cpub-admin-layouts-toolbar-viewport-btn[data-active='true'] {
432
+ background: var(--surface);
433
+ color: var(--accent);
434
+ }
435
+
436
+ .cpub-admin-layouts-toolbar-indicator {
437
+ display: inline-flex;
438
+ align-items: center;
439
+ gap: var(--space-1);
440
+ font-family: var(--font-mono);
441
+ font-size: var(--text-xs);
442
+ text-transform: uppercase;
443
+ letter-spacing: var(--tracking-wide);
444
+ min-width: 140px;
445
+ }
446
+ .cpub-admin-layouts-toolbar-indicator[data-tone='neutral'] { color: var(--text-dim); }
447
+ .cpub-admin-layouts-toolbar-indicator[data-tone='pending'] { color: var(--text-dim); }
448
+ .cpub-admin-layouts-toolbar-indicator[data-tone='success'] { color: var(--accent); }
449
+ .cpub-admin-layouts-toolbar-indicator[data-tone='error'] { color: var(--red); }
450
+
451
+ .cpub-admin-layouts-toolbar-actions { display: inline-flex; gap: var(--space-2); }
452
+
453
+ .cpub-admin-layouts-toolbar-btn {
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: var(--space-1);
457
+ padding: var(--space-1) var(--space-3);
458
+ background: var(--surface);
459
+ border: var(--border-width-default) solid var(--border);
460
+ color: var(--text);
461
+ font-family: var(--font-mono);
462
+ font-size: var(--text-xs);
463
+ text-transform: uppercase;
464
+ letter-spacing: var(--tracking-wide);
465
+ cursor: pointer;
466
+ }
467
+ .cpub-admin-layouts-toolbar-btn:hover:not(:disabled) {
468
+ background: var(--surface2);
469
+ border-color: var(--accent);
470
+ }
471
+ .cpub-admin-layouts-toolbar-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
472
+ .cpub-admin-layouts-toolbar-btn:disabled { opacity: 0.5; cursor: not-allowed; }
473
+ .cpub-admin-layouts-toolbar-btn--primary {
474
+ background: var(--accent);
475
+ color: var(--surface);
476
+ border-color: var(--accent);
477
+ }
478
+ .cpub-admin-layouts-toolbar-btn--primary:hover:not(:disabled) {
479
+ background: var(--accent);
480
+ filter: brightness(1.1);
481
+ color: var(--surface);
482
+ }
483
+ </style>
@@ -1,11 +1,61 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Divider block — `<hr>` with optional variant + vertical-spacing.
4
+ *
5
+ * Existing in-article use (BlockContentRenderer) passes nothing → keeps
6
+ * the default solid line with the stock 36px margin (preserved exactly
7
+ * via the `data-spacing-y='md'` rule below).
8
+ *
9
+ * Layout-engine use (Stage E.1) passes
10
+ * `content: { variant: 'solid'|'dashed'|'dotted'|'accent',
11
+ * spacingY: 'sm'|'md'|'lg'|'xl' }`
12
+ * to customise. Variants applied via data-attrs (`var(--*)` only).
13
+ */
14
+ import { computed } from 'vue';
15
+
16
+ const props = defineProps<{
17
+ content?: Record<string, unknown>;
18
+ }>();
19
+
20
+ const variant = computed(() => {
21
+ const v = props.content?.variant;
22
+ return v === 'dashed' || v === 'dotted' || v === 'accent' ? v : 'solid';
23
+ });
24
+
25
+ const spacingY = computed(() => {
26
+ const s = props.content?.spacingY;
27
+ return s === 'sm' || s === 'lg' || s === 'xl' ? s : 'md';
28
+ });
29
+ </script>
30
+
1
31
  <template>
2
- <hr class="cpub-block-divider" />
32
+ <hr
33
+ class="cpub-block-divider"
34
+ :data-variant="variant"
35
+ :data-spacing-y="spacingY"
36
+ />
3
37
  </template>
4
38
 
5
39
  <style scoped>
6
40
  .cpub-block-divider {
7
41
  border: none;
8
42
  border-top: var(--border-width-default) solid var(--border);
9
- margin: 36px 0;
43
+ margin: 36px 0; /* default — preserved for existing block callers */
10
44
  }
45
+
46
+ /* variants */
47
+ .cpub-block-divider[data-variant='dashed'] { border-top-style: dashed; }
48
+ .cpub-block-divider[data-variant='dotted'] { border-top-style: dotted; }
49
+ .cpub-block-divider[data-variant='accent'] {
50
+ border-top-color: var(--accent);
51
+ border-top-width: 2px;
52
+ }
53
+
54
+ /* vertical spacing — explicit overrides for layout-engine sections.
55
+ `md` keeps the legacy 36px margin so existing block callers (passing
56
+ no content prop, defaulting to 'md') see no visual change. */
57
+ .cpub-block-divider[data-spacing-y='sm'] { margin: 16px 0; }
58
+ .cpub-block-divider[data-spacing-y='md'] { margin: 36px 0; }
59
+ .cpub-block-divider[data-spacing-y='lg'] { margin: 64px 0; }
60
+ .cpub-block-divider[data-spacing-y='xl'] { margin: 96px 0; }
11
61
  </style>