@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
@@ -38,10 +38,11 @@
38
38
  * an admin-only error placeholder.
39
39
  */
40
40
  import { computed } from 'vue';
41
- import type { LayoutSection, LayoutPayload, LayoutZoneClient } from '../composables/useLayout';
42
- import { useSectionRegistry } from '../sections/registry';
41
+ import type { LayoutPayload, LayoutZoneClient, LayoutRow as LayoutRowType } from '../composables/useLayout';
42
+ import type { EditorSelection } from '../composables/useLayoutEditor';
43
+ import LayoutRow from './LayoutRow.vue';
43
44
 
44
- const props = defineProps<{
45
+ const props = withDefaults(defineProps<{
45
46
  /** Route path this layout is for — e.g. '/', '/blog', '/hubs/foo'. */
46
47
  route: string;
47
48
  /** Zone slug — must match a zone declared in the layout's storage. */
@@ -52,53 +53,85 @@ const props = defineProps<{
52
53
  * pane to render the in-progress draft without a save round-trip.
53
54
  */
54
55
  previewOverride?: LayoutPayload | null;
55
- }>();
56
+ /**
57
+ * Editor mode flag. When `true`, the row + section wrappers get
58
+ * `--editable` modifier classes (dashed hover outline + type-label
59
+ * badge) AND Phase 3b/A's selection chrome (tabindex, click→select,
60
+ * keyboard activation, --selected outline). Drag-drop itself wires
61
+ * up in the next commits of this session via @vue-dnd-kit/core
62
+ * makeDraggable/makeDroppable on the section/row containers.
63
+ *
64
+ * Default `false` so public render paths (pages/index.vue,
65
+ * [...customPath].vue) are byte-pattern unchanged. The selection
66
+ * callback prop is optional — its absence makes the section
67
+ * affordances inert (still focusable visually but click is a no-op),
68
+ * so consumers without an editor in scope don't accidentally light
69
+ * up selection behavior.
70
+ *
71
+ * See docs/plans/phase-3-editor.md Phase 3a.1 + 3b/A.
72
+ */
73
+ editable?: boolean;
74
+ /**
75
+ * Selection callback — called when the admin clicks (or presses
76
+ * Enter/Space on) a section in editable mode. The editor page wires
77
+ * this to `useLayoutEditor.select` so the inspector dispatcher
78
+ * switches to the section-config form. Optional — when absent,
79
+ * editable=true still paints the chrome but click is inert.
80
+ */
81
+ onSelect?: (selection: EditorSelection) => void;
82
+ /**
83
+ * The currently-selected target — drives the `--selected` modifier
84
+ * class on the section or row wrapper. Read from the editor's
85
+ * reactive `selectedId` ref upstream so the highlight updates without
86
+ * a re-render contract. Optional; defaults to null (no highlight).
87
+ */
88
+ selectedId?: EditorSelection | null;
89
+ /**
90
+ * Phase 3b/B — cross-zone lookup, threaded down to each LayoutRow's
91
+ * drop handler. Synthesised by AdminLayoutsCanvas from the editor's
92
+ * full draft (which the public render path doesn't have access to).
93
+ * Without it, cross-row drops fall back to noop in the dispatcher.
94
+ */
95
+ findRow?: (rowId: string) => LayoutRowType | null;
96
+ /** Phase 3b/B — zone-of-row lookup for narration. */
97
+ findZone?: (rowId: string) => string | null;
98
+ /** Phase 3b/B — all zone slugs in the active layout. Drives the
99
+ * "Move to zone…" popover's option list. */
100
+ zoneSlugs?: string[];
101
+ /** Phase 3b/B — landing-row lookup for the "Move to zone…" path. */
102
+ findFirstRowInZone?: (zoneSlug: string) => LayoutRowType | null;
103
+ /** Session 164 polish — remove row callback threaded down to LayoutRow. */
104
+ onRemoveRow?: (zoneSlug: string, rowId: string) => void;
105
+ /**
106
+ * Phase 3c — closure threading the editor's live draft into the
107
+ * resize composable, via LayoutRow. The closure stays optional so
108
+ * LayoutSlot's public render path (no editor in scope) skips it +
109
+ * the resize handle isn't rendered. Synthesised by AdminLayoutsCanvas
110
+ * from `editor.draft` so the row's resize closure sees the LIVE
111
+ * mutating draft (not the previewOverride snapshot — those diverge
112
+ * mid-edit).
113
+ */
114
+ getDraft?: () => import('@commonpub/server').LayoutRecord | null;
115
+ }>(), {
116
+ previewOverride: null,
117
+ editable: false,
118
+ onSelect: undefined,
119
+ selectedId: null,
120
+ findRow: undefined,
121
+ findZone: undefined,
122
+ zoneSlugs: () => [],
123
+ findFirstRowInZone: undefined,
124
+ onRemoveRow: undefined,
125
+ getDraft: undefined,
126
+ });
56
127
 
57
128
  const { layout, pending } = useLayout(props.route);
58
- const features = useFeatures();
59
- const { isAuthenticated, user } = useAuth();
60
- const sectionRegistry = useSectionRegistry();
61
129
 
62
130
  const activeLayout = computed(() => props.previewOverride ?? layout.value);
63
131
 
64
132
  const zone = computed<LayoutZoneClient | null>(
65
133
  () => activeLayout.value?.zones.find((z: LayoutZoneClient) => z.zone === props.zone) ?? null,
66
134
  );
67
-
68
- function isFeatureOn(featureGate: string | undefined): boolean {
69
- if (!featureGate) return true;
70
- return (features.features.value as unknown as Record<string, boolean>)?.[featureGate] ?? false;
71
- }
72
-
73
- function currentRole(): string {
74
- if (!isAuthenticated.value) return 'anonymous';
75
- return user.value?.role ?? 'member';
76
- }
77
-
78
- function sectionVisible(s: LayoutSection): boolean {
79
- if (!s.enabled) return false;
80
- const v = s.visibility;
81
- if (!v) return true;
82
- if (v.features && v.features.some((f: string) => !isFeatureOn(f))) return false;
83
- if (v.roles && v.roles.length > 0 && !v.roles.includes(currentRole())) return false;
84
- // hideAt is a viewport-level filter — applied via CSS, not here
85
- return true;
86
- }
87
-
88
- /**
89
- * Resolve colSpan honouring the responsive fallback chain. Defers the
90
- * viewport choice to CSS — we use `--cpub-section-cols-{sm|md|lg}`
91
- * custom properties on each section and let media queries pick which
92
- * one becomes the active `grid-column: span N` via `attr()`-style
93
- * values. For now, just use the base colSpan (12 = full width on mobile
94
- * happens via the row's flex-wrap).
95
- */
96
- function resolveColSpan(s: LayoutSection, viewport: 'lg' | 'md' | 'sm'): number {
97
- if (viewport === 'lg') return s.responsive?.lg ?? s.colSpan;
98
- if (viewport === 'md') return s.responsive?.md ?? s.responsive?.lg ?? s.colSpan;
99
- // Mobile default 12 unless explicitly overridden
100
- return s.responsive?.sm ?? 12;
101
- }
102
135
  </script>
103
136
 
104
137
  <template>
@@ -111,67 +144,32 @@ function resolveColSpan(s: LayoutSection, viewport: 'lg' | 'md' | 'sm'): number
111
144
  All four are valid "absence" cases — fall back to legacy rendering
112
145
  via the page's v-if structure.
113
146
  -->
147
+ <!--
148
+ Phase 3b/A extraction: row + section rendering moved to <LayoutRow>
149
+ so each row instance can own its own `makeDroppable` template ref
150
+ (dnd-kit composables run per-component setup; one row instance per
151
+ component is the natural fit). The HTML SHAPE is preserved — same
152
+ .cpub-layout-row + .cpub-layout-section classes, same data-* attrs
153
+ — so existing tests + selectors keep working unchanged.
154
+ -->
114
155
  <template v-if="zone && zone.rows.length > 0">
115
- <div
156
+ <LayoutRow
116
157
  v-for="row in zone.rows"
117
158
  :key="row.id"
118
- class="cpub-layout-row"
119
- :data-row-id="row.id"
120
- :data-gap="row.config?.gap ?? 'md'"
121
- :data-align="row.config?.align ?? 'stretch'"
122
- :data-padding-y="row.config?.paddingY ?? 'none'"
123
- :style="row.config?.background ? { background: row.config.background } : {}"
124
- >
125
- <div
126
- v-for="section in row.sections.filter(sectionVisible)"
127
- :key="section.id"
128
- class="cpub-layout-section"
129
- :data-section-id="section.id"
130
- :data-section-type="section.type"
131
- :data-hide-sm="section.visibility?.hideAt?.includes('sm') ? 'true' : 'false'"
132
- :data-hide-md="section.visibility?.hideAt?.includes('md') ? 'true' : 'false'"
133
- :data-hide-lg="section.visibility?.hideAt?.includes('lg') ? 'true' : 'false'"
134
- :style="{
135
- '--cpub-section-cols-sm': resolveColSpan(section, 'sm'),
136
- '--cpub-section-cols-md': resolveColSpan(section, 'md'),
137
- '--cpub-section-cols-lg': resolveColSpan(section, 'lg'),
138
- }"
139
- >
140
- <!--
141
- Section render path:
142
- 1. Look up the section's `type` in the registry (one shared
143
- instance per app process, populated at module-load time in
144
- sections/registry.ts).
145
- 2. If registered, render via <component :is> with the section's
146
- `config` + computed `meta`. Vue's component-resolver handles
147
- both functional + SFC renderers.
148
- 3. If NOT registered, fall back to the admin-only placeholder
149
- so admins can see "this section type isn't installed" while
150
- end users see nothing for the section (an unknown section
151
- shouldn't leak rendering debug info to the public).
152
- -->
153
- <component
154
- v-if="sectionRegistry.has(section.type)"
155
- :is="sectionRegistry.get(section.type)!.component"
156
- :config="section.config"
157
- :meta="{
158
- route,
159
- zone,
160
- isPreview: !!previewOverride,
161
- effectiveColSpan: resolveColSpan(section, 'lg'),
162
- sectionId: section.id,
163
- }"
164
- />
165
- <div
166
- v-else
167
- class="cpub-layout-section-placeholder"
168
- :aria-label="`Unregistered section type: ${section.type}`"
169
- >
170
- <code>{{ section.type }}</code>
171
- <span class="cpub-layout-section-placeholder-hint">section type not registered</span>
172
- </div>
173
- </div>
174
- </div>
159
+ :row="row"
160
+ :route="route"
161
+ :zone="zone.zone"
162
+ :editable="editable"
163
+ :is-preview="!!previewOverride"
164
+ :on-select="onSelect"
165
+ :selected-id="selectedId"
166
+ :find-row="findRow"
167
+ :find-zone="findZone"
168
+ :zone-slugs="zoneSlugs"
169
+ :find-first-row-in-zone="findFirstRowInZone"
170
+ :on-remove-row="onRemoveRow"
171
+ :get-draft="getDraft"
172
+ />
175
173
  </template>
