@immediately-run/grove 0.1.2 → 0.1.3

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 (77) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +5 -2
  3. package/package.json +10 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +484 -152
  6. package/src/GroveWiki.tsx +105 -30
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +23 -12
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -150
  21. package/src/components/GroveNav.test.tsx +145 -5
  22. package/src/components/GroveNav.tsx +51 -8
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +18 -3
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +16 -7
  40. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  41. package/src/hooks/useContentComponents.ts +1 -1
  42. package/src/hooks/useEditAffordance.ts +17 -4
  43. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  44. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  45. package/src/hooks/useScrollReset.test.tsx +132 -0
  46. package/src/hooks/useScrollReset.ts +52 -0
  47. package/src/index.css +9 -2
  48. package/src/lib/agentPrompt.test.ts +96 -0
  49. package/src/lib/agentPrompt.ts +86 -0
  50. package/src/lib/agentTools.test.ts +115 -0
  51. package/src/lib/agentTools.ts +132 -0
  52. package/src/lib/agentTranscript.ts +50 -0
  53. package/src/lib/assetPath.test.ts +36 -0
  54. package/src/lib/assetPath.ts +42 -0
  55. package/src/lib/collectionCalls.test.ts +69 -0
  56. package/src/lib/content.test.ts +10 -2
  57. package/src/lib/content.ts +7 -0
  58. package/src/lib/contentStylesheet.test.ts +80 -0
  59. package/src/lib/contentStylesheet.ts +103 -0
  60. package/src/lib/corpusScan.test.ts +37 -3
  61. package/src/lib/corpusScan.ts +17 -2
  62. package/src/lib/inlineProse.parity.test.ts +52 -0
  63. package/src/lib/layout.ts +29 -0
  64. package/src/lib/pageVariants.test.tsx +87 -0
  65. package/src/lib/queries.test.ts +33 -1
  66. package/src/lib/queries.ts +33 -1
  67. package/src/lib/reachCard.test.ts +94 -0
  68. package/src/lib/reachCard.ts +112 -0
  69. package/src/lib/shell.ts +7 -0
  70. package/src/lib/starterSweep.test.tsx +160 -0
  71. package/src/lib/starterSweep.ts +97 -0
  72. package/src/lib/themeAssets.test.ts +135 -0
  73. package/src/lib/themeAssets.ts +143 -0
  74. package/src/lib/themeSelection.test.ts +2 -2
  75. package/src/mdxComponents.ts +4 -0
  76. package/viewer-manifest.schema.json +37 -0
  77. package/viewer.manifest.json +132 -6
