@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.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useLayout.ts +34 -41
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/package.json +10 -7
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +11 -19
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +16 -30
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +11 -21
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +11 -26
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +5 -13
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +17 -18
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionContests.vue +0 -193
- package/components/sections/SectionCustomHtml.vue +0 -70
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionEditorial.vue +0 -138
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionHubs.vue +0 -247
- package/components/sections/SectionImage.vue +0 -104
- package/components/sections/SectionParagraph.vue +0 -55
- package/components/sections/SectionStats.vue +0 -151
|
@@ -38,10 +38,11 @@
|
|
|
38
38
|
* an admin-only error placeholder.
|
|
39
39
|
*/
|
|
40
40
|
import { computed } from 'vue';
|
|
41
|
-
import type {
|
|
42
|
-
import {
|
|
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
|
-
<
|
|
156
|
+
<LayoutRow
|
|
116
157
|
v-for="row in zone.rows"
|
|
117
158
|
:key="row.id"
|
|
118
|
-
|
|
119
|
-
:
|
|
120
|
-
:
|
|
121
|
-
:
|
|
122
|
-
:
|
|
123
|
-
:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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>
|