176
174
 
177
175
  <!-- Loading shimmer while initial fetch is in flight (no layout payload yet) -->
@@ -182,68 +180,12 @@ function resolveColSpan(s: LayoutSection, viewport: 'lg' | 'md' | 'sm'): number
182
180
  </template>
183
181
 
184
182
  <style scoped>
185
- .cpub-layout-row {
186
- display: grid;
187
- grid-template-columns: repeat(12, 1fr);
188
- gap: var(--space-4);
189
- width: 100%;
190
- }
191
- .cpub-layout-row[data-gap='none'] { gap: 0; }
192
- .cpub-layout-row[data-gap='sm'] { gap: var(--space-2); }
193
- .cpub-layout-row[data-gap='md'] { gap: var(--space-4); }
194
- .cpub-layout-row[data-gap='lg'] { gap: var(--space-6); }
195
-
196
- .cpub-layout-row[data-align='center'] { align-items: center; }
197
- .cpub-layout-row[data-align='start'] { align-items: start; }
198
- .cpub-layout-row[data-align='stretch'] { align-items: stretch; }
199
-
200
- .cpub-layout-row[data-padding-y='sm'] { padding-block: var(--space-2); }
201
- .cpub-layout-row[data-padding-y='md'] { padding-block: var(--space-4); }
202
- .cpub-layout-row[data-padding-y='lg'] { padding-block: var(--space-6); }
203
- .cpub-layout-row[data-padding-y='xl'] { padding-block: var(--space-8); }
204
-
205
- /* Section span: default to lg (desktop). Media queries swap.
206
- --cpub-section-cols-* are set per-section via inline style. */
207
- .cpub-layout-section {
208
- grid-column: span var(--cpub-section-cols-lg, 12);
209
- min-width: 0;
210
- }
211
-
212
- @media (max-width: 1024px) {
213
- .cpub-layout-section { grid-column: span var(--cpub-section-cols-md, var(--cpub-section-cols-lg, 12)); }
214
- }
215
- @media (max-width: 640px) {
216
- .cpub-layout-section { grid-column: span var(--cpub-section-cols-sm, 12); }
217
- }
218
-
219
- /* hideAt — orthogonal to colSpan. Use display:none so layout doesn't reserve space. */
220
- .cpub-layout-section[data-hide-sm='true'] { @media (max-width: 640px) { display: none; } }
221
- .cpub-layout-section[data-hide-md='true'] { @media (min-width: 641px) and (max-width: 1024px) { display: none; } }
222
- .cpub-layout-section[data-hide-lg='true'] { @media (min-width: 1025px) { display: none; } }
223
-
224
- /* Placeholder shown when no renderer is registered for the section type */
225
- .cpub-layout-section-placeholder {
226
- padding: var(--space-4);
227
- background: var(--surface2);
228
- border: 1px dashed var(--border2);
229
- font-family: var(--font-mono);
230
- font-size: var(--text-sm);
231
- color: var(--text-dim);
232
- text-align: center;
233
- display: flex;
234
- flex-direction: column;
235
- gap: 4px;
236
- align-items: center;
237
- }
238
- .cpub-layout-section-placeholder code {
239
- color: var(--accent);
240
- }
241
- .cpub-layout-section-placeholder-hint {
242
- font-size: var(--text-xs);
243
- color: var(--text-faint);
244
- }
245
-
246
- /* Skeleton loading state */
183
+ /*
184
+ * LayoutSlot is now just a zone walker. All row + section chrome moved
185
+ * to LayoutRow.vue in Phase 3b/A so per-row makeDroppable can attach
186
+ * to its own template ref. Only the skeleton loader (used when
187
+ * useLayout is still in flight) lives here.
188
+ */
247
189
  .cpub-layout-skeleton {
248
190
  display: flex;
249
191
  flex-direction: column;
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PageFrame — the ONE canonical page frame: arranges the three standard
4
+ * zones (full-width above; main + sidebar grid below) at a single
5
+ * max-width + single sidebar width + single responsive collapse.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * The "frame" (how zones are arranged + sized) was hand-duplicated in
10
+ * every page, and the copies drifted into FOUR divergent definitions:
11
+ * - homepage `index.vue` `.cpub-main-layout` → 1280px, `1fr 300px`, pad 28/32/48, gap 32
12
+ * - custom `[...customPath].vue` → 1280px, `minmax(0,1fr) 320px`, pad var(--space-4)
13
+ * - editor `AdminLayoutsCanvas.vue` → 375/768/100%, NO main+sidebar grid (zones stacked in boxes)
14
+ * → the editor preview was structurally wrong vs production (broken
15
+ * WYSIWYG) and homepage vs custom-page disagreed (300 vs 320 sidebar).
16
+ * This collapses them to one, using the LIVE HOMEPAGE's exact values
17
+ * (the homepage is the primary live surface the editor previews) so
18
+ * adoption is WYSIWYG-faithful. See `LayoutSlot.vue` header.
19
+ *
20
+ * ## API: slots, not props
21
+ *
22
+ * Named slots `#full-width` / `#main` / `#sidebar` so callers keep their
23
+ * bespoke zone content (homepage's mobile-hoist / powered-badge; the
24
+ * editor's per-zone labels + add-row). Each region renders only when its
25
+ * slot has content — checked via `$slots` in the template (NOT a computed
26
+ * over `useSlots()`, which isn't reactive and can go stale).
27
+ *
28
+ * `editable` does NOT change the arrangement (the editor must preview the
29
+ * REAL frame); it only flags edit mode + is forwarded to the zone
30
+ * scoped-slots so the editor canvas can wrap each region with its own
31
+ * chrome without re-diverging the layout.
32
+ *
33
+ * ## Full-width is FULL-BLEED
34
+ *
35
+ * The `#full-width` slot spans the full container width (edge-to-edge) —
36
+ * matching the live homepage, where the full-width LayoutSlot is a direct
37
+ * child of the page root (NO max-width) and only `.cpub-main-layout`
38
+ * (main+sidebar) is capped at 1280. So the max-width / page padding live on
39
+ * `.cpub-page-frame-grid`, NOT the outer frame; the outer frame is a plain
40
+ * full-width block. (Verified against `index.vue` + the live commonpub.io
41
+ * homepage — the hero renders edge-to-edge.)
42
+ *
43
+ * Uniquely-named `cpub-page-frame-*` classes per feedback-view-identity-classes.
44
+ * `var(--*)` only — the frame's literal values live in `--cpub-frame-*`
45
+ * custom props (one place; Phase 4 frame variants override them).
46
+ */
47
+ withDefaults(defineProps<{ editable?: boolean }>(), { editable: false });
48
+ </script>
49
+
50
+ <template>
51
+ <div class="cpub-page-frame" :class="{ 'cpub-page-frame--editable': editable }">
52
+ <div v-if="$slots['full-width']" class="cpub-page-frame-full">
53
+ <slot name="full-width" :editable="editable" />
54
+ </div>
55
+
56
+ <div
57
+ v-if="$slots.main || $slots.sidebar"
58
+ class="cpub-page-frame-grid"
59
+ :data-with-sidebar="$slots.sidebar ? 'yes' : 'no'"
60
+ >
61
+ <main v-if="$slots.main" class="cpub-page-frame-main">
62
+ <slot name="main" :editable="editable" />
63
+ </main>
64
+ <aside v-if="$slots.sidebar" class="cpub-page-frame-sidebar">
65
+ <slot name="sidebar" :editable="editable" />
66
+ </aside>
67
+ </div>
68
+ </div>
69
+ </template>
70
+
71
+ <style scoped>
72
+ .cpub-page-frame {
73
+ /* Canonical frame tokens — ONE place. Values replicate the live homepage
74
+ so the editor canvas (which previews the homepage layout via PageFrame)
75
+ is faithful. Override on a wrapper for Phase 4 frame variants. */
76
+ --cpub-frame-max: 1280px;
77
+ --cpub-frame-sidebar: 300px;
78
+ --cpub-frame-gap: 32px;
79
+ --cpub-frame-pad: 28px 32px 48px;
80
+ /* Plain full-width block — NO max-width / padding here (matches the
81
+ homepage's plain root <div>). The cap lives on the grid below so the
82
+ full-width slot stays edge-to-edge. */
83
+ width: 100%;
84
+ }
85
+
86
+ /* Full-bleed: spans the whole frame width (= the hero edge-to-edge). */
87
+ .cpub-page-frame-full { width: 100%; }
88
+
89
+ /* The constrained content area (main + sidebar) — this is the equivalent of
90
+ the homepage's `.cpub-main-layout`. */
91
+ .cpub-page-frame-grid {
92
+ max-width: var(--cpub-frame-max);
93
+ margin: 0 auto;
94
+ padding: var(--cpub-frame-pad);
95
+ display: grid;
96
+ gap: var(--cpub-frame-gap);
97
+ align-items: start;
98
+ }
99
+ .cpub-page-frame-grid[data-with-sidebar='yes'] {
100
+ grid-template-columns: minmax(0, 1fr) var(--cpub-frame-sidebar);
101
+ }
102
+ .cpub-page-frame-grid[data-with-sidebar='no'] {
103
+ grid-template-columns: 1fr;
104
+ }
105
+ .cpub-page-frame-main { min-width: 0; }
106
+
107
+ /* Sidebar collapses below main on tablet/phone (matches the homepage). */
108
+ @media (max-width: 1024px) {
109
+ .cpub-page-frame-grid[data-with-sidebar='yes'] {
110
+ grid-template-columns: 1fr;
111
+ }
112
+ }
113
+ @media (max-width: 640px) {
114
+ .cpub-page-frame-grid { padding: 24px 16px; }
115
+ }
116
+ </style>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * <AdminLayoutsAnnouncer> — the visible-to-screen-readers, hidden-to-
4
+ * sighted-users companion to `useLayoutAnnouncer`. Mirrors the
5
+ * singleton refs into TWO live regions: one ASSERTIVE for drag/drop +
6
+ * Move Up/Down (time-critical), one POLITE for undo/redo (informational).
7
+ *
8
+ * Why two regions? The assertive region INTERRUPTS whatever the SR was
9
+ * announcing, which is right for drag — the user's next arrow press
10
+ * lands on the new state, so they need the prior result NOW. But undo
11
+ * is the user telling the editor what to do; the editor's
12
+ * acknowledgement shouldn't interrupt — it should queue politely. ARIA
13
+ * 1.2 explicitly supports this kind of dual-channel design (a single
14
+ * page can have multiple live regions).
15
+ *
16
+ * Each region carries its own `role` + `aria-live` pairing:
17
+ * - assertive: `role="status"` + explicit `aria-live="assertive"`.
18
+ * ARIA 1.2 defines status's implicit aria-live as "polite"; the
19
+ * explicit attribute overrides per the spec ("Authors MAY override
20
+ * implicit values"). The pair reaches both screen reader families
21
+ * (NVDA/JAWS read the explicit aria-live; VoiceOver reads the role).
22
+ * - polite: `role="status"` with no explicit aria-live, so the
23
+ * implicit "polite" applies. Cleaner than `aria-live="polite"` on
24
+ * `role="status"` (redundant by spec).
25
+ *
26
+ * `aria-atomic="true"` on BOTH so the whole message is read on change,
27
+ * not just the diff — drag narration like "moved" + "3 of 5" needs to
28
+ * land as one phrase, and undo's "Undid: <label>" needs the label.
29
+ *
30
+ * Mount this ONCE per editor page; the singleton design means
31
+ * multiple instances would mirror identical content (harmless but
32
+ * wasteful).
33
+ */
34
+ import { useLayoutAnnouncer } from '../../../composables/useLayoutAnnouncer';
35
+
36
+ const { message, politeMessage } = useLayoutAnnouncer();
37
+ </script>
38
+
39
+ <template>
40
+ <!-- Assertive: drag/drop + Move Up/Down state changes. -->
41
+ <div
42
+ role="status"
43
+ aria-live="assertive"
44
+ aria-atomic="true"
45
+ class="cpub-sr-only"
46
+ >{{ message }}</div>
47
+ <!-- Polite: undo/redo + non-time-critical confirmations. -->
48
+ <div
49
+ role="status"
50
+ aria-atomic="true"
51
+ class="cpub-sr-only"
52
+ >{{ politeMessage }}</div>
53
+ </template>