@commonpub/layer 0.22.1 → 0.23.1

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 (31) hide show
  1. package/components/LayoutSlot.vue +266 -0
  2. package/components/sections/SectionContentFeed.vue +160 -0
  3. package/components/sections/SectionDivider.vue +55 -0
  4. package/components/sections/SectionHeading.vue +78 -0
  5. package/components/sections/SectionHero.vue +164 -0
  6. package/components/sections/SectionImage.vue +104 -0
  7. package/components/sections/SectionParagraph.vue +55 -0
  8. package/composables/useFeatures.ts +10 -0
  9. package/composables/useLayout.ts +132 -0
  10. package/package.json +7 -6
  11. package/pages/admin/theme/index.vue +22 -1
  12. package/pages/index.vue +23 -2
  13. package/sections/builtin/content-feed.ts +52 -0
  14. package/sections/builtin/divider.ts +37 -0
  15. package/sections/builtin/heading.ts +36 -0
  16. package/sections/builtin/hero.ts +67 -0
  17. package/sections/builtin/image.ts +67 -0
  18. package/sections/builtin/paragraph.ts +34 -0
  19. package/sections/registry.ts +61 -0
  20. package/server/api/admin/layouts/[id]/publish.post.ts +33 -0
  21. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +43 -0
  22. package/server/api/admin/layouts/[id]/versions/index.get.ts +29 -0
  23. package/server/api/admin/layouts/[id].delete.ts +34 -0
  24. package/server/api/admin/layouts/[id].get.ts +27 -0
  25. package/server/api/admin/layouts/[id].put.ts +64 -0
  26. package/server/api/admin/layouts/index.get.ts +25 -0
  27. package/server/api/admin/layouts/index.post.ts +48 -0
  28. package/server/api/admin/layouts/seed-homepage.post.ts +35 -0
  29. package/server/api/admin/themes/discover.get.ts +14 -5
  30. package/server/api/layouts/by-route.get.ts +87 -0
  31. package/server/utils/layoutCache.ts +53 -0
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: hero — banner with title, optional eyebrow + subtitle,
4
+ * up to two CTAs.
5
+ *
6
+ * Phase 1c starter. Variants: `default` (left-aligned, grid backdrop),
7
+ * `compact` (smaller padding, no backdrop), `centered` (centered text
8
+ * + CTAs, grid backdrop).
9
+ *
10
+ * Intentionally does NOT replicate the existing `HomepageHeroSection`'s
11
+ * contest-dispatch logic — that becomes its own data-aware section in
12
+ * Phase 6b (`contest-feature` or similar). This hero is pure
13
+ * config-driven so the editor preview is deterministic.
14
+ *
15
+ * `var(--*)` only.
16
+ */
17
+ import type { SectionRenderProps } from '@commonpub/ui';
18
+
19
+ interface HeroCta {
20
+ label: string;
21
+ href: string;
22
+ variant: 'primary' | 'secondary';
23
+ }
24
+
25
+ interface HeroConfig extends Record<string, unknown> {
26
+ variant: 'default' | 'compact' | 'centered';
27
+ eyebrow: string;
28
+ title: string;
29
+ subtitle: string;
30
+ ctas: HeroCta[];
31
+ }
32
+
33
+ defineProps<SectionRenderProps<HeroConfig>>();
34
+ </script>
35
+
36
+ <template>
37
+ <section
38
+ class="cpub-section-hero"
39
+ :data-variant="config.variant"
40
+ :aria-labelledby="`section-hero-${meta.sectionId}`"
41
+ >
42
+ <div v-if="config.variant !== 'compact'" class="cpub-section-hero-grid-bg" aria-hidden="true" />
43
+ <div class="cpub-section-hero-inner">
44
+ <div class="cpub-section-hero-content">
45
+ <p v-if="config.eyebrow" class="cpub-section-hero-eyebrow">{{ config.eyebrow }}</p>
46
+ <h1
47
+ :id="`section-hero-${meta.sectionId}`"
48
+ class="cpub-section-hero-title"
49
+ >
50
+ {{ config.title }}
51
+ </h1>
52
+ <p v-if="config.subtitle" class="cpub-section-hero-subtitle">{{ config.subtitle }}</p>
53
+ <div v-if="config.ctas.length > 0" class="cpub-section-hero-actions">
54
+ <NuxtLink
55
+ v-for="(cta, i) in config.ctas"
56
+ :key="i"
57
+ :to="cta.href"
58
+ class="cpub-btn"
59
+ :class="cta.variant === 'primary' ? 'cpub-btn-primary' : ''"
60
+ >
61
+ {{ cta.label }}
62
+ </NuxtLink>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </section>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .cpub-section-hero {
71
+ position: relative;
72
+ background: var(--surface);
73
+ border-bottom: var(--border-width-default) solid var(--border);
74
+ overflow: hidden;
75
+ min-height: 180px;
76
+ display: flex;
77
+ align-items: stretch;
78
+ }
79
+ .cpub-section-hero[data-variant='compact'] {
80
+ min-height: 120px;
81
+ border-bottom: none;
82
+ }
83
+
84
+ .cpub-section-hero-grid-bg {
85
+ position: absolute;
86
+ inset: 0;
87
+ background-image:
88
+ linear-gradient(var(--border2) 1px, transparent 1px),
89
+ linear-gradient(90deg, var(--border2) 1px, transparent 1px);
90
+ background-size: 32px 32px;
91
+ opacity: 0.25;
92
+ pointer-events: none;
93
+ }
94
+
95
+ .cpub-section-hero-inner {
96
+ position: relative;
97
+ z-index: 1;
98
+ max-width: 1280px;
99
+ margin: 0 auto;
100
+ padding: var(--space-6) var(--space-6);
101
+ width: 100%;
102
+ display: flex;
103
+ align-items: center;
104
+ }
105
+ .cpub-section-hero[data-variant='compact'] .cpub-section-hero-inner {
106
+ padding: var(--space-4) var(--space-6);
107
+ }
108
+
109
+ .cpub-section-hero-content { flex: 1; min-width: 0; }
110
+
111
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-inner {
112
+ justify-content: center;
113
+ }
114
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-content {
115
+ text-align: center;
116
+ max-width: 720px;
117
+ }
118
+
119
+ .cpub-section-hero-eyebrow {
120
+ font-family: var(--font-mono);
121
+ font-size: var(--text-xs);
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.1em;
124
+ color: var(--text-faint);
125
+ margin: 0 0 var(--space-2);
126
+ }
127
+
128
+ .cpub-section-hero-title {
129
+ font-size: var(--text-3xl);
130
+ font-weight: 700;
131
+ line-height: 1.2;
132
+ margin: 0 0 var(--space-2);
133
+ color: var(--text);
134
+ }
135
+ .cpub-section-hero[data-variant='compact'] .cpub-section-hero-title {
136
+ font-size: var(--text-2xl);
137
+ }
138
+
139
+ .cpub-section-hero-subtitle {
140
+ font-size: var(--text-md);
141
+ color: var(--text-dim);
142
+ line-height: 1.6;
143
+ margin: 0 0 var(--space-4);
144
+ max-width: 560px;
145
+ }
146
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-subtitle {
147
+ margin-inline: auto;
148
+ }
149
+
150
+ .cpub-section-hero-actions {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: var(--space-2);
154
+ }
155
+ .cpub-section-hero[data-variant='centered'] .cpub-section-hero-actions {
156
+ justify-content: center;
157
+ }
158
+
159
+ @media (max-width: 640px) {
160
+ .cpub-section-hero-inner { padding: var(--space-4); }
161
+ .cpub-section-hero-title { font-size: var(--text-2xl); }
162
+ .cpub-section-hero-actions { width: 100%; }
163
+ }
164
+ </style>
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: image — single image with optional caption + link.
4
+ *
5
+ * Phase 1c starter. Author-provided `src` is rendered as-is; aspect-ratio
6
+ * keeps cards uniform. Lazy-loaded by default for below-fold sections.
7
+ *
8
+ * Phase 3e inspector will swap the `src` text input for an ImageUpload
9
+ * picker via `.describe('image')` Zod metadata.
10
+ *
11
+ * `var(--*)` only.
12
+ */
13
+ import type { SectionRenderProps } from '@commonpub/ui';
14
+
15
+ interface ImageConfig extends Record<string, unknown> {
16
+ src: string;
17
+ alt: string;
18
+ caption: string;
19
+ href: string;
20
+ fit: 'contain' | 'cover';
21
+ aspectRatio: '16/9' | '4/3' | '1/1' | 'auto';
22
+ }
23
+
24
+ defineProps<SectionRenderProps<ImageConfig>>();
25
+ </script>
26
+
27
+ <template>
28
+ <figure
29
+ class="cpub-section-image"
30
+ :data-aspect="config.aspectRatio"
31
+ :data-fit="config.fit"
32
+ >
33
+ <!-- The `href` field is optional — link only when truthy so screen
34
+ readers don't announce a non-interactive image as a link. -->
35
+ <component
36
+ :is="config.href ? 'a' : 'div'"
37
+ :href="config.href || undefined"
38
+ class="cpub-section-image-frame"
39
+ >
40
+ <img
41
+ v-if="config.src"
42
+ :src="config.src"
43
+ :alt="config.alt"
44
+ loading="lazy"
45
+ decoding="async"
46
+ class="cpub-section-image-img"
47
+ />
48
+ <div v-else class="cpub-section-image-placeholder" aria-hidden="true">
49
+ <i class="fa-regular fa-image" />
50
+ </div>
51
+ </component>
52
+ <figcaption v-if="config.caption" class="cpub-section-image-caption">
53
+ {{ config.caption }}
54
+ </figcaption>
55
+ </figure>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .cpub-section-image {
60
+ margin: 0;
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: var(--space-2);
64
+ }
65
+ .cpub-section-image-frame {
66
+ display: block;
67
+ width: 100%;
68
+ background: var(--surface2);
69
+ border: var(--border-width-default) solid var(--border);
70
+ overflow: hidden;
71
+ text-decoration: none;
72
+ color: inherit;
73
+ }
74
+ .cpub-section-image[data-aspect='16/9'] .cpub-section-image-frame { aspect-ratio: 16 / 9; }
75
+ .cpub-section-image[data-aspect='4/3'] .cpub-section-image-frame { aspect-ratio: 4 / 3; }
76
+ .cpub-section-image[data-aspect='1/1'] .cpub-section-image-frame { aspect-ratio: 1 / 1; }
77
+
78
+ .cpub-section-image-img {
79
+ width: 100%;
80
+ height: 100%;
81
+ display: block;
82
+ }
83
+ .cpub-section-image[data-fit='cover'] .cpub-section-image-img { object-fit: cover; }
84
+ .cpub-section-image[data-fit='contain'] .cpub-section-image-img { object-fit: contain; }
85
+ /* Aspect=auto + no explicit ratio: let the image dictate height */
86
+ .cpub-section-image[data-aspect='auto'] .cpub-section-image-img {
87
+ height: auto;
88
+ }
89
+
90
+ .cpub-section-image-placeholder {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 100%;
95
+ min-height: 120px;
96
+ color: var(--text-faint);
97
+ font-size: var(--text-xl);
98
+ }
99
+ .cpub-section-image-caption {
100
+ font-size: var(--text-sm);
101
+ color: var(--text-dim);
102
+ line-height: 1.5;
103
+ }
104
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Built-in section: paragraph — plain prose body.
4
+ *
5
+ * Phase 1c starter. Stores `text: string` and splits on blank lines into
6
+ * paragraphs at render time. The auto-form inspector (Phase 3e) will
7
+ * upgrade this to a TipTap subset via the `.describe('rich')` Zod
8
+ * metadata — at which point `schemaVersion` bumps to 2 with a migration
9
+ * that converts plain text → block tuples.
10
+ *
11
+ * `var(--*)` only.
12
+ */
13
+ import { computed } from 'vue';
14
+ import type { SectionRenderProps } from '@commonpub/ui';
15
+
16
+ interface ParagraphConfig extends Record<string, unknown> {
17
+ text: string;
18
+ align: 'left' | 'center';
19
+ }
20
+
21
+ const props = defineProps<SectionRenderProps<ParagraphConfig>>();
22
+
23
+ // Blank-line split — preserves authored paragraph breaks without needing
24
+ // a rich-text editor. Empty paragraphs are dropped (defensive).
25
+ const paragraphs = computed<string[]>(() =>
26
+ (props.config.text ?? '')
27
+ .split(/\n{2,}/)
28
+ .map((p) => p.trim())
29
+ .filter((p) => p.length > 0),
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <div class="cpub-section-paragraph" :data-align="config.align">
35
+ <p v-for="(p, i) in paragraphs" :key="i">{{ p }}</p>
36
+ </div>
37
+ </template>
38
+
39
+ <style scoped>
40
+ .cpub-section-paragraph {
41
+ margin-block: var(--space-3);
42
+ color: var(--text);
43
+ font-size: var(--text-md);
44
+ line-height: 1.7;
45
+ }
46
+ .cpub-section-paragraph[data-align='center'] {
47
+ text-align: center;
48
+ }
49
+ .cpub-section-paragraph p {
50
+ margin: 0 0 var(--space-3);
51
+ }
52
+ .cpub-section-paragraph p:last-child {
53
+ margin-bottom: 0;
54
+ }
55
+ </style>
@@ -26,6 +26,14 @@ export interface FeatureFlags {
26
26
  emailNotifications: boolean;
27
27
  publicApi: boolean;
28
28
  contentImport: boolean;
29
+ /**
30
+ * DB-backed page layout engine. Default OFF — when ON, the homepage
31
+ * (and future layout-bearing pages) render via `<LayoutSlot>` zones
32
+ * resolved from the `layouts` table. Operator MUST run
33
+ * POST /api/admin/layouts/seed-homepage before flipping this on so
34
+ * a default layout exists at scope ('route', '/'). Added session 158.
35
+ */
36
+ layoutEngine: boolean;
29
37
  /**
30
38
  * Cross-instance delegated authorization. All sub-flags default false.
31
39
  * Mirrors `@commonpub/config`'s `IdentityFeatures`. Phase 1b+ — see
@@ -46,6 +54,7 @@ export const DEFAULT_FLAGS: FeatureFlags = {
46
54
  contests: false, events: false, learning: true, explainers: true,
47
55
  editorial: true, federation: false, admin: false, emailNotifications: false,
48
56
  publicApi: false, contentImport: true,
57
+ layoutEngine: false,
49
58
  identity: {
50
59
  linkRemoteAccounts: false,
51
60
  signInWithRemote: false,
@@ -120,6 +129,7 @@ export function useFeatures() {
120
129
  emailNotifications: computed(() => flags.value.emailNotifications),
121
130
  publicApi: computed(() => flags.value.publicApi),
122
131
  contentImport: computed(() => flags.value.contentImport),
132
+ layoutEngine: computed(() => flags.value.layoutEngine),
123
133
  identity: computed(() => flags.value.identity),
124
134
  };
125
135
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Client-side composable for resolving a route's active layout.
3
+ *
4
+ * Wraps `useFetch('/api/layouts/by-route?path=…')` with SSR-safe caching:
5
+ * - Server-side: fetched during SSR, payload is included in the
6
+ * hydration snapshot (Nuxt's `useFetch` default) — same data is
7
+ * re-used on client mount with zero extra requests
8
+ * - Client-side: subsequent navigations to the same path return the
9
+ * cached value (Nuxt's request cache, keyed by `key`)
10
+ *
11
+ * Returns `null` when:
12
+ * - The layout-engine feature is OFF (`/api/layouts/by-route` 404s,
13
+ * which we surface as null so consumers fall back to legacy renderers
14
+ * gracefully)
15
+ * - No layout exists for the route
16
+ *
17
+ * The `<LayoutSlot>` component is the only intended caller in v1; other
18
+ * consumers should use that instead.
19
+ */
20
+ import type { Ref } from 'vue';
21
+
22
+ export interface LayoutSection {
23
+ id: string;
24
+ order: number;
25
+ type: string;
26
+ config: Record<string, unknown>;
27
+ colSpan: number;
28
+ responsive: { sm?: number; md?: number; lg?: number } | null;
29
+ enabled: boolean;
30
+ visibility: { roles?: string[]; features?: string[]; hideAt?: ('sm' | 'md' | 'lg')[] } | null;
31
+ schemaVersion: number;
32
+ }
33
+
34
+ export interface LayoutRow {
35
+ id: string;
36
+ order: number;
37
+ config: {
38
+ gap?: 'none' | 'sm' | 'md' | 'lg';
39
+ align?: 'start' | 'center' | 'stretch';
40
+ background?: string;
41
+ paddingY?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
42
+ } | null;
43
+ sections: LayoutSection[];
44
+ }
45
+
46
+ export interface LayoutZoneClient {
47
+ zone: string;
48
+ rows: LayoutRow[];
49
+ }
50
+
51
+ export interface LayoutPayload {
52
+ zones: LayoutZoneClient[];
53
+ pageMeta: {
54
+ title: string;
55
+ description?: string;
56
+ ogImage?: string;
57
+ noindex?: boolean;
58
+ ogType?: 'website' | 'article' | 'profile';
59
+ access?: 'public' | 'members' | 'admin';
60
+ frame?: 'narrow' | 'wide' | 'two-column' | 'three-column' | 'sidebar-left' | 'sidebar-right';
61
+ } | null;
62
+ state: 'draft' | 'published';
63
+ }
64
+
65
+ export interface UseLayoutResult {
66
+ /** The layout payload, or null if none exists / feature off. */
67
+ layout: Ref<LayoutPayload | null>;
68
+ /** True while the initial fetch is in flight. */
69
+ pending: Ref<boolean>;
70
+ /** Truthy if the fetch errored (incl. 404 for feature off). */
71
+ error: Ref<unknown>;
72
+ /** Re-fetch the layout (after a save / publish). */
73
+ refresh: () => Promise<void>;
74
+ }
75
+
76
+ /**
77
+ * Resolve the layout for a given route path. SSR-safe; caches per path
78
+ * for the request lifetime + survives hydration.
79
+ *
80
+ * Returns a layout=null Ref when the feature is off so consumers can
81
+ * `v-if="layout"` and fall through to a legacy renderer.
82
+ *
83
+ * **Reactivity**: pass a string for the typical case (literal route on
84
+ * a page) — useFetch fires once at setup. For the rare case where the
85
+ * route changes without remount (e.g. a parent component swapping the
86
+ * prop dynamically), pass a Ref or getter — useFetch will refire on
87
+ * change. Without this, a path-prop change on `<LayoutSlot>` would
88
+ * silently leave the stale fetch result in place.
89
+ */
90
+ export function useLayout(path: string | Ref<string> | (() => string)): UseLayoutResult {
91
+ const pathGetter = (): string => (
92
+ typeof path === 'string'
93
+ ? path
94
+ : typeof path === 'function'
95
+ ? path()
96
+ : path.value
97
+ );
98
+
99
+ const { data, pending, error, refresh } = useFetch<LayoutPayload | null>(
100
+ '/api/layouts/by-route',
101
+ {
102
+ // Static key for the literal-string case (so Nuxt's request cache
103
+ // can dedupe across components on the same nav). For the reactive
104
+ // case, omit key so useFetch derives one from the query Ref.
105
+ key: typeof path === 'string' ? `layout:${path}` : undefined,
106
+ // Functional query — useFetch re-evaluates on watched deps change.
107
+ query: { path: pathGetter },
108
+ // Watch the path getter so reactive callers refetch on change.
109
+ // For string callers this is empty array → no extra reactivity.
110
+ watch: typeof path === 'string' ? [] : [pathGetter],
111
+ // 404 from the API (feature off OR route has no layout) is NOT an
112
+ // exceptional case — surface as null. Don't treat as error.
113
+ onResponseError({ response }) {
114
+ if (response.status === 404) {
115
+ // Don't throw; data stays null
116
+ return;
117
+ }
118
+ },
119
+ // Falsy data on 404 maps to null
120
+ transform: (input: LayoutPayload | null | undefined) => input ?? null,
121
+ server: true,
122
+ lazy: false,
123
+ },
124
+ );
125
+
126
+ return {
127
+ layout: data as Ref<LayoutPayload | null>,
128
+ pending,
129
+ error,
130
+ refresh: async () => { await refresh(); },
131
+ };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -13,6 +13,7 @@
13
13
  "middleware",
14
14
  "pages",
15
15
  "plugins",
16
+ "sections",
16
17
  "server",
17
18
  "theme",
18
19
  "types",
@@ -51,15 +52,15 @@
51
52
  "vue-router": "^4.3.0",
52
53
  "zod": "^4.3.6",
53
54
  "@commonpub/auth": "0.6.0",
54
- "@commonpub/docs": "0.6.3",
55
- "@commonpub/config": "0.14.0",
55
+ "@commonpub/config": "0.15.0",
56
56
  "@commonpub/editor": "0.7.11",
57
57
  "@commonpub/explainer": "0.7.15",
58
- "@commonpub/learning": "0.5.2",
59
- "@commonpub/schema": "0.17.0",
60
58
  "@commonpub/protocol": "0.12.0",
59
+ "@commonpub/server": "2.57.0",
60
+ "@commonpub/schema": "0.17.0",
61
+ "@commonpub/learning": "0.5.2",
61
62
  "@commonpub/ui": "0.9.0",
62
- "@commonpub/server": "2.56.0"
63
+ "@commonpub/docs": "0.6.3"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@testing-library/jest-dom": "^6.9.1",
@@ -259,9 +259,30 @@ function recheckDiscovery(): void {
259
259
  * If admins want to re-capture from a fresh :root state, they can revert
260
260
  * to the base theme, clear overrides, then the banner will reappear.
261
261
  */
262
+ /**
263
+ * Show the "your site has a custom theme" banner ONLY when the detected
264
+ * overrides on :root are likely from a CSS file the thin layer app
265
+ * loaded (the deveco.io case) — NOT from the built-in theme itself or
266
+ * from a custom theme the admin already saved.
267
+ *
268
+ * Refined gate (5 conditions, all must pass):
269
+ * - count > 0 (something to capture)
270
+ * - active theme is NOT a custom theme (already captured)
271
+ * - active theme IS 'base' (any non-base built-in's tokens would
272
+ * dominate the diff, making "capture" produce a clone of that
273
+ * theme — pointless. e.g. commonpub.io's active='agora' triggers
274
+ * count > 0 from agora.css; banner would confuse the admin)
275
+ * - no instance-wide token overrides set (those explain the diff)
276
+ *
277
+ * Caveat: a thin app that registers a `themes:` entry AND sets
278
+ * instanceDefault to the registered slug (e.g. 'deveco') would have
279
+ * the banner HIDDEN even though their CSS file overrides ARE the use
280
+ * case the banner targets. For that case the admin can use the Fork
281
+ * button on the registered theme's card instead. Document.
282
+ */
262
283
  const showDiscoveryBanner = computed<boolean>(() => {
263
284
  if (discovery.value.count === 0) return false;
264
- if (instanceDefault.value.startsWith('cpub-custom-')) return false;
285
+ if (instanceDefault.value !== 'base') return false; // hides built-in non-base + registered + custom
265
286
  if (Object.keys(initialOverrides.value).length > 0) return false;
266
287
  return true;
267
288
  });
package/pages/index.vue CHANGED
@@ -14,7 +14,7 @@ const sortedSections = computed(() =>
14
14
  );
15
15
 
16
16
  const { user: authUser } = useAuth();
17
- const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled } = useFeatures();
17
+ const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled, layoutEngine: layoutEngineEnabled } = useFeatures();
18
18
  const { enabledTypeMeta } = useContentTypes();
19
19
 
20
20
  const activeTab = ref(authUser.value ? 'foryou' : 'latest');
@@ -143,8 +143,29 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
143
143
 
144
144
  <template>
145
145
  <div>
146
+ <!-- ═══ LAYOUT ENGINE (Phase 1c — feature-flagged) ═══
147
+ When `features.layoutEngine` is ON, render the homepage via
148
+ <LayoutSlot> zones backed by the layouts table. Operators flip
149
+ this on AFTER running POST /api/admin/layouts/seed-homepage so
150
+ a default layout exists at scope ('route', '/'). If the flag is
151
+ on but no layout exists, LayoutSlot renders nothing and the
152
+ user sees an empty page — documented at
153
+ docs/reference/guides/layout-engine.md. Falls through to the
154
+ configurable section renderer when the flag is OFF (default). -->
155
+ <template v-if="layoutEngineEnabled">
156
+ <LayoutSlot route="/" zone="full-width" />
157
+ <div class="cpub-main-layout">
158
+ <main class="cpub-feed-col">
159
+ <LayoutSlot route="/" zone="main" />
160
+ </main>
161
+ <aside class="cpub-sidebar">
162
+ <LayoutSlot route="/" zone="sidebar" />
163
+ </aside>
164
+ </div>
165
+ </template>
166
+
146
167
  <!-- ═══ CONFIGURABLE HOMEPAGE (section renderer) ═══ -->
147
- <template v-if="hasCustomSections">
168
+ <template v-else-if="hasCustomSections">
148
169
  <!-- Full-width sections (hero) -->
149
170
  <HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
150
171
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Built-in section definition: content-feed.
3
+ *
4
+ * Phase 1c starter and the first DATA section. Fetches `/api/content`
5
+ * with config-driven filters and renders a responsive grid of
6
+ * `<ContentCard>`s.
7
+ *
8
+ * Config fields split into server-filter (forwarded to `/api/content`)
9
+ * and render-only (`heading`, `columns`). Keeping the contract explicit
10
+ * here matches the auto-form mapping in Phase 3e and stops accidental
11
+ * pass-through of admin-only filter values.
12
+ */
13
+ import { z } from 'zod';
14
+ import type { SectionDefinition } from '@commonpub/ui';
15
+ import SectionContentFeed from '../../components/sections/SectionContentFeed.vue';
16
+
17
+ const configSchema = z.object({
18
+ heading: z.string().max(120).default(''),
19
+ contentType: z.string().max(64).default(''),
20
+ sort: z.enum(['recent', 'popular', 'featured', 'editorial']).default('recent'),
21
+ limit: z.number().int().min(1).max(24).default(6),
22
+ columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(3),
23
+ tag: z.string().max(64).default(''),
24
+ featured: z.boolean().default(false),
25
+ });
26
+
27
+ export const contentFeedSection: SectionDefinition<z.infer<typeof configSchema>> = {
28
+ type: 'content-feed',
29
+ name: 'Content feed',
30
+ description: 'Grid of content cards filtered by type / tag / sort',
31
+ icon: 'fa-stream',
32
+ category: 'data',
33
+ status: 'stable',
34
+ configSchema,
35
+ defaultConfig: {
36
+ heading: '',
37
+ contentType: '',
38
+ sort: 'recent',
39
+ limit: 6,
40
+ columns: 3,
41
+ tag: '',
42
+ featured: false,
43
+ },
44
+ schemaVersion: 1,
45
+ component: SectionContentFeed,
46
+ // Multi-column grid collapses to less than half-width — readability + the
47
+ // card aspect ratio break down below 6
48
+ minColSpan: 6,
49
+ maxColSpan: 12,
50
+ defaultColSpan: 12,
51
+ resizable: true,
52
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Built-in section definition: divider.
3
+ *
4
+ * Phase 1 proof-of-life — the simplest possible registered section.
5
+ * Validates the registry → LayoutSlot → renderer chain without any
6
+ * Zod complexity, content fetches, or admin-only config.
7
+ *
8
+ * Phase 1c adds: hero, heading, paragraph, image, content-feed —
9
+ * each in its own `builtin/{type}.ts` file, registered in
10
+ * `../registry.ts` alongside this one.
11
+ */
12
+ import { z } from 'zod';
13
+ import type { SectionDefinition } from '@commonpub/ui';
14
+ import SectionDivider from '../../components/sections/SectionDivider.vue';
15
+
16
+ const configSchema = z.object({
17
+ variant: z.enum(['solid', 'dashed', 'dotted', 'accent']).default('solid'),
18
+ spacingY: z.enum(['sm', 'md', 'lg', 'xl']).default('md'),
19
+ });
20
+
21
+ export const dividerSection: SectionDefinition<z.infer<typeof configSchema>> = {
22
+ type: 'divider',
23
+ name: 'Divider',
24
+ description: 'Horizontal rule with style + spacing options',
25
+ icon: 'fa-minus',
26
+ category: 'layout',
27
+ status: 'stable',
28
+ configSchema,
29
+ defaultConfig: { variant: 'solid', spacingY: 'md' },
30
+ schemaVersion: 1,
31
+ component: SectionDivider,
32
+ // Dividers are always full-width; resize is meaningless for a 1px line
33
+ minColSpan: 12,
34
+ maxColSpan: 12,
35
+ defaultColSpan: 12,
36
+ resizable: false,
37
+ };