@@ -0,0 +1,97 @@
1
+ // The layout-starter sweep (R3-309, bucket A's gate) — PURE analysis over a starter's
2
+ // source and its rendered markup, so the same checker drives the real starters from
3
+ // disk AND fault-injected fixtures in the test.
4
+ //
5
+ // WHY A GATE AT ALL. A starter is a file people COPY, which makes it teaching
6
+ // material: whatever it does, corpora will do. The safe renderer fails QUIETLY in
7
+ // four ways (ENGINE_BOUNDARY §6 / 03-layout-catalogue §3) — expression props
8
+ // dropped, unregistered tags collapsed to their children, `import` lines rendered
9
+ // as visible prose, mid-line block tags eaten as literal angle brackets — and a
10
+ // shipped starter that trips any of them teaches the failure with no error
11
+ // anywhere. Worse than no starter.
12
+ //
13
+ // The render itself is INJECTED (`render(body) → markup`) because the real one is
14
+ // the published safe renderer plus Grove's component map — exactly what
15
+ // SafeLayout sends a `_layout.mdx` through — and the test wires that up; keeping
16
+ // it out of this module keeps the analysis pure and runnable over strings.
17
+
18
+ /** Which marker substring an ALWAYS-RENDERING primitive must leave in the markup. A
19
+ * primitive whose marker is absent either collapsed (unregistered) or changed its
20
+ * root — both are drift a copy would inherit. Data-dependent components
21
+ * (`DocList`, `ChildPages`) are deliberately absent: with no corpus in the sweep
22
+ * they render nothing BY DESIGN, and their registration is checked through
23
+ * `knownTags` instead. */
24
+ export const STARTER_MARKERS: Record<string, string> = {
25
+ GroveNav: 'grove-nav',
26
+ GroveSidebar: 'grove-sidebar',
27
+ GroveFooter: 'grove-footer',
28
+ };
29
+
30
+ /** Strip a leading frontmatter block — the render path never sees it (the sweep
31
+ * asserts the BODY a reader would get). */
32
+ export function stripStarterFrontmatter(src: string): string {
33
+ return src.replace(/^---\n[\s\S]*?\n---\n?/, '');
34
+ }
35
+
36
+ /** Every capitalized component tag a starter uses (`<GroveNav`, `<DocList`). */
37
+ export function componentTagsOf(body: string): string[] {
38
+ return [...body.matchAll(/<([A-Z][A-Za-z0-9]*)/g)].map((m) => m[1]!);
39
+ }
40
+
41
+ /** The text a reader sees, once every element is stripped away — what "visible
42
+ * prose" checks run over. Entities are decoded so an eaten tag shows up as '<'. */
43
+ export function visibleTextOf(markup: string): string {
44
+ return markup
45
+ .replace(/<[^>]+>/g, '')
46
+ .replace(/&lt;/g, '<')
47
+ .replace(/&gt;/g, '>')
48
+ .replace(/&amp;/g, '&');
49
+ }
50
+
51
+ export interface StarterSweepDeps {
52
+ /** Render a starter BODY through the real safe renderer + component map. Async:
53
+ * the real parser is. */
54
+ render: (body: string) => string | Promise<string>;
55
+ /** The component names the map actually registers (off-vocabulary detection). */
56
+ knownTags: ReadonlySet<string>;
57
+ }
58
+
59
+ /**
60
+ * Sweep one starter. Returns one violation string per finding — empty means the
61
+ * starter survives the interpreter.
62
+ */
63
+ export async function starterViolations(
64
+ source: string,
65
+ deps: StarterSweepDeps,
66
+ ): Promise<string[]> {
67
+ const body = stripStarterFrontmatter(source);
68
+ const markup = await deps.render(body);
69
+ const text = visibleTextOf(markup);
70
+ const out: string[] = [];
71
+
72
+ // (3) an unresolved import renders as visible prose — a copy would teach it.
73
+ if (/^\s*(import|export)\s/m.test(text)) out.push('an import/export line renders as visible prose');
74
+
75
+ // (4) a block tag that does not open on its own line is eaten by micromark and
76
+ // shows up as literal angle brackets. Starters carry no raw HTML, so ANY '<' in
77
+ // the visible text is this failure.
78
+ if (text.includes('<')) out.push(`literal angle brackets in the visible text (${JSON.stringify(text.match(/.{0,24}<.{0,24}/)?.[0])})`);
79
+
80
+ // Starters are structural: braces belong to no tag we document, and under the
81
+ // interpreter they stay characters — a `{/* comment */}` renders as prose.
82
+ if (text.includes('{') || text.includes('}')) {
83
+ out.push('braces render as visible text (an expression or JSX comment the interpreter does not consume)');
84
+ }
85
+
86
+ for (const tag of componentTagsOf(body)) {
87
+ if (!deps.knownTags.has(tag)) {
88
+ out.push(`<${tag}> is not in the component map — under the interpreter it collapses to its children`);
89
+ continue;
90
+ }
91
+ const marker = STARTER_MARKERS[tag];
92
+ if (marker && !markup.includes(marker)) {
93
+ out.push(`<${tag}> rendered without its "${marker}" marker — the wrapper collapsed or drifted`);
94
+ }
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1,135 @@
1
+ // R3-315's adversarial exits, at the minter:
2
+ // • faces mint from LOCAL bytes — no network location is named anywhere;
3
+ // • switching sets revokes the outgoing URLs (no leak across a theme lifetime);
4
+ // • a missing font or asset degrades — skipped, never thrown, fallback answers;
5
+ // • minted URLs and @font-face srcs are `blob:` — no chroot prefix can reach
6
+ // them (proven non-vacuous by a leaking canary the same check catches).
7
+ import { describe, it, expect, vi, afterEach } from 'vitest';
8
+ import { mintThemeAssets } from './themeAssets';
9
+ import type { AssetReader } from './themeAssets';
10
+
11
+ const BYTES = new Uint8Array([1, 2, 3, 4]);
12
+ const ok: AssetReader = async () => BYTES;
13
+ const miss: AssetReader = async () => {
14
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
15
+ };
16
+
17
+ const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
18
+ const createSpy = vi.spyOn(URL, 'createObjectURL');
19
+
20
+ afterEach(() => {
21
+ revokeSpy.mockClear();
22
+ createSpy.mockClear();
23
+ });
24
+
25
+ describe('minting', () => {
26
+ it('emits one @font-face per declared face, blob: src, descriptors carried', async () => {
27
+ const m = await mintThemeAssets(
28
+ { fonts: [{ family: 'Source Serif 4', src: './fonts/a.woff2', weight: '400 700', style: 'normal' }] },
29
+ '/app/themes/x.css',
30
+ ok,
31
+ );
32
+ expect(m.minted).toBe(1);
33
+ expect(m.fontFaceCss).toContain('@font-face');
34
+ expect(m.fontFaceCss).toContain('"Source Serif 4"');
35
+ expect(m.fontFaceCss).toContain('font-weight: 400 700');
36
+ expect(m.fontFaceCss).toMatch(/src: url\("blob:/);
37
+ expect(m.fontFaceCss).toContain('format("woff2")');
38
+ });
39
+
40
+ it('resolves src RELATIVE TO THE DECLARING FILE and reads the resolved path', async () => {
41
+ const read = vi.fn(ok);
42
+ await mintThemeAssets({ fonts: [{ family: 'A', src: '../fonts/a.woff2' }] }, '/app/themes/x.css', read);
43
+ expect(read).toHaveBeenCalledWith('/app/fonts/a.woff2');
44
+ });
45
+
46
+ it('named assets become --asset-<name> vars on .grove-root', async () => {
47
+ const m = await mintThemeAssets({ assets: { paper: './textures/paper.jpg' } }, '/app/themes/x.css', ok);
48
+ expect(m.assetVarsCss).toMatch(/^\.grove-root\{--asset-paper: url\("blob:/);
49
+ expect(m.minted).toBe(1);
50
+ });
51
+
52
+ it('malformed names/refs are skipped, not thrown', async () => {
53
+ const m = await mintThemeAssets(
54
+ { assets: { 'bad name': './x.png', 'also__bad!': './y.png', ok: './z.png' } },
55
+ '/app/t.css',
56
+ ok,
57
+ );
58
+ expect(m.minted).toBe(1);
59
+ expect(m.assetVarsCss).toContain('--asset-ok');
60
+ });
61
+ });
62
+
63
+ describe('degrade, never break', () => {
64
+ it('a missing font file skips its @font-face — the token fallback answers', async () => {
65
+ const m = await mintThemeAssets(
66
+ { fonts: [{ family: 'Here', src: './here.woff2' }, { family: 'Gone', src: './gone.woff2' }] },
67
+ '/app/t.css',
68
+ async (p) => (p.endsWith('here.woff2') ? BYTES : Promise.reject(new Error('ENOENT'))),
69
+ );
70
+ expect(m.minted).toBe(1);
71
+ expect(m.fontFaceCss).toContain('"Here"');
72
+ expect(m.fontFaceCss).not.toContain('"Gone"');
73
+ });
74
+
75
+ it('a missing named asset skips its var — the var() fallback answers', async () => {
76
+ const m = await mintThemeAssets({ assets: { paper: './none.jpg' } }, '/app/t.css', miss);
77
+ expect(m.minted).toBe(0);
78
+ expect(m.assetVarsCss).toBe('');
79
+ });
80
+
81
+ it('nothing throws for an all-missing set', async () => {
82
+ const m = await mintThemeAssets({ fonts: [{ family: 'X', src: './x.woff2' }], assets: { a: './b.png' } }, '/app/t.css', miss);
83
+ expect(m.minted).toBe(0);
84
+ expect(m.fontFaceCss).toBe('');
85
+ expect(m.assetVarsCss).toBe('');
86
+ });
87
+ });
88
+
89
+ describe('the revoke scope is the theme lifetime (no leak on switch)', () => {
90
+ it('revoke() releases every minted URL exactly once (idempotent)', async () => {
91
+ const m = await mintThemeAssets(
92
+ { fonts: [{ family: 'A', src: './a.woff2' }], assets: { b: './b.png' } },
93
+ '/app/t.css',
94
+ ok,
95
+ );
96
+ expect(m.minted).toBe(2);
97
+ m.revoke();
98
+ m.revoke(); // idempotent
99
+ expect(revokeSpy).toHaveBeenCalledTimes(2);
100
+ });
101
+
102
+ it('mint → revoke → mint again mints FRESH URLs (a switch leaks nothing)', async () => {
103
+ const first = await mintThemeAssets({ fonts: [{ family: 'A', src: './a.woff2' }] }, '/app/t.css', ok);
104
+ const urls1 = first.fontFaceCss;
105
+ first.revoke();
106
+ const second = await mintThemeAssets({ fonts: [{ family: 'A', src: './a.woff2' }] }, '/app/t.css', ok);
107
+ expect(second.minted).toBe(1);
108
+ expect(second.fontFaceCss).not.toBe(urls1); // distinct URL per mint
109
+ expect(revokeSpy).toHaveBeenCalledTimes(1);
110
+ second.revoke();
111
+ });
112
+ });
113
+
114
+ describe('UNDER DISPATCH no chroot prefix reaches a minted URL or @font-face src', () => {
115
+ it('the minted output is blob:-only — the mount path appears nowhere in it', async () => {
116
+ const m = await mintThemeAssets(
117
+ { fonts: [{ family: 'A', src: './a.woff2' }], assets: { b: './b.png' } },
118
+ '/mnt/abc123def456/themes/x.css',
119
+ ok,
120
+ );
121
+ const all = m.fontFaceCss + m.assetVarsCss;
122
+ expect(all).not.toContain('mnt');
123
+ expect(all).not.toContain('abc123def456');
124
+ expect(all).toMatch(/blob:/);
125
+ });
126
+
127
+ it('NON-VACUOUS by fault injection: the same assertion catches a leaking src', () => {
128
+ // The canary: the regression class this exit exists for — an implementation
129
+ // that writes the resolved path into the CSS instead of the minted URL.
130
+ const leaky = `@font-face{src: url("/mnt/abc123def456/themes/a.woff2");}`;
131
+ expect(() => {
132
+ expect(leaky).not.toContain('abc123def456');
133
+ }).toThrow();
134
+ });
135
+ });
@@ -0,0 +1,143 @@
1
+ // Engine-resolved theme assets (R3-315; plan 05-content-carried-themes §4).
2
+ //
3
+ // A theme DECLARES its fonts and named assets (`fonts: [{family, src, weight,
4
+ // style}]`, `assets: {<name>: <relPath>}`, resolved relative to the declaring
5
+ // file); the ENGINE reads the bytes off the fs, mints a `blob:` URL for each, and
6
+ // hands the result to CSS — an `@font-face` rule per font (emitted into the
7
+ // engine-owned cascade layer) and a `--asset-<name>` custom property per asset.
8
+ // Content never names a network location at all, which is the point: `blob:` is
9
+ // permitted by `img-src`, `font-src` AND `style-src` in every stance including
10
+ // M3, so one mechanism serves fork, dispatch and library modes — and the wiki
11
+ // renders its faces with the network off, in every stance.
12
+ //
13
+ // The revoke scope is the THEME'S lifetime, not a component's — `useObjectUrl`'s
14
+ // component-scoped revoke does not fit an asset set that outlives any single
15
+ // surface, so this module owns the lifecycle explicitly: `revoke()` releases
16
+ // every minted URL, and the switching surface calls it on teardown.
17
+ //
18
+ // DEGRADE, NEVER BREAK: a missing font file skips that `@font-face` (the token's
19
+ // fallback stack answers); a missing named asset skips its var (the var()'s own
20
+ // fallback answers). Nothing throws and nothing renders a broken glyph.
21
+ //
22
+ // The minted URLs carry no path information (`blob:` + origin), so under dispatch
23
+ // the chroot prefix cannot reach a URL or a `@font-face` src by construction —
24
+ // asserted, not assumed, in the tests.
25
+
26
+ import { resolvePath } from './assetPath';
27
+
28
+ /** One declared webfont. `src` is relative to the declaring file (or `/…` absolute). */
29
+ export interface FontDeclaration {
30
+ family: string;
31
+ src: string;
32
+ /** CSS `font-weight` descriptor, e.g. `'400 700'` or `'700'`. */
33
+ weight?: string;
34
+ /** CSS `font-style` descriptor, e.g. `'normal'` | `'italic'`. */
35
+ style?: string;
36
+ }
37
+
38
+ /** The asset declarations a theme (or the engine's own default set) carries. */
39
+ export interface ThemeAssetDeclarations {
40
+ fonts?: FontDeclaration[];
41
+ assets?: Record<string, string>;
42
+ }
43
+
44
+ /** The minted result: CSS the engine owns, plus the revoke handle. */
45
+ export interface MintedThemeAssets {
46
+ /** `@font-face` rules with `blob:` srcs — the caller emits these inside the
47
+ * engine cascade layer. Empty when nothing declared or everything degraded. */
48
+ fontFaceCss: string;
49
+ /** A `.grove-root` rule assigning `--asset-<name>: url(blob:…)` per minted
50
+ * asset — empty when nothing declared or everything degraded. */
51
+ assetVarsCss: string;
52
+ /** How many URLs were minted (observable for leak tests). */
53
+ minted: number;
54
+ /** Release every minted URL. Idempotent. */
55
+ revoke(): void;
56
+ }
57
+
58
+ /** The bytes-read the minter needs — injected so tests spy and fault-inject. */
59
+ export type AssetReader = (absPath: string) => Promise<Uint8Array<ArrayBuffer> | string>;
60
+
61
+ const FONT_MIME: Record<string, string> = {
62
+ woff2: 'font/woff2',
63
+ woff: 'font/woff',
64
+ ttf: 'font/ttf',
65
+ otf: 'font/otf',
66
+ };
67
+ const IMAGE_MIME: Record<string, string> = {
68
+ png: 'image/png',
69
+ jpg: 'image/jpeg',
70
+ jpeg: 'image/jpeg',
71
+ webp: 'image/webp',
72
+ svg: 'image/svg+xml',
73
+ avif: 'image/avif',
74
+ };
75
+
76
+ function ext(p: string): string {
77
+ const m = /\.([a-z0-9]+)$/i.exec(p);
78
+ return m ? m[1].toLowerCase() : '';
79
+ }
80
+
81
+ /**
82
+ * Mint the blob URLs + CSS for a declaration set.
83
+ *
84
+ * @param decls the fonts/assets the declaring file asked for
85
+ * @param basePath the DECLARING FILE's absolute fs path (resolution base)
86
+ * @param read the bytes-read (fs)
87
+ */
88
+ export async function mintThemeAssets(
89
+ decls: ThemeAssetDeclarations,
90
+ basePath: string,
91
+ read: AssetReader,
92
+ ): Promise<MintedThemeAssets> {
93
+ const urls: string[] = [];
94
+ const fontRules: string[] = [];
95
+ const varRules: string[] = [];
96
+
97
+ const mint = async (ref: string): Promise<string | null> => {
98
+ try {
99
+ const abs = resolvePath(basePath, ref);
100
+ const bytes = await read(abs);
101
+ const e = ext(abs);
102
+ const type = FONT_MIME[e] ?? IMAGE_MIME[e] ?? 'application/octet-stream';
103
+ const url = URL.createObjectURL(new Blob([bytes], { type }));
104
+ urls.push(url);
105
+ return url;
106
+ } catch {
107
+ return null; // degrade: the fallback stack / var() fallback answers
108
+ }
109
+ };
110
+
111
+ for (const f of decls.fonts ?? []) {
112
+ if (!f || typeof f.family !== 'string' || typeof f.src !== 'string') continue;
113
+ const url = await mint(f.src);
114
+ if (!url) continue;
115
+ const descriptors = [
116
+ `font-family: ${JSON.stringify(f.family)}`,
117
+ `src: url(${JSON.stringify(url)}) format(${JSON.stringify(ext(f.src) === 'woff2' ? 'woff2' : ext(f.src))})`,
118
+ ...(f.weight ? [`font-weight: ${f.weight}`] : []),
119
+ ...(f.style ? [`font-style: ${f.style}`] : []),
120
+ 'font-display: swap',
121
+ ];
122
+ fontRules.push(`@font-face{${descriptors.join(';')}}`);
123
+ }
124
+
125
+ for (const [name, ref] of Object.entries(decls.assets ?? {})) {
126
+ if (!/^[a-z][a-z0-9-]*$/i.test(name) || typeof ref !== 'string') continue;
127
+ const url = await mint(ref);
128
+ if (!url) continue;
129
+ varRules.push(`--asset-${name}: url(${JSON.stringify(url)});`);
130
+ }
131
+
132
+ let revoked = false;
133
+ return {
134
+ fontFaceCss: fontRules.join('\n'),
135
+ assetVarsCss: varRules.length ? `.grove-root{${varRules.join('')}}` : '',
136
+ minted: urls.length,
137
+ revoke() {
138
+ if (revoked) return;
139
+ revoked = true;
140
+ for (const u of urls) URL.revokeObjectURL(u);
141
+ },
142
+ };
143
+ }
@@ -45,8 +45,8 @@ describe('resolvePolarity — reader, else host, else the palette’s own prefer
45
45
  describe('the catalogue carries a preferred polarity for every theme (R3-308)', () => {
46
46
  it('every theme declares one, and unknown ids fall back to dark', () => {
47
47
  for (const t of THEMES) expect(['light', 'dark']).toContain(t.preferred);
48
- expect(preferredPolarity('pixies')).toBe('dark');
49
- expect(preferredPolarity('family')).toBe('light');
48
+ expect(preferredPolarity('editorial')).toBe('dark');
49
+ expect(preferredPolarity('archive')).toBe('light');
50
50
  expect(preferredPolarity('never-heard-of')).toBe('dark');
51
51
  });
52
52
  });
@@ -6,6 +6,8 @@ import Lede from './components/Lede';
6
6
  import Infobox from './components/Infobox';
7
7
  import More from './components/More';
8
8
  import DocList from './components/DocList';
9
+ import ThemeGallery from './components/ThemeGallery';
10
+ import LayoutGallery from './components/LayoutGallery';
9
11
  import TagCloud from './components/TagCloud';
10
12
  import TagList from './components/TagList';
11
13
  import Directory from './components/Directory';
@@ -61,6 +63,8 @@ export const GROVE_MDX = {
61
63
  ChildPages,
62
64
  Timeline,
63
65
  FamilyTree,
66
+ ThemeGallery,
67
+ LayoutGallery,
64
68
  // Layout primitives — a `_layout.mdx` arranges the shell out of these around an
65
69
  // <Outlet/>, so the site chrome is content (see src/lib/layout.ts).
66
70
  Outlet,
@@ -57,6 +57,43 @@
57
57
  "corpusTooling": { "type": "array", "items": { "type": "string" }, "description": "Keys a corpus's own tooling reads. The engine ignores them." },
58
58
  "passThrough": { "type": "boolean", "description": "Whether unknown keys are allowed (they are carried, unread). False makes the vocabulary closed." }
59
59
  }
60
+ },
61
+ "layouts": {
62
+ "type": "object",
63
+ "description": "The layout-starters catalogue (R3-309, bucket A): `_layout.mdx` starters under `content/_layouts/` a contributor or agent copies from. Keyed by starter id (= filename); `layoutRole` is the declared kind the entry and the file must agree on — the field existed in the sample layouts for a year with nothing reading it, and the catalogue is what gives it a job.",
64
+ "additionalProperties": {
65
+ "type": "object",
66
+ "required": ["layoutRole", "arranges", "ships", "summary"],
67
+ "additionalProperties": false,
68
+ "properties": {
69
+ "layoutRole": {
70
+ "enum": ["root", "section"],
71
+ "description": "`root` is an outermost shell (selects the nav arrangement); `section` nests under a root and wraps one folder."
72
+ },
73
+ "arranges": { "type": "string", "description": "The chrome the starter arranges around `<Outlet/>`." },
74
+ "ships": { "type": "boolean", "description": "Whether the starter file exists today (kept false for a catalogue entry whose file lands with a later item)." },
75
+ "summary": { "type": "string" }
76
+ }
77
+ }
78
+ },
79
+ "collections": {
80
+ "type": "object",
81
+ "description": "The collection-shapes catalogue (R3-309, bucket C): what a LIST of entries looks like. Behaviour lives here, not in a starter — an `_layout.mdx` cannot hold state, so infinite scroll is an engine component's literal prop, never a file you copy.",
82
+ "additionalProperties": {
83
+ "type": "object",
84
+ "required": ["ships", "summary"],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "component": { "type": "string", "description": "The engine component the shape rides on (absent for shapes that are not component calls at all — a directory URL is one)." },
88
+ "props": {
89
+ "type": "object",
90
+ "description": "The literal attributes a corpus writes to select the shape. Expression props are dropped silently by the interpreter — every documented call must stay literal.",
91
+ "additionalProperties": { "type": "string" }
92
+ },
93
+ "ships": { "type": "boolean", "description": "Whether the shape renders today; a not-yet-shipping entry documents what lands with which item." },
94
+ "summary": { "type": "string" }
95
+ }
96
+ }
60
97
  }
61
98
  }
62
99
  }
@@ -36,7 +36,7 @@
36
36
  "title": "string?",
37
37
  "children": "node?"
38
38
  },
39
- "summary": "An aside block with a kind (note / warning / \u2026)."
39
+ "summary": "An aside block with a kind (note / warning / …)."
40
40
  },
41
41
  "Lede": {
42
42
  "tier": "engine",
@@ -73,9 +73,11 @@
73
73
  "sanitizing": false,
74
74
  "props": {
75
75
  "tag": "string?",
76
- "limit": "number?"
76
+ "limit": "number?",
77
+ "paginate": "'infinite'?",
78
+ "batch": "number?"
77
79
  },
78
- "summary": "A queried list of entries \u2014 the generic index primitive."
80
+ "summary": "A queried list of entries — the generic index primitive. paginate=\"infinite\" windows it with a sentinel and a stated end (R3-314); the attribute must be a literal."
79
81
  },
80
82
  "TagCloud": {
81
83
  "tier": "engine",
@@ -177,7 +179,7 @@
177
179
  "overridable": true,
178
180
  "sanitizing": false,
179
181
  "props": {},
180
- "summary": "The entry's own metadata line (updated, reading time, \u2026)."
182
+ "summary": "The entry's own metadata line (updated, reading time, …)."
181
183
  },
182
184
  "RecentlyUpdated": {
183
185
  "tier": "engine",
@@ -227,7 +229,7 @@
227
229
  "overridable": false,
228
230
  "sanitizing": false,
229
231
  "props": {},
230
- "summary": "The layout chain's inward slot. NOT overridable \u2014 the chain's nesting is engine mechanics, and replacing it detaches every layer from the page it wraps."
232
+ "summary": "The layout chain's inward slot. NOT overridable — the chain's nesting is engine mechanics, and replacing it detaches every layer from the page it wraps."
231
233
  },
232
234
  "GroveNav": {
233
235
  "tier": "chrome",
@@ -249,6 +251,20 @@
249
251
  "sanitizing": false,
250
252
  "props": {},
251
253
  "summary": "The site footer."
254
+ },
255
+ "ThemeGallery": {
256
+ "tier": "engine",
257
+ "overridable": true,
258
+ "sanitizing": false,
259
+ "props": {},
260
+ "summary": "The themes gallery: one row per shipped theme, enumerated from this manifest — adding a theme adds a row with no edit to the entry."
261
+ },
262
+ "LayoutGallery": {
263
+ "tier": "engine",
264
+ "overridable": true,
265
+ "sanitizing": false,
266
+ "props": {},
267
+ "summary": "The layouts gallery: the starters, page variants and collection shapes grouped by their three mechanisms, enumerated from this manifest."
252
268
  }
253
269
  },
254
270
  "frontmatter": {
@@ -261,8 +277,118 @@
261
277
  "render",
262
278
  "nav",
263
279
  "order",
264
- "tags"
280
+ "tags",
281
+ "cover"
265
282
  ],
266
283
  "passThrough": true
284
+ },
285
+ "themes": {
286
+ "default": {
287
+ "label": "immediately.run",
288
+ "preferred": "dark",
289
+ "ships": true,
290
+ "summary": "The brand applied to a reading surface — the baseline every other look is measured against."
291
+ },
292
+ "archive": {
293
+ "label": "Archive",
294
+ "preferred": "light",
295
+ "ships": true,
296
+ "summary": "Encyclopedic reference: dense, quiet, contents-forward. Source Serif 4 reading face."
297
+ },
298
+ "journal": {
299
+ "label": "Journal",
300
+ "preferred": "light",
301
+ "ships": true,
302
+ "summary": "A personal notebook or family record: warm, short measure, soft frame. Lora reading face."
303
+ },
304
+ "editorial": {
305
+ "label": "Editorial",
306
+ "preferred": "dark",
307
+ "ships": true,
308
+ "summary": "Loud on purpose — a zine, a gig listing, a release archive. Loud through weight and colour, not a new face."
309
+ }
310
+ },
311
+ "pageVariants": {
312
+ "doc": {
313
+ "key": "layout",
314
+ "value": "doc",
315
+ "ships": true,
316
+ "summary": "The default reference page: contents rail, floated infobox, backlink index."
317
+ },
318
+ "post": {
319
+ "key": "layout",
320
+ "value": "post",
321
+ "ships": true,
322
+ "summary": "One measured column, no rails — the long-form entry."
323
+ },
324
+ "full": {
325
+ "key": "layout",
326
+ "value": "full",
327
+ "ships": true,
328
+ "summary": "The widest measure; drawn on the directory board rather than its own."
329
+ },
330
+ "bare": {
331
+ "key": "frame",
332
+ "value": "none",
333
+ "ships": true,
334
+ "summary": "No chrome at all; the entry supplies its own."
335
+ }
336
+ },
337
+ "layouts": {
338
+ "shell": {
339
+ "layoutRole": "root",
340
+ "arranges": "nav · sidebar · content · footer",
341
+ "ships": true,
342
+ "summary": "The site shell as copyable content. `nav: side` by default; flip the frontmatter to `nav: top` and drop the `<GroveSidebar/>` line for the top-nav arrangement."
343
+ },
344
+ "section": {
345
+ "layoutRole": "section",
346
+ "arranges": "shared hero + <Outlet/> + siblings rail",
347
+ "ships": true,
348
+ "summary": "A folder frame: one shared header above every entry in a subtree, `<ChildPages/>` below. Nest it under a root shell."
349
+ },
350
+ "hero": {
351
+ "layoutRole": "root",
352
+ "arranges": "a full-bleed image band over top navigation, then a short grid",
353
+ "ships": true,
354
+ "summary": "A landing root: `nav: top`, a hero band, then `<DocList shape=\"grid\" limit=\"4\"/>`. The picture slot renders the link-graph lattice until `cover:` lands."
355
+ }
356
+ },
357
+ "collections": {
358
+ "feed": {
359
+ "component": "DocList",
360
+ "props": {
361
+ "shape": "feed"
362
+ },
363
+ "ships": true,
364
+ "summary": "A dated list of entries — title, date, lede."
365
+ },
366
+ "image-excerpt": {
367
+ "component": "DocList",
368
+ "props": {
369
+ "shape": "grid"
370
+ },
371
+ "ships": true,
372
+ "summary": "Cards with a picture slot and an excerpt. The slot holds the link-graph lattice until `cover:` lands (R3-313); it is not gated on it."
373
+ },
374
+ "timeline": {
375
+ "component": "Timeline",
376
+ "props": {},
377
+ "ships": true,
378
+ "summary": "Entries on a dated spine; already matches the reference archetype."
379
+ },
380
+ "directory": {
381
+ "ships": true,
382
+ "summary": "A folder URL with no `index.mdx` of its own — the generated listing. Not a component call: the mechanism is the URL itself."
383
+ },
384
+ "infinite-scroll": {
385
+ "component": "DocList",
386
+ "props": {
387
+ "paginate": "infinite",
388
+ "batch": "20"
389
+ },
390
+ "ships": true,
391
+ "summary": "The list that grows as you scroll: a windowed feed with a keyboard-actionable sentinel and a stated end. Behaviour like this can ONLY be an engine component's literal prop — never a starter file."
392
+ }
267
393
  }
268
394
  }