@immediately-run/grove 0.1.3 → 0.1.5

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/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @immediately-run/grove — the viewer kit for directory-as-content wikis
2
2
 
3
- > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.3)
3
+ > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.5)
4
4
 
5
5
  Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
6
6
  spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
@@ -97,6 +97,7 @@ is one corpus's conventions (declared by that corpus, not this repo).
97
97
 
98
98
  - `site`
99
99
  - `theme`
100
+ - `stylesheets`
100
101
  - `layout`
101
102
  - `view`
102
103
  - `frame`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/grove",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -25,13 +25,13 @@
25
25
  "scripts": {
26
26
  "dev": "vite",
27
27
  "build": "tsc -b && vite build",
28
- "lint": "eslint .",
28
+ "lint": "eslint . --max-warnings 0",
29
29
  "test": "vitest run",
30
30
  "preview": "vite preview",
31
31
  "verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run check:published && npm run check:theme-contrast && npm run check:theme-token-only && npm run lint && npm run build && npm run test",
32
32
  "check:theme-contrast": "node scripts/check-theme-contrast.mjs --self-test && node scripts/check-theme-contrast.mjs",
33
33
  "check:theme-token-only": "node scripts/check-theme-token-only.mjs --self-test && node scripts/check-theme-token-only.mjs",
34
- "check:manifest": "node scripts/check-manifest.mjs",
34
+ "check:manifest": "node scripts/check-manifest.mjs --self-test && node scripts/check-manifest.mjs",
35
35
  "check:deps": "node scripts/check-app-dependencies.mjs --self-test && node scripts/check-app-dependencies.mjs",
36
36
  "check:published": "node scripts/check-published-parity.mjs --self-test && node scripts/check-published-parity.mjs --offline-ok",
37
37
  "check:engine-api": "node scripts/check-engine-api.mjs",
package/src/App.tsx CHANGED
@@ -12,8 +12,12 @@
12
12
  // Every helper the wiki is made of — which keys are entries, what their hrefs are, which
13
13
  // layouts wrap them — is a function of the root; every surface a reader sees — nav,
14
14
  // sidebar, search, backlinks, routing, the 404 index — is a function of the index. A
15
- // component that rendered before either was settled would show the VIEWER's corpus, or an
16
- // empty one, and then not correct itself. Both are outcomes dispatch may never produce.
15
+ // component that rendered before the root was settled would show the VIEWER's corpus, and
16
+ // then not correct itself; so would one that rendered with no index in scope, because the
17
+ // metadata hooks fall back to the host's store. So this file holds until the root is
18
+ // settled and the bundle is LISTED — every key known, rows filling in — and from then on
19
+ // always provides the bundle's index. Which rows an entry needs READ before it paints is
20
+ // GroveWiki's call (MDX_FROM_MOUNT_SPEC D8).
17
21
  //
18
22
  // Hence the split: this file resolves, `GroveWiki` renders.
19
23
 
@@ -29,6 +33,8 @@ import { useContentComponents } from './hooks/useContentComponents';
29
33
  import { getContentRoot } from './lib/contentRoot';
30
34
  import { viewedDocumentForTarget } from './lib/content';
31
35
  import GroveWiki from './GroveWiki';
36
+ import BootMessage from './components/BootMessage';
37
+ import { CorpusScanContext } from './lib/corpusScanContext';
32
38
 
33
39
  // R3-268 — the viewed-document rule, registered ONCE at module load: every
34
40
  // `navigate()`/`<Link>` navigation declares which file the destination renders,
@@ -81,25 +87,18 @@ export default function App() {
81
87
  boot.status === 'ready' || boot.status === 'fork' ? getContentRoot() : null,
82
88
  );
83
89
 
84
- if (boot.status === 'failed') {
85
- return (
86
- <div className="grove-boot">
87
- <p className="grove-boot__msg">{boot.message}</p>
88
- </div>
89
- );
90
- }
90
+ if (boot.status === 'failed') return <BootMessage>{boot.message}</BootMessage>;
91
91
 
92
92
  // The provider must be COMPLETE before content paints (MDX_FROM_MOUNT_SPEC §2's
93
93
  // invariant): rendering into a half-composed map would flash a missing-component error
94
94
  // for `<RoadmapBoard>` until registration landed — the very error content components
95
95
  // exist to remove — and a nested provider patched in afterwards would do the same.
96
- // Holding here costs nothing, because the gate already exists for the bundle scan.
97
- if (boot.status === 'waiting' || bundle.status === 'scanning' || contentComponents.status === 'loading') {
98
- return (
99
- <div className="grove-boot">
100
- <p className="grove-boot__msg">Opening…</p>
101
- </div>
102
- );
96
+ //
97
+ // The frontmatter index is NOT held for (D8). Once the bundle is listed every key is
98
+ // known; the rows the requested entry needs are GroveWiki's to wait for, and the rest
99
+ // fill in around a painted page.
100
+ if (boot.status === 'waiting' || bundle.status === 'listing' || contentComponents.status === 'loading') {
101
+ return <BootMessage />;
103
102
  }
104
103
 
105
104
  // Corpus-declared components (R3-174) go on as a nested provider, which the SDK's
@@ -120,14 +119,17 @@ export default function App() {
120
119
  // the resolved theme (the catalogue looks change the reading face); nothing
121
120
  // font-shaped waits on the scan from here.
122
121
 
123
- if (bundle.status === 'ready' && bundle.metadata) {
122
+ if (bundle.metadata && bundle.scan) {
124
123
  // Provide the scanned bundle as the metadata SOURCE through the supported
125
124
  // surface (R3-276), not a wholesale TinkerableContext re-provision: the
126
125
  // platform stays free to grow its own state, and the hooks read the nearest
127
126
  // MetadataSource — so every consumer works unchanged, and nothing re-states
128
- // host fields it does not own.
127
+ // host fields it does not own. Provided from the first partial index on, so the
128
+ // tree keeps its shape when the scan completes rather than remounting the wiki.
129
129
  return (
130
- <MetadataSource value={bundle.metadata}>{withComponents}</MetadataSource>
130
+ <CorpusScanContext value={bundle.scan}>
131
+ <MetadataSource value={bundle.metadata}>{withComponents}</MetadataSource>
132
+ </CorpusScanContext>
131
133
  );
132
134
  }
133
135
 
package/src/GroveApp.css CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  /* R3-316 — the escape hatch, pinned where content cannot reach: for !important
10
10
  declarations EARLIER layers win, so grove.reset (the first layer, ordered in
11
- index.css) beats grove.content no matter what a ui/stylesheet tries. A reader
11
+ index.css) beats grove.content no matter what a content stylesheet tries. A reader
12
12
  can always reach a shipped theme without clearing storage — the one guarantee
13
13
  the content layer must not be able to revoke. Proven by test (the hiding
14
14
  vectors are enumerated), not by reading this comment. */
@@ -2994,9 +2994,9 @@ button.grove-search__row {
2994
2994
  padding: 0 18px;
2995
2995
  }
2996
2996
 
2997
- /* Boot gate (R3-169) — shown only while a dispatched viewer resolves its delegated
2998
- corpus, or when that delegation never arrived. Deliberately plain: at this point the
2999
- corpus is not readable, so none of its theming applies. */
2997
+ /* Boot gate (R3-169) — shown while a dispatched viewer resolves its delegated corpus,
2998
+ lists it, or reads the few files the requested entry needs; and when that delegation
2999
+ never arrived. Deliberately plain: the corpus's own theming is not applied yet. */
3000
3000
  .grove-boot {
3001
3001
  display: grid;
3002
3002
  place-items: center;
@@ -0,0 +1,216 @@
1
+ // @vitest-environment jsdom
2
+ // The entry gate and declared stylesheets, rendered (MDX_FROM_MOUNT_SPEC D7, D8):
3
+ // • while a file the entry needs is unread, the body is the boot line and the layers are
4
+ // not rendered — and those files are asked for first;
5
+ // • once they are read, the entry renders in the same mounted shell;
6
+ // • the stylesheets home declares hold the body until they are read, then apply;
7
+ // • a declaration that names no entry is shown to the reader.
8
+ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot, type Root } from 'react-dom/client';
11
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
12
+ import { CorpusScanContext } from './lib/corpusScanContext';
13
+ import type { CorpusScanGate } from './lib/corpusScan';
14
+
15
+ beforeAll(() => {
16
+ Object.defineProperty(window, 'matchMedia', {
17
+ writable: true,
18
+ value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
19
+ });
20
+ });
21
+
22
+ // One read double behind both doors: the `fs` module (entry bodies, declared stylesheets)
23
+ // and the SDK's shared fs (layouts, theme faces).
24
+ const { readFile } = vi.hoisted(() => ({ readFile: vi.fn() }));
25
+ vi.mock('fs', () => ({ default: { promises: { readFile: (...a: unknown[]) => readFile(...a) } } }));
26
+ (globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
27
+ promises: { readFile: (...a: unknown[]) => readFile(...a) },
28
+ };
29
+
30
+ const { default: GroveWiki } = await import('./GroveWiki');
31
+
32
+ const ENTRY = '/app/content/wiki/a.mdx';
33
+ const HOME = '/app/content/home.mdx';
34
+ const LAYOUT = '/app/content/_layout.mdx';
35
+ const SHEET = '/app/content/themes/paper.mdx';
36
+
37
+ const NAV = {
38
+ mode: 'github',
39
+ namespace: 'immediately-run',
40
+ provider: 'github',
41
+ repository: 'corpus',
42
+ ref: 'main',
43
+ sandboxPath: ENTRY,
44
+ hash: '',
45
+ search: '',
46
+ };
47
+
48
+ // `render: safe` renders the body through the real safe renderer over the fs double (the
49
+ // compiled evaluator exists only inside the sandbox bundler).
50
+ function meta(home: Record<string, unknown> = {}) {
51
+ return {
52
+ [ENTRY]: { title: 'Reference entry.', render: 'safe' },
53
+ [HOME]: { title: 'Home', ...home },
54
+ [LAYOUT]: { site: 'Gate fixture' },
55
+ [SHEET]: {},
56
+ };
57
+ }
58
+
59
+ function gate(settled: Set<string> | 'all', failed: Record<string, string> = {}) {
60
+ return {
61
+ isSettled: (k: string) => settled === 'all' || settled.has(k),
62
+ prioritize: vi.fn<CorpusScanGate['prioritize']>(),
63
+ readFailure: (k: string) => failed[k] ?? null,
64
+ } satisfies CorpusScanGate;
65
+ }
66
+
67
+ async function render(root: Root, scan: CorpusScanGate, filesMetadata: Record<string, unknown>) {
68
+ await act(async () => {
69
+ root.render(
70
+ <TinkerableContext.Provider
71
+ value={{ outerHref: 'https://immediately.run/x', navigationState: NAV, routingSpec: { routes: [] } as never, filesMetadata } as never}
72
+ >
73
+ <CorpusScanContext value={scan}>
74
+ <GroveWiki />
75
+ </CorpusScanContext>
76
+ </TinkerableContext.Provider>,
77
+ );
78
+ });
79
+ for (let i = 0; i < 6; i++) await act(async () => {});
80
+ }
81
+
82
+ function mount(): { container: HTMLElement; root: Root } {
83
+ const container = document.createElement('div');
84
+ document.body.appendChild(container);
85
+ return { container, root: createRoot(container) };
86
+ }
87
+
88
+ const bootLine = (c: HTMLElement) => c.querySelector('.grove-shell .grove-boot__msg')?.textContent ?? null;
89
+
90
+ beforeEach(() => {
91
+ readFile.mockReset();
92
+ readFile.mockImplementation(async (path: string) =>
93
+ path === SHEET ? '---\ntitle: Paper\n---\n--bg: #123456;\n' : '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n',
94
+ );
95
+ localStorage.clear();
96
+ });
97
+
98
+ describe('the entry gate (D8)', () => {
99
+ it('holds the body on the boot line while the entry is unread, and asks for its files first', async () => {
100
+ const { container, root } = mount();
101
+ const scan = gate(new Set([HOME, LAYOUT]));
102
+ await render(root, scan, meta());
103
+ expect(bootLine(container)).toBe('Opening…');
104
+ expect(container.textContent).not.toContain('The body text.');
105
+ expect(container.querySelector('.grove-root')).toBeTruthy(); // the shell is mounted
106
+ expect(scan.prioritize).toHaveBeenCalledWith([HOME, ENTRY, LAYOUT]);
107
+ await act(async () => root.unmount());
108
+ });
109
+
110
+ it('renders the entry in the same shell once its files are read', async () => {
111
+ const { container, root } = mount();
112
+ await render(root, gate(new Set([HOME, LAYOUT])), meta());
113
+ const shell = container.querySelector('.grove-root');
114
+ await render(root, gate('all'), meta());
115
+ expect(bootLine(container)).toBeNull();
116
+ expect(container.textContent).toContain('The body text.');
117
+ expect(container.querySelector('.grove-root')).toBe(shell); // not remounted
118
+ await act(async () => root.unmount());
119
+ });
120
+
121
+ it('a critical file that failed to read fails closed: the reason, never the layers', async () => {
122
+ const { container, root } = mount();
123
+ await render(root, gate('all', { [HOME]: 'EIO' }), meta());
124
+ expect(bootLine(container)).toBe(`Could not read ${HOME} (EIO). Reload to try again.`);
125
+ expect(container.textContent).not.toContain('The body text.');
126
+ await act(async () => root.unmount());
127
+ });
128
+
129
+ it('an unread HOME holds the body too — its `render: safe` is not known yet', async () => {
130
+ const { container, root } = mount();
131
+ await render(root, gate(new Set([ENTRY, LAYOUT])), meta());
132
+ expect(bootLine(container)).toBe('Opening…');
133
+ await act(async () => root.unmount());
134
+ });
135
+ });
136
+
137
+ describe('declared stylesheets (D7)', () => {
138
+ it('hold the body until read, then apply into the content layer', async () => {
139
+ let release!: () => void;
140
+ const held = new Promise<void>((r) => (release = r));
141
+ readFile.mockImplementation(async (path: string) => {
142
+ if (path === SHEET) {
143
+ await held;
144
+ return '---\ntitle: Paper\n---\n--bg: #123456;\n';
145
+ }
146
+ return '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n';
147
+ });
148
+ const { container, root } = mount();
149
+ await render(root, gate('all'), meta({ stylesheets: ['themes/paper.mdx'] }));
150
+ expect(bootLine(container)).toBe('Opening…');
151
+ expect(readFile).toHaveBeenCalledWith(SHEET, 'utf8');
152
+ await act(async () => release());
153
+ for (let i = 0; i < 6; i++) await act(async () => {});
154
+ expect(bootLine(container)).toBeNull();
155
+ expect(container.querySelector('style[data-grove-content-theme]')?.textContent).toContain('--bg: #123456;');
156
+ await act(async () => root.unmount());
157
+ });
158
+
159
+ it('a tagged entry that home does not declare is NOT applied — tag discovery is gone', async () => {
160
+ const { container, root } = mount();
161
+ const m = meta();
162
+ m[SHEET] = { tags: ['ui/stylesheet'] };
163
+ await render(root, gate('all'), m);
164
+ expect(container.querySelector('style[data-grove-content-theme]')).toBeNull();
165
+ expect(readFile).not.toHaveBeenCalledWith(SHEET, 'utf8');
166
+ await act(async () => root.unmount());
167
+ });
168
+
169
+ it('a declaration that names no entry is shown, and does not hold the page', async () => {
170
+ const { container, root } = mount();
171
+ await render(root, gate('all'), meta({ stylesheets: ['../outside.mdx'] }));
172
+ const error = container.querySelector('.grove-decl-error');
173
+ expect(error?.textContent).toContain('../outside.mdx');
174
+ expect(bootLine(container)).toBeNull();
175
+ await act(async () => root.unmount());
176
+ });
177
+
178
+ it('a declared sheet that will not read is shown with its path', async () => {
179
+ // Its own path: `safeSources` is the session-wide source cache, and a sheet an earlier
180
+ // case read successfully stays read.
181
+ const broken = '/app/content/themes/broken.mdx';
182
+ readFile.mockImplementation(async (path: string) => {
183
+ if (path === broken) throw new Error('ENOENT');
184
+ return '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n';
185
+ });
186
+ const { container, root } = mount();
187
+ await render(root, gate('all'), { ...meta({ stylesheets: ['themes/broken.mdx'] }), [broken]: {} });
188
+ expect(container.querySelector('.grove-decl-error')?.textContent).toContain(`${broken} — could not be read`);
189
+ expect(container.textContent).toContain('The body text.');
190
+ await act(async () => root.unmount());
191
+ });
192
+ });
193
+
194
+ describe('declared stylesheets — the deadline', () => {
195
+ it('a sheet read that never answers stops holding the page, and says so', async () => {
196
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
197
+ try {
198
+ const stalled = '/app/content/themes/stalled.mdx';
199
+ readFile.mockImplementation((path: string) =>
200
+ path === stalled ? new Promise(() => undefined) : Promise.resolve('---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n'),
201
+ );
202
+ const { container, root } = mount();
203
+ await render(root, gate('all'), { ...meta({ stylesheets: ['themes/stalled.mdx'] }), [stalled]: {} });
204
+ expect(bootLine(container)).toBe('Opening…');
205
+ await act(async () => {
206
+ vi.advanceTimersByTime(15_000);
207
+ });
208
+ for (let i = 0; i < 6; i++) await act(async () => {});
209
+ expect(bootLine(container)).toBeNull();
210
+ expect(container.querySelector('.grove-decl-error')?.textContent).toContain(`${stalled} — could not be read (no answer after 15 s)`);
211
+ await act(async () => root.unmount());
212
+ } finally {
213
+ vi.useRealTimers();
214
+ }
215
+ });
216
+ });
package/src/GroveWiki.tsx CHANGED
@@ -44,9 +44,13 @@ import Search from './components/Search';
44
44
  import Drawer from './components/Drawer';
45
45
  import GroveAgent from './components/GroveAgent';
46
46
  import ThemeAssets from './components/ThemeAssets';
47
- import ContentTheme, { type ContentStylesheet } from './components/ContentTheme';
47
+ import ContentTheme from './components/ContentTheme';
48
+ import BootMessage from './components/BootMessage';
48
49
  import { themeAssetsFor } from './data/themeFonts';
49
- import { parseFrontmatter } from './lib/frontmatter';
50
+ import { useContentStylesheets } from './hooks/useContentStylesheets';
51
+ import { criticalKeys } from './lib/criticalKeys';
52
+ import { criticalFailure, entryPending } from './lib/entryGate';
53
+ import { CorpusScanContext } from './lib/corpusScanContext';
50
54
 
51
55
  declare const module: any;
52
56
 
@@ -270,44 +274,29 @@ export default function GroveWiki({
270
274
  // metadata store and re-derives when the content set or the entry changes.
271
275
  const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
272
276
 
273
- // ── Content-carried themes (R3-316) ────────────────────────────────────────
274
- // `ui/stylesheet` entries discovered from the SAME index every other surface
275
- // reads (mode-invariant: fork, dispatch and library all fill it); bodies are
276
- // raw CSS behind frontmatter, gated by the grammar inside ContentTheme and
277
- // admitted only into the lowest cascade layer. A rejected sheet degrades to a
278
- // status line naming the line — never a silent drop, never a crash.
279
- const [contentSheets, setContentSheets] = useState<ContentStylesheet[]>([]);
277
+ // ── Content-carried themes (R3-316; MDX_FROM_MOUNT_SPEC D7) ─────────────────
278
+ // The stylesheets the home entry declares (`stylesheets:`); bodies are raw CSS
279
+ // behind frontmatter, gated by the grammar inside ContentTheme and admitted only
280
+ // into the lowest cascade layer. A declaration that names no entry, a sheet that
281
+ // will not read, and a sheet the grammar rejects all degrade to a status line
282
+ // naming it — never a silent drop, never a crash.
283
+ const stylesheets = useContentStylesheets(homeMeta?.stylesheets, homeKey());
280
284
  const [rejectedSheet, setRejectedSheet] = useState<string | null>(null);
285
+ const sheetErrors = rejectedSheet ? [...stylesheets.errors, rejectedSheet] : stylesheets.errors;
286
+
287
+ // ── The entry gate (MDX_FROM_MOUNT_SPEC D8) ─────────────────────────────────
288
+ // Under dispatch the index fills in while the wiki is already on screen. The files
289
+ // that decide how THIS entry renders — itself, home, its layouts, its `frame:` — are
290
+ // read ahead of the rest, and the body waits for them: an unread row reads as
291
+ // `render` unset, which is the executing path. The chrome around it stays mounted.
292
+ const scanGate = useContext(CorpusScanContext);
293
+ const critical = criticalKeys(entryKey, allMeta, scanGate.readFailure);
294
+ const criticalSig = critical.join('|');
281
295
  useEffect(() => {
282
- let alive = true;
283
- const found: ContentStylesheet[] = [];
284
- const paths = Object.keys(allMeta).filter((p) => {
285
- const tags = allMeta[p]?.tags;
286
- return Array.isArray(tags) && tags.includes('ui/stylesheet');
287
- });
288
- (async () => {
289
- for (const p of paths) {
290
- try {
291
- const raw = await (await import('./lib/safeSources')).safeSources.read(p);
292
- const parsed = parseFrontmatter(raw);
293
- found.push({
294
- path: p,
295
- css: parsed.body,
296
- declarations: {
297
- ...(Array.isArray(parsed.data.fonts) ? { fonts: parsed.data.fonts as never } : {}),
298
- ...(parsed.data.assets && typeof parsed.data.assets === 'object' ? { assets: parsed.data.assets as never } : {}),
299
- },
300
- });
301
- } catch {
302
- /* an unreadable stylesheet contributes nothing — same as an absent one */
303
- }
304
- }
305
- if (alive) setContentSheets(found);
306
- })();
307
- return () => {
308
- alive = false;
309
- };
310
- }, [allMeta]);
296
+ scanGate.prioritize(criticalSig.split('|'));
297
+ }, [scanGate, criticalSig]);
298
+ const pending = entryPending(critical, scanGate.isSettled, stylesheets.status);
299
+ const failure = criticalFailure(critical, scanGate.readFailure);
311
300
  const chain: string[] = layoutChainForKey(entryKey, allMeta);
312
301
  const frameNone = meta?.frame === 'none' || meta?.frame === false;
313
302
 
@@ -448,17 +437,21 @@ export default function GroveWiki({
448
437
  @font-face rules (R3-315's mechanism); switching themes revokes the
449
438
  outgoing set and re-mints. */}
450
439
  <ThemeAssets declarations={themeAssetsFor(theme)} />
451
- <ContentTheme sheets={contentSheets} onRejected={(path, v) => setRejectedSheet(`${path}:${v.line} — ${v.reason}`)} />
440
+ <ContentTheme sheets={stylesheets.sheets} onRejected={(path, v) => setRejectedSheet(`${path}:${v.line} — ${v.reason}`)} />
452
441
  {/* R3-627: remember where the reader was on the entry they leave, and put
453
442
  them back there on Back. Grove's scroller is its own container, so the
454
443
  ref goes across; `useScrollReset` above stands down on the same
455
444
  traversal so the two do not fight. */}
456
445
  <ScrollRestoration scroller={scrollRef} />
457
446
  <div className="device__scroll" ref={scrollRef}>
458
- {rejectedSheet ? (
447
+ {sheetErrors.length > 0 ? (
459
448
  <div className="grove-decl-error" role="status">
460
- <strong>A stylesheet entry was rejected by the theme grammar.</strong>
461
- <div>{rejectedSheet}</div>
449
+ <strong>A stylesheet this corpus declares could not be applied.</strong>
450
+ <ul>
451
+ {sheetErrors.map((e, i) => (
452
+ <li key={i}>{e}</li>
453
+ ))}
454
+ </ul>
462
455
  </div>
463
456
  ) : null}
464
457
  {rejectedComponents.length > 0 ? (
@@ -474,7 +467,7 @@ export default function GroveWiki({
474
467
  </div>
475
468
  ) : null}
476
469
  <div className="grove-shell" data-nav={frameNone ? undefined : navMode}>
477
- {renderLayers(chain, useDefault, safe)}
470
+ {failure ? <BootMessage>{failure}</BootMessage> : pending ? <BootMessage /> : renderLayers(chain, useDefault, safe)}
478
471
  </div>
479
472
  </div>
480
473
 
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /** The single line Grove shows while it cannot paint an entry yet — booting, listing the
4
+ * bundle, or waiting on the files an entry needs — and, given a message, when boot fails. */
5
+ export default function BootMessage({ children = 'Opening…' }: { children?: ReactNode }) {
6
+ return (
7
+ <div className="grove-boot">
8
+ <p className="grove-boot__msg">{children}</p>
9
+ </div>
10
+ );
11
+ }
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  // R3-316's component + escape-hatch exits:
3
- // • a clean ui/stylesheet renders into @layer grove.content (and its declared
3
+ // • a clean content stylesheet renders into @layer grove.content (and its declared
4
4
  // faces mint through the R3-315 path — never a url());
5
5
  // • a rejected sheet names the line and degrades (no style element for it);
6
6
  // • THE ESCAPE HATCH: the hiding vectors a content stylesheet would try are
@@ -1,12 +1,12 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- import { gateStylesheet } from '../lib/contentStylesheet';
2
+ import { gateStylesheet, type ContentStylesheet } from '../lib/contentStylesheet';
3
3
  import { mintThemeAssets, type MintedThemeAssets } from '../lib/themeAssets';
4
4
  import { openFs } from '@immediately-run/sdk';
5
5
 
6
- // A bundle's own look, carried as content (R3-316): each `ui/stylesheet` entry's
7
- // body is gated by the grammar (declarations only — a selector, at-rule, `url(`,
8
- // `@import` or `@font-face` is rejected with the line named, never silently
9
- // dropped) and admitted ONLY into the lowest cascade layer (`grove.content`),
6
+ // A bundle's own look, carried as content (R3-316): each stylesheet the home entry
7
+ // declares (`stylesheets:`, MDX_FROM_MOUNT_SPEC D7) has its body gated by the grammar
8
+ // (declarations only — a selector, at-rule, `url(`, `@import` or `@font-face` is
9
+ // rejected with the line named, never silently dropped) and admitted ONLY into the lowest cascade layer (`grove.content`),
10
10
  // where it can style tokens and nothing else. The entry's frontmatter may
11
11
  // declare `fonts:`/`assets:` — minted by the engine exactly as an engine theme's
12
12
  // are, so a content theme can change the reading face without naming a location.
@@ -22,15 +22,6 @@ import { openFs } from '@immediately-run/sdk';
22
22
 
23
23
  const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
24
24
 
25
- export interface ContentStylesheet {
26
- /** The entry's absolute fs path (the declaring file for its asset refs). */
27
- path: string;
28
- /** The raw body bytes (CSS). */
29
- css: string;
30
- /** The entry's declared fonts/assets, if any. */
31
- declarations: { fonts?: unknown; assets?: unknown };
32
- }
33
-
34
25
  interface Props {
35
26
  sheets: ContentStylesheet[];
36
27
  /** Where a rejected sheet's verdict goes (the reader-facing degrade). */
@@ -5,39 +5,56 @@
5
5
  // wiki renders with empty nav, empty sidebar, no search, no backlinks and no routing —
6
6
  // silently, because an absent index is indistinguishable from an empty bundle.
7
7
  //
8
- // The scan runs ONCE per root and the result is handed to `TinkerableContext.filesMetadata`,
9
- // which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata` already read
10
- // from — so every consumer keeps working untouched.
8
+ // The index is PROGRESSIVE (MDX_FROM_MOUNT_SPEC D8): once the listing is done every key is
9
+ // present, and rows fill in as files are read. The caller hands `metadata` to a
10
+ // `MetadataSource`, which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata`
11
+ // read from, and hands `scan` to the entry gate, which asks whether the rows it needs have
12
+ // been read.
11
13
 
12
- import { useEffect, useState } from 'react';
14
+ import { useEffect, useState, useSyncExternalStore } from 'react';
13
15
  import fs from 'fs';
14
- import { scanCorpus, type CorpusMetadata, type ScanFs } from '../lib/corpusScan';
16
+ import {
17
+ createCorpusScan,
18
+ type CorpusMetadata,
19
+ type CorpusScan,
20
+ type CorpusScanSnapshot,
21
+ type CorpusScanStatus,
22
+ type ScanFs,
23
+ } from '../lib/corpusScan';
15
24
 
16
25
  export interface BundleIndex {
17
- /** `idle` — a fork, nothing to scan · `scanning` — hold the render · `ready` — use it. */
18
- status: 'idle' | 'scanning' | 'ready';
26
+ /** `idle` — a fork, nothing to scan · `listing` — hold the render, no key is known yet ·
27
+ * `reading` — every key is known, rows are filling in · `complete` — every row is read. */
28
+ status: 'idle' | CorpusScanStatus;
29
+ /** Null until the listing is done (and always for a fork). */
19
30
  metadata: CorpusMetadata | null;
31
+ /** The running scan, for the entry gate. Null for a fork and before the scan starts. */
32
+ scan: CorpusScan | null;
20
33
  }
21
34
 
35
+ const LISTING: CorpusScanSnapshot = { status: 'listing', metadata: {} };
36
+ const noSubscribe = () => () => undefined;
37
+ const listingSnapshot = () => LISTING;
38
+
22
39
  /** Scan `root`, or do nothing when it is null (the fork packaging). */
23
40
  export function useBundleMetadata(root: string | null): BundleIndex {
24
- const [index, setIndex] = useState<{ root: string; metadata: CorpusMetadata } | null>(null);
41
+ const [owned, setOwned] = useState<{ root: string; scan: CorpusScan } | null>(null);
25
42
 
26
43
  useEffect(() => {
27
- if (!root || index?.root === root) return;
28
- let cancelled = false;
29
- void scanCorpus(root, fs.promises as unknown as ScanFs).then((metadata) => {
30
- // A bundle that resolves to nothing is still a result: `ready` with an empty map
31
- // renders the 404 index, which tells the reader the folder has no entries. Staying
32
- // in `scanning` forever would show a spinner and say nothing.
33
- if (!cancelled) setIndex({ root, metadata });
34
- });
35
- return () => {
36
- cancelled = true;
37
- };
38
- }, [root, index?.root]);
44
+ if (!root) return;
45
+ const scan = createCorpusScan(root, fs.promises as unknown as ScanFs);
46
+ // The scan is an external resource this effect owns: created here, disposed below.
47
+ // eslint-disable-next-line react-hooks/set-state-in-effect
48
+ setOwned({ root, scan });
49
+ return () => scan.dispose();
50
+ }, [root]);
51
+
52
+ const scan = owned && owned.root === root ? owned.scan : null;
53
+ const snap = useSyncExternalStore(scan ? scan.subscribe : noSubscribe, scan ? scan.snapshot : listingSnapshot);
39
54
 
40
- if (!root) return { status: 'idle', metadata: null };
41
- if (index?.root === root) return { status: 'ready', metadata: index.metadata };
42
- return { status: 'scanning', metadata: null };
55
+ if (!root) return { status: 'idle', metadata: null, scan: null };
56
+ // A bundle that resolves to nothing is still a result: `complete` with an empty map
57
+ // renders the 404 index, which tells the reader the folder has no entries.
58
+ if (snap.status === 'listing') return { status: 'listing', metadata: null, scan };
59
+ return { status: snap.status, metadata: snap.metadata, scan };
43
60
  }
@@ -95,8 +95,8 @@ async function loadDeclared(root: string): Promise<Omit<ContentComponents, 'stat
95
95
  *
96
96
  * Returns `loading` until every declaration has resolved, so the caller can hold the
97
97
  * content paint. That is the §2 invariant — compose the complete provider before content
98
- * paints, never render into a partial one — and it costs nothing here because Grove
99
- * already gates on `useOpenWikiBoot` and `useBundleMetadata`. Rendering into a
98
+ * paints, never render into a partial one — and it costs little here because Grove
99
+ * already gates on `useOpenWikiBoot` and on the bundle listing. Rendering into a
100
100
  * half-composed provider would flash a missing-component error for `<RoadmapBoard>` until
101
101
  * registration landed, which is exactly the error this path removes.
102
102
  */