@commonpub/layer 0.22.1 → 0.23.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.
@@ -0,0 +1,266 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * &lt;LayoutSlot&gt; — renders one zone of a route's active layout.
4
+ *
5
+ * Page (e.g. `pages/index.vue`) is the FRAME; this component is the
6
+ * fillings for each zone the page exposes. Pages declare zones like:
7
+ *
8
+ * ```vue
9
+ * &lt;template&gt;
10
+ * &lt;LayoutSlot route="/" zone="full-width" /&gt;
11
+ * &lt;div class="cpub-content-grid"&gt;
12
+ * &lt;LayoutSlot route="/" zone="main" /&gt;
13
+ * &lt;aside&gt;&lt;LayoutSlot route="/" zone="sidebar" /&gt;&lt;/aside&gt;
14
+ * &lt;/div&gt;
15
+ * &lt;/template&gt;
16
+ * ```
17
+ *
18
+ * For each row in the zone:
19
+ * - Renders a 12-column CSS Grid container with the row's gap setting
20
+ * - For each enabled + visible section: renders the registered section
21
+ * component, scoped to its resolved colSpan
22
+ *
23
+ * Visibility filters (run client-side on every render so role/feature
24
+ * changes propagate without a re-fetch):
25
+ * - section.enabled === false → skipped
26
+ * - visibility.features intersected with active feature flags must
27
+ * have ALL flags ON
28
+ * - visibility.roles must include the current user's role (or
29
+ * 'anonymous' for unauthenticated)
30
+ *
31
+ * When the layout-engine feature is OFF (the default for v1), `useLayout`
32
+ * resolves to null and this component renders nothing — pages that
33
+ * adopt &lt;LayoutSlot&gt; need a v-if fallback to the legacy renderer
34
+ * during the migration window.
35
+ *
36
+ * Section rendering: the registry component map is populated by a Nuxt
37
+ * plugin at startup (Phase 1c). Until then, unknown types render
38
+ * an admin-only error placeholder.
39
+ */
40
+ import { computed } from 'vue';
41
+ import type { LayoutSection, LayoutPayload, LayoutZoneClient } from '../composables/useLayout';
42
+ import { useSectionRegistry } from '../sections/registry';
43
+
44
+ const props = defineProps<{
45
+ /** Route path this layout is for — e.g. '/', '/blog', '/hubs/foo'. */
46
+ route: string;
47
+ /** Zone slug — must match a zone declared in the layout's storage. */
48
+ zone: string;
49
+ /**
50
+ * Optional override — when set, this layout is rendered INSTEAD of
51
+ * fetching from /api/layouts/by-route. Used by the editor preview
52
+ * pane to render the in-progress draft without a save round-trip.
53
+ */
54
+ previewOverride?: LayoutPayload | null;
55
+ }>();
56
+
57
+ const { layout, pending } = useLayout(props.route);
58
+ const features = useFeatures();
59
+ const { isAuthenticated, user } = useAuth();
60
+ const sectionRegistry = useSectionRegistry();
61
+
62
+ const activeLayout = computed(() => props.previewOverride ?? layout.value);
63
+
64
+ const zone = computed<LayoutZoneClient | null>(
65
+ () => activeLayout.value?.zones.find((z: LayoutZoneClient) => z.zone === props.zone) ?? null,
66
+ );
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
+ </script>
103
+
104
+ <template>
105
+ <!--
106
+ Renders nothing when:
107
+ - layout-engine feature is off (useLayout returns null)
108
+ - no layout exists for this route
109
+ - no zone of that slug in the layout
110
+ - zone has zero rows
111
+ All four are valid "absence" cases — fall back to legacy rendering
112
+ via the page's v-if structure.
113
+ -->
114
+ <template v-if="zone && zone.rows.length > 0">
115
+ <div
116
+ v-for="row in zone.rows"
117
+ :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>
175
+ </template>
176
+
177
+ <!-- Loading shimmer while initial fetch is in flight (no layout payload yet) -->
178
+ <div v-else-if="pending && !previewOverride" class="cpub-layout-skeleton" aria-hidden="true">
179
+ <div class="cpub-layout-skeleton-row" />
180
+ <div class="cpub-layout-skeleton-row" />
181
+ </div>
182
+ </template>
183
+
184
+ <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 */
247
+ .cpub-layout-skeleton {
248
+ display: flex;
249
+ flex-direction: column;
250
+ gap: var(--space-4);
251
+ padding: var(--space-4);
252
+ }
253
+ .cpub-layout-skeleton-row {
254
+ height: 60px;
255
+ background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%);
256
+ background-size: 200% 100%;
257
+ animation: cpub-layout-skel 1.4s ease-in-out infinite;
258
+ }
259
+ @keyframes cpub-layout-skel {
260
+ 0% { background-position: 200% 0; }
261
+ 100% { background-position: -200% 0; }
262
+ }
263
+ @media (prefers-reduced-motion: reduce) {
264
+ .cpub-layout-skeleton-row { animation: none; }
265
+ }
266
+ </style>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: content-feed — a data-driven grid of content cards.
4
+ *
5
+ * Phase 1c starter and the first DATA section. Fetches `/api/content`
6
+ * with config-driven filter parameters; renders the existing
7
+ * `<ContentCard>` so the visual identity matches feeds elsewhere on the
8
+ * site.
9
+ *
10
+ * Each instance fetches independently (no global feed cache) — Nuxt's
11
+ * useFetch dedupes by `key`, which we derive from the config so two
12
+ * identically-configured feeds share a single request while two
13
+ * differently-configured feeds (e.g. main + sidebar with different
14
+ * `sort`) hit the endpoint separately.
15
+ *
16
+ * SSR-safe: useFetch fetches at setup() and includes the payload in the
17
+ * hydration snapshot.
18
+ *
19
+ * `var(--*)` only.
20
+ */
21
+ import { computed } from 'vue';
22
+ import type { PaginatedResponse, Serialized, ContentListItem } from '@commonpub/server';
23
+ import type { SectionRenderProps } from '@commonpub/ui';
24
+
25
+ interface ContentFeedConfig extends Record<string, unknown> {
26
+ heading: string;
27
+ contentType: string;
28
+ sort: 'recent' | 'popular' | 'featured' | 'editorial';
29
+ limit: number;
30
+ columns: 1 | 2 | 3 | 4;
31
+ tag: string;
32
+ featured: boolean;
33
+ }
34
+
35
+ const props = defineProps<SectionRenderProps<ContentFeedConfig>>();
36
+
37
+ // Build the API query — server expects `type` (single value or absent),
38
+ // `sort`, `limit`, `tag`, `featured`. Omit empty strings so the validator
39
+ // treats them as absent (vs the empty-string=match-empty trap).
40
+ const apiQuery = computed(() => {
41
+ const q: Record<string, unknown> = {
42
+ status: 'published',
43
+ sort: props.config.sort,
44
+ limit: Math.min(Math.max(props.config.limit, 1), 24),
45
+ };
46
+ if (props.config.contentType) q.type = props.config.contentType;
47
+ if (props.config.tag) q.tag = props.config.tag;
48
+ if (props.config.featured) q.featured = true;
49
+ return q;
50
+ });
51
+
52
+ // Stable key so two identical content-feed sections on the same page
53
+ // share a single request, while different configurations don't collide.
54
+ const fetchKey = computed(
55
+ () => `section-content-feed:${JSON.stringify(apiQuery.value)}`,
56
+ );
57
+
58
+ // NO await — section is rendered inside <LayoutSlot> deep in the tree;
59
+ // awaiting top-level here would require Suspense on every parent, which
60
+ // neither the production page-render path nor the editor preview pane
61
+ // is set up for. The page (`/`, `/about`, etc.) already does its own
62
+ // `await useFetch` for content via the legacy renderer, so initial
63
+ // load is data-ready; this section's fetch is a fresh request per
64
+ // instance + config. Pending state surfaces in the template instead.
65
+ const { data: feed, pending } = useFetch<PaginatedResponse<Serialized<ContentListItem>>>(
66
+ '/api/content',
67
+ {
68
+ query: apiQuery,
69
+ key: fetchKey.value,
70
+ // Empty-result handler: surface a friendly empty state in the template
71
+ // rather than throwing. 404 wouldn't be a thing on /api/content anyway.
72
+ },
73
+ );
74
+
75
+ const items = computed(() => feed.value?.items ?? []);
76
+ const isEmpty = computed(() => !pending.value && items.value.length === 0);
77
+ </script>
78
+
79
+ <template>
80
+ <section
81
+ class="cpub-section-content-feed"
82
+ :aria-labelledby="config.heading ? `section-feed-${meta.sectionId}` : undefined"
83
+ >
84
+ <h2
85
+ v-if="config.heading"
86
+ :id="`section-feed-${meta.sectionId}`"
87
+ class="cpub-section-content-feed-heading"
88
+ >
89
+ {{ config.heading }}
90
+ </h2>
91
+
92
+ <div v-if="pending" class="cpub-section-content-feed-loading">
93
+ <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true" />
94
+ <span>Loading…</span>
95
+ </div>
96
+
97
+ <div
98
+ v-else-if="!isEmpty"
99
+ class="cpub-section-content-feed-grid"
100
+ :data-columns="config.columns"
101
+ >
102
+ <ContentCard
103
+ v-for="item in items"
104
+ :key="item.id"
105
+ :item="item"
106
+ />
107
+ </div>
108
+
109
+ <p v-else class="cpub-section-content-feed-empty">
110
+ No content yet.
111
+ </p>
112
+ </section>
113
+ </template>
114
+
115
+ <style scoped>
116
+ .cpub-section-content-feed {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: var(--space-3);
120
+ }
121
+ .cpub-section-content-feed-heading {
122
+ font-family: var(--font-mono);
123
+ font-size: var(--text-xs);
124
+ font-weight: 700;
125
+ text-transform: uppercase;
126
+ letter-spacing: 0.08em;
127
+ color: var(--text-faint);
128
+ margin: 0;
129
+ padding-bottom: var(--space-2);
130
+ border-bottom: var(--border-width-default) solid var(--border);
131
+ }
132
+ .cpub-section-content-feed-grid {
133
+ display: grid;
134
+ gap: var(--space-3);
135
+ }
136
+ .cpub-section-content-feed-grid[data-columns='1'] { grid-template-columns: 1fr; }
137
+ .cpub-section-content-feed-grid[data-columns='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
138
+ .cpub-section-content-feed-grid[data-columns='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
139
+ .cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
140
+
141
+ /* Responsive collapse — multi-col grids stack on tablet/mobile */
142
+ @media (max-width: 1024px) {
143
+ .cpub-section-content-feed-grid[data-columns='3'],
144
+ .cpub-section-content-feed-grid[data-columns='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
145
+ }
146
+ @media (max-width: 640px) {
147
+ .cpub-section-content-feed-grid { grid-template-columns: 1fr; }
148
+ }
149
+
150
+ .cpub-section-content-feed-loading,
151
+ .cpub-section-content-feed-empty {
152
+ display: flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ gap: var(--space-2);
156
+ padding: var(--space-6);
157
+ color: var(--text-faint);
158
+ font-size: var(--text-sm);
159
+ }
160
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: divider — a horizontal rule.
4
+ *
5
+ * Phase 1 proof-of-life — the simplest possible section, validating that:
6
+ * - `SectionRegistry.register()` accepts a Vue component
7
+ * - `<LayoutSlot>` resolves the section type slug to a renderer
8
+ * - The renderer receives `config` + `meta` and produces DOM
9
+ *
10
+ * Phase 1c adds the real catalogue (hero / heading / paragraph / image /
11
+ * content-feed). Until then, dropping a divider into a layout is the
12
+ * end-to-end smoke test of the layout engine.
13
+ *
14
+ * Uses `var(--*)` only (rule #3); inherits all colors / spacing from
15
+ * the active theme.
16
+ */
17
+ import type { SectionRenderProps } from '@commonpub/ui';
18
+
19
+ interface DividerConfig extends Record<string, unknown> {
20
+ variant: 'solid' | 'dashed' | 'dotted' | 'accent';
21
+ spacingY: 'sm' | 'md' | 'lg' | 'xl';
22
+ }
23
+
24
+ defineProps<SectionRenderProps<DividerConfig>>();
25
+ </script>
26
+
27
+ <template>
28
+ <hr
29
+ class="cpub-section-divider"
30
+ :data-variant="config.variant"
31
+ :data-spacing-y="config.spacingY"
32
+ :aria-label="`section ${meta.sectionId}`"
33
+ />
34
+ </template>
35
+
36
+ <style scoped>
37
+ .cpub-section-divider {
38
+ margin-block: var(--space-4);
39
+ border: 0;
40
+ border-top: var(--border-width-default) solid var(--border2);
41
+ width: 100%;
42
+ }
43
+
44
+ .cpub-section-divider[data-variant='dashed'] { border-top-style: dashed; }
45
+ .cpub-section-divider[data-variant='dotted'] { border-top-style: dotted; }
46
+ .cpub-section-divider[data-variant='accent'] {
47
+ border-top-color: var(--accent);
48
+ border-top-width: var(--border-width-thick);
49
+ }
50
+
51
+ .cpub-section-divider[data-spacing-y='sm'] { margin-block: var(--space-2); }
52
+ .cpub-section-divider[data-spacing-y='md'] { margin-block: var(--space-4); }
53
+ .cpub-section-divider[data-spacing-y='lg'] { margin-block: var(--space-6); }
54
+ .cpub-section-divider[data-spacing-y='xl'] { margin-block: var(--space-8); }
55
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: heading — a single configurable heading (h1–h4)
4
+ * with optional eyebrow + subline.
5
+ *
6
+ * Phase 1c starter. Semantic level is admin-chosen — the auto-form
7
+ * inspector (Phase 3e) will warn on multiple h1s per layout.
8
+ *
9
+ * `var(--*)` only.
10
+ */
11
+ import { computed } from 'vue';
12
+ import type { SectionRenderProps } from '@commonpub/ui';
13
+
14
+ interface HeadingConfig extends Record<string, unknown> {
15
+ text: string;
16
+ level: 1 | 2 | 3 | 4;
17
+ align: 'left' | 'center';
18
+ eyebrow: string;
19
+ subline: string;
20
+ }
21
+
22
+ const props = defineProps<SectionRenderProps<HeadingConfig>>();
23
+
24
+ // Vue's <component :is> with a tag string handles the level swap without
25
+ // needing a v-if chain. Defensive clamp: an out-of-range level (shouldn't
26
+ // happen — Zod gates it on write) falls back to h2.
27
+ const headingTag = computed(() => {
28
+ const n = props.config.level;
29
+ return n >= 1 && n <= 4 ? `h${n}` : 'h2';
30
+ });
31
+ </script>
32
+
33
+ <template>
34
+ <section
35
+ class="cpub-section-heading"
36
+ :data-align="config.align"
37
+ :aria-labelledby="`section-heading-${meta.sectionId}`"
38
+ >
39
+ <p v-if="config.eyebrow" class="cpub-section-heading-eyebrow">{{ config.eyebrow }}</p>
40
+ <component
41
+ :is="headingTag"
42
+ :id="`section-heading-${meta.sectionId}`"
43
+ class="cpub-section-heading-text"
44
+ >
45
+ {{ config.text }}
46
+ </component>
47
+ <p v-if="config.subline" class="cpub-section-heading-subline">{{ config.subline }}</p>
48
+ </section>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .cpub-section-heading {
53
+ margin-block: var(--space-4);
54
+ }
55
+ .cpub-section-heading[data-align='center'] {
56
+ text-align: center;
57
+ }
58
+ .cpub-section-heading-eyebrow {
59
+ font-family: var(--font-mono);
60
+ font-size: var(--text-xs);
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.1em;
63
+ color: var(--text-faint);
64
+ margin: 0 0 var(--space-2);
65
+ }
66
+ .cpub-section-heading-text {
67
+ margin: 0;
68
+ font-weight: 700;
69
+ line-height: 1.25;
70
+ color: var(--text);
71
+ }
72
+ .cpub-section-heading-subline {
73
+ margin: var(--space-2) 0 0;
74
+ color: var(--text-dim);
75
+ font-size: var(--text-md);
76
+ line-height: 1.6;
77
+ }
78
+ </style>