@immediately-run/grove 0.1.1 → 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 (85) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +6 -2
  3. package/package.json +20 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +485 -85
  6. package/src/GroveWiki.tsx +176 -54
  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 +24 -15
  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 -149
  21. package/src/components/GroveNav.test.tsx +229 -0
  22. package/src/components/GroveNav.tsx +69 -20
  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 +19 -4
  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 +44 -4
  40. package/src/devfs.d.ts +5 -4
  41. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  42. package/src/hooks/useContentComponents.ts +1 -1
  43. package/src/hooks/useEditAffordance.ts +99 -0
  44. package/src/hooks/useOpenWikiBoot.ts +1 -1
  45. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  46. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  47. package/src/hooks/useScrollReset.test.tsx +132 -0
  48. package/src/hooks/useScrollReset.ts +52 -0
  49. package/src/index.css +9 -2
  50. package/src/lib/agentPrompt.test.ts +96 -0
  51. package/src/lib/agentPrompt.ts +86 -0
  52. package/src/lib/agentTools.test.ts +115 -0
  53. package/src/lib/agentTools.ts +132 -0
  54. package/src/lib/agentTranscript.ts +50 -0
  55. package/src/lib/assetPath.test.ts +36 -0
  56. package/src/lib/assetPath.ts +42 -0
  57. package/src/lib/collectionCalls.test.ts +69 -0
  58. package/src/lib/content.test.ts +10 -2
  59. package/src/lib/content.ts +7 -0
  60. package/src/lib/contentRoot.ts +16 -1
  61. package/src/lib/contentStylesheet.test.ts +80 -0
  62. package/src/lib/contentStylesheet.ts +103 -0
  63. package/src/lib/corpusScan.test.ts +37 -3
  64. package/src/lib/corpusScan.ts +17 -2
  65. package/src/lib/editTarget.test.ts +108 -0
  66. package/src/lib/editTarget.ts +93 -0
  67. package/src/lib/inlineProse.parity.test.ts +52 -0
  68. package/src/lib/layout.ts +29 -0
  69. package/src/lib/openWiki.test.ts +46 -5
  70. package/src/lib/openWiki.ts +18 -3
  71. package/src/lib/pageVariants.test.tsx +87 -0
  72. package/src/lib/queries.test.ts +33 -1
  73. package/src/lib/queries.ts +33 -1
  74. package/src/lib/reachCard.test.ts +94 -0
  75. package/src/lib/reachCard.ts +112 -0
  76. package/src/lib/shell.ts +17 -0
  77. package/src/lib/starterSweep.test.tsx +160 -0
  78. package/src/lib/starterSweep.ts +97 -0
  79. package/src/lib/themeAssets.test.ts +135 -0
  80. package/src/lib/themeAssets.ts +143 -0
  81. package/src/lib/themeSelection.test.ts +52 -0
  82. package/src/lib/themeSelection.ts +59 -0
  83. package/src/mdxComponents.ts +4 -0
  84. package/viewer-manifest.schema.json +37 -0
  85. package/viewer.manifest.json +133 -6
package/src/GroveWiki.tsx CHANGED
@@ -5,14 +5,15 @@ import fs from 'fs';
5
5
  import type { Metadata } from '@immediately-run/sdk';
6
6
  import {
7
7
  Include,
8
+ ScrollRestoration,
8
9
  useAllMetadata,
9
10
  useFileMetadata,
11
+ useHostTheme,
10
12
  useMetadataQuery,
11
- useMounts,
12
13
  } from '@immediately-run/sdk';
13
14
  import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
14
15
  import { LinkSpaceContext } from '@immediately-run/sdk/linkSpace';
15
- import { CorpusContext, toCorpusPath, fromCorpusPath } from '@immediately-run/sdk/corpus';
16
+ import { BundleContext, toBundlePath, fromBundlePath } from '@immediately-run/sdk/bundle';
16
17
  import {
17
18
  contentDir,
18
19
  homeKey,
@@ -23,12 +24,16 @@ import {
23
24
  sandboxPathToKey,
24
25
  } from './lib/content';
25
26
  import { queryPaths, queryRecords, readingTime, stripFrontmatter } from './lib/wiki';
26
- import { navQuery } from './lib/queries';
27
+ import { navQuery, plainLabel } from './lib/queries';
27
28
  import type { NavRecord } from './lib/queries';
28
- import { layoutChainForKey } from './lib/layout';
29
+ import { layoutChainForKey, resolveNavMode, resolvePageLayout } from './lib/layout';
29
30
  import { folderIndexKey } from './lib/directory';
31
+ import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
32
+ import { preferredPolarity } from './data/themes';
30
33
  import { useDirectoryListing } from './hooks/useDirectoryListing';
31
- import { getContentRoot, isDispatched } from './lib/contentRoot';
34
+ import { useScrollReset } from './hooks/useScrollReset';
35
+ import { useEditAffordance } from './hooks/useEditAffordance';
36
+ import { getContentRoot } from './lib/contentRoot';
32
37
  import type { RejectedComponent } from './lib/corpusComponents';
33
38
  import { GroveShellContext, OutletContext } from './lib/shell';
34
39
  import type { GroveShell, NavItem } from './lib/shell';
@@ -38,6 +43,10 @@ import DefaultLayout from './components/DefaultLayout';
38
43
  import Search from './components/Search';
39
44
  import Drawer from './components/Drawer';
40
45
  import GroveAgent from './components/GroveAgent';
46
+ import ThemeAssets from './components/ThemeAssets';
47
+ import ContentTheme, { type ContentStylesheet } from './components/ContentTheme';
48
+ import { themeAssetsFor } from './data/themeFonts';
49
+ import { parseFrontmatter } from './lib/frontmatter';
41
50
 
42
51
  declare const module: any;
43
52
 
@@ -56,17 +65,17 @@ function writePref(k: string, v: string): void {
56
65
  }
57
66
  }
58
67
 
59
- // Corpus-absolute path → the href that navigates to it, for CONTENT (R3-174).
68
+ // Bundle-absolute path → the href that navigates to it, for CONTENT (R3-174).
60
69
  //
61
70
  // Module scope, and that is load-bearing rather than tidiness: this function is handed to
62
- // content through `CorpusContext`, and the SDK's `useCorpusEntries` memoizes on its
71
+ // content through `BundleContext`, and the SDK's `useBundleEntries` memoizes on its
63
72
  // identity. A closure rebuilt each render would make that memo never hold, so the hook the
64
73
  // SDK documents as "safe in a dependency array" would quietly stop being one — from the
65
74
  // PROVIDER's side, where nobody using it would think to look. Nothing here is reactive:
66
75
  // `contentDir()` is boot-settled module state and `keyToHref` is a pure function of it.
67
- function corpusHref(corpusPath: string): string {
68
- const absolute = fromCorpusPath(corpusPath, contentDir().replace(/\/+$/, ''));
69
- return absolute === null ? corpusPath : keyToHref(absolute);
76
+ function bundleHref(bundlePath: string): string {
77
+ const absolute = fromBundlePath(bundlePath, contentDir().replace(/\/+$/, ''));
78
+ return absolute === null ? bundlePath : keyToHref(absolute);
70
79
  }
71
80
 
72
81
  // Build the nested render for a layout chain (outermost first). Each layer wraps
@@ -114,10 +123,45 @@ export default function GroveWiki({
114
123
  }) {
115
124
  const ctx = useContext(TinkerableContext) as any;
116
125
  const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
117
- const mounts = useMounts() as any[];
126
+ const hash: string = ctx?.navigationState?.hash ?? '';
118
127
 
119
- const [theme, setTheme] = useState(() => readPref('grove:theme') || 'default');
120
- const [light, setLight] = useState(() => readPref('grove:appearance') === 'light');
128
+ // ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
129
+ //
130
+ // Two INDEPENDENT axes with three sources, resolved through ONE module
131
+ // (lib/themeSelection) so no surface re-derives the precedence:
132
+ //
133
+ // palette = reader override, else the author's `theme:` on the home entry, else default
134
+ // polarity = reader override, else the host's theme, else the palette's preferred
135
+ //
136
+ // The stored prefs are OVERRIDES, nullable by nature: absent until the reader
137
+ // acts, which is what gives the author's declaration its turn. They are written
138
+ // by the user actions (chooseTheme/choosePolarity), NEVER by an effect on mount
139
+ // — the old effects promoted the initial value to a reader choice on first
140
+ // visit, which is exactly why a `theme:` declaration could never have won.
141
+ // (Visitors from before this change carry a mount-written pref; it stands —
142
+ // they are Grove readers, and a reader outranks an author.)
143
+ const [readerTheme, setReaderTheme] = useState<string | null>(() => readPref('grove:theme'));
144
+ const [readerAppearance, setReaderAppearance] = useState<Polarity | null>(() => {
145
+ const p = readPref('grove:appearance');
146
+ return p === 'light' || p === 'dark' ? p : null;
147
+ });
148
+ const chooseTheme = (id: string) => {
149
+ setReaderTheme(id);
150
+ writePref('grove:theme', id);
151
+ };
152
+ const choosePolarity = (wantLight: boolean) => {
153
+ const p: Polarity = wantLight ? 'light' : 'dark';
154
+ setReaderAppearance(p);
155
+ writePref('grove:appearance', p);
156
+ };
157
+ // The host drives POLARITY ONLY (`theme:read` is the one theme capability the
158
+ // open-wiki binding holds) — and only when there IS a host. `useHostTheme`'s
159
+ // channel reports an `initial: 'dark'` before any host speaks, so an unframed
160
+ // standalone `vite dev` render would otherwise carry a phantom host opinion
161
+ // and flip light-preferred themes. Framed === a host exists to have one.
162
+ const framed = typeof window !== 'undefined' && window.parent !== window;
163
+ const hostTheme = useHostTheme();
164
+ const hostPolarity: Polarity | null = framed ? hostTheme : null;
121
165
  const [menuOpen, setMenuOpen] = useState(false);
122
166
  const [searchOpen, setSearchOpen] = useState(false);
123
167
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -132,8 +176,6 @@ export default function GroveWiki({
132
176
  mq.addEventListener('change', on);
133
177
  return () => mq.removeEventListener('change', on);
134
178
  }, []);
135
- useEffect(() => writePref('grove:theme', theme), [theme]);
136
- useEffect(() => writePref('grove:appearance', light ? 'light' : 'dark'), [light]);
137
179
 
138
180
  // ⌘K / Ctrl-K opens search.
139
181
  useEffect(() => {
@@ -147,28 +189,37 @@ export default function GroveWiki({
147
189
  return () => window.removeEventListener('keydown', on);
148
190
  }, []);
149
191
 
150
- // ⚠ TEMPORARY, and not a design: dispatched content IS writable — R3-266.
151
- //
152
- // The affordance below calls `requestEdit`, which is **self-scoped by contract** ("v1
153
- // supports only a repo-relative path in the CURRENT repo … editing a file in one of your
154
- // mounts is the `edit-file` task, not this"). Under dispatch the corpus is a mount, so
155
- // that call would edit GROVE rather than the corpus on screen. Offering it would be
156
- // wrong; withholding it *as a design* is also wrong, and this comment exists so the next
157
- // reader does not conclude the second from the first.
192
+ // R3-266 — dispatched content IS writable, and the MOUNT decides.
158
193
  //
159
- // The fix is a verb swap, not a withheld capability: delegate the entry to
160
- // `invokeTask('edit-file', { file: capFile({ mountId, relPath }, { mode: 'rw' }) })`,
161
- // declare `invokes: edit-file`, and gate on the CORPUS MOUNT's mode rather than on the
162
- // packaging. A task callee already holds the minted delegated grant, so nothing new has
163
- // to be minted. Tracked in R3-266; `docs/specs/REPO_CONTENT_DISPATCH_SPEC.mdx` §5.
164
- const writable =
165
- !isDispatched() && !readOnly && (mounts?.some((m) => m.type === 'worktree' && m.mode !== 'ro') ?? false);
194
+ // This used to read `!isDispatched() && …`, withholding every edit affordance from a
195
+ // dispatched viewer. The reason was real but the conclusion was not: `requestEdit` is
196
+ // **self-scoped by contract**, so under dispatch it names a path in GROVE's repo rather
197
+ // than in the corpus on screen. The fix is a verb swap, not a withheld capability — see
198
+ // `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
199
+ // mount change so a live role downgrade hides the affordance instead of producing
200
+ // `EROFS` on click.
201
+ const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(readOnly);
166
202
 
167
203
  const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
168
204
  // The site brand is a wiki-wide constant, so read it from the home entry's
169
205
  // `site` frontmatter — not the current entry's (which only home would carry),
170
206
  // else the brand flips to the 'Grove' fallback on every sub-page.
171
207
  const homeMeta = useFileMetadata(homeKey()) as any;
208
+ // R3-308: the author's palette declaration — `theme:` on the home entry, the
209
+ // wiki-wide sibling of `site:`. Like `site`, it is read from HOME and not the
210
+ // current entry, so a sub-page never flips the wiki's look back to `default`.
211
+ const authorTheme: string | null =
212
+ typeof homeMeta?.theme === 'string' && homeMeta.theme ? homeMeta.theme : null;
213
+ // The two axes, resolved through the one module that owns the precedence. `theme`
214
+ // and `light` below are the RESOLVED values every surface renders from — the raw
215
+ // reader overrides live only in state and in the menu handlers.
216
+ const theme = resolvePalette({ reader: readerTheme, author: authorTheme });
217
+ const polarity: Polarity = resolvePolarity({
218
+ reader: readerAppearance,
219
+ host: hostPolarity,
220
+ preferred: preferredPolarity(theme),
221
+ });
222
+ const light = polarity === 'light';
172
223
  // Existence / 404: the whole index tells us if a followed link is dead. Layout
173
224
  // files are structure, not entries, so they're excluded here (and everywhere).
174
225
  const allKeysQuery = useCallback((fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry), []);
@@ -191,8 +242,7 @@ export default function GroveWiki({
191
242
  const includePath = keyToInclude(entryKey);
192
243
  const meta = useFileMetadata(entryKey) as any;
193
244
 
194
- const layout: string = meta?.layout || 'doc';
195
- const navMode: 'top' | 'side' = 'side';
245
+ const layout = resolvePageLayout(meta);
196
246
  const siteTitle: string = meta?.site || homeMeta?.site || 'Grove';
197
247
  // Interpreter mode (TRUST_MODES §5 / R3-213): render this entry's body through the
198
248
  // non-executable safe renderer instead of the compiled `<Include>` path.
@@ -219,10 +269,59 @@ export default function GroveWiki({
219
269
  // frontmatter map (folder convention + `frame` override), so it reads the whole
220
270
  // metadata store and re-derives when the content set or the entry changes.
221
271
  const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
272
+
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[]>([]);
280
+ const [rejectedSheet, setRejectedSheet] = useState<string | null>(null);
281
+ 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]);
222
311
  const chain: string[] = layoutChainForKey(entryKey, allMeta);
223
312
  const frameNone = meta?.frame === 'none' || meta?.frame === false;
313
+
314
+ // R3-309 — the nav arrangement is the ROOT layout's to choose (`nav: top` in the
315
+ // outermost _layout.mdx's frontmatter; the default shell and the starters read the
316
+ // same value through the shell). Undeclared or unknown → 'side', the arrangement
317
+ // Grove has always shipped — never an unstyled page.
318
+ const navMode = resolveNavMode(chain.length ? (allMeta[chain[0]!] as Record<string, unknown>) : undefined);
224
319
  const useDefault = chain.length === 0 && !frameNone;
225
320
 
321
+ // Every navigation starts at the top of the entry, except one aimed at a section. The
322
+ // ref goes on `.device__scroll` below — the only thing on the page that scrolls.
323
+ const scrollRef = useScrollReset(entryKey, hash);
324
+
226
325
  // Reading time: read the entry body once per entry.
227
326
  useEffect(() => {
228
327
  let active = true;
@@ -257,9 +356,9 @@ export default function GroveWiki({
257
356
 
258
357
  const shell: GroveShell = {
259
358
  theme,
260
- setTheme,
359
+ setTheme: chooseTheme,
261
360
  light,
262
- setLight,
361
+ setLight: choosePolarity,
263
362
  menuOpen,
264
363
  setMenuOpen,
265
364
  searchOpen,
@@ -269,6 +368,10 @@ export default function GroveWiki({
269
368
  vw,
270
369
  navMode,
271
370
  writable,
371
+ openEditor,
372
+ editBusy,
373
+ editRefused,
374
+ editHint,
272
375
  siteTitle,
273
376
  safe,
274
377
  navItems,
@@ -282,11 +385,11 @@ export default function GroveWiki({
282
385
  directory,
283
386
  };
284
387
 
285
- // The corpus scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
388
+ // The bundle scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
286
389
  //
287
- // A component the corpus ships cannot import this engine — it would resolve a second
390
+ // A component the bundle ships cannot import this engine — it would resolve a second
288
391
  // copy from the registry, with its own `contentRoot` module state, and answer about the
289
- // wrong corpus — so everything it needs about the corpus arrives through the SDK, which
392
+ // wrong bundle — so everything it needs about the bundle arrives through the SDK, which
290
393
  // both sides genuinely share (one `/node_modules` per frame). Three facts, and each is
291
394
  // one a content component cannot derive for itself:
292
395
  //
@@ -298,7 +401,7 @@ export default function GroveWiki({
298
401
  // a `_layout.mdx` wraps the entry, so `<Include>`'s own module identity would name
299
402
  // the layout; furniture in the layout chain (a status line, a dependency rail) needs
300
403
  // the page it is describing.
301
- // • `toHref` — because corpus-path→URL is this VIEWER's policy and the two packagings
404
+ // • `toHref` — because bundle-path→URL is this VIEWER's policy and the two packagings
302
405
  // genuinely disagree (`urlAnchor`). Content that computed its own hrefs would be
303
406
  // correct in exactly one packaging, which is the mode-invariance rule
304
407
  // (PLATFORM_LAYERING §1.1) broken in the least visible possible way.
@@ -311,34 +414,53 @@ export default function GroveWiki({
311
414
  // rejects one here because `entryKey` derives from the metadata query's array).
312
415
  //
313
416
  // That is safe because the EXPENSIVE half does not key on this object's identity: the
314
- // SDK's `useCorpusEntries` destructures `{root, toHref}` and memoizes on those, and both
417
+ // SDK's `useBundleEntries` destructures `{root, toHref}` and memoizes on those, and both
315
418
  // are stable — `root` is a string compared by value, `toHref` is the module-scope
316
- // `corpusHref` above. A new wrapper re-renders consumers (cheap); it does not re-derive
419
+ // `bundleHref` above. A new wrapper re-renders consumers (cheap); it does not re-derive
317
420
  // 800-odd entries. Making `toHref` a closure would silently undo that, which is the
318
421
  // whole reason it is not one.
319
- const corpusRoot = contentDir().replace(/\/+$/, '');
320
- const corpusEntry = toCorpusPath(entryKey, corpusRoot);
321
- const corpusScope = { root: corpusRoot, entry: corpusEntry, toHref: corpusHref };
422
+ const bundleRoot = contentDir().replace(/\/+$/, '');
423
+ const bundleEntry = toBundlePath(entryKey, bundleRoot);
424
+ const bundleScope = { root: bundleRoot, entry: bundleEntry, toHref: bundleHref };
322
425
 
323
426
  return (
324
- // R3-277b: declare the enclosing corpus for the platform's link-space consumers
325
- // (the shared resolver's corpus-anchored absolute + `$fs:` handling read this).
427
+ // R3-277b: declare the enclosing bundle for the platform's link-space consumers
428
+ // (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
429
+ // R3-482: the field keeps the deprecated `corpusRoot` spelling until grove's SDK
430
+ // pin reaches a release whose WikiLink reads `bundleRoot` (sdk#171 / 0.68.1) —
431
+ // stating only the new spelling now would silently un-anchor every absolute link.
326
432
  <LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
327
- {/* R3-174: the corpus scope CONTENT reads — sibling to the link space, not a
433
+ {/* R3-174: the bundle scope CONTENT reads — sibling to the link space, not a
328
434
  replacement for it. The two answer different questions: `LinkSpaceContext` tells
329
- the platform's link resolver where absolute hrefs are anchored; `CorpusContext`
330
- tells a component the corpus ships which entries exist, which one is being read,
331
- and how to turn a corpus path into a URL. */}
332
- <CorpusContext value={corpusScope}>
435
+ the platform's link resolver where absolute hrefs are anchored; `BundleContext`
436
+ tells a component the bundle ships which entries exist, which one is being read,
437
+ and how to turn a bundle path into a URL. */}
438
+ <BundleContext value={bundleScope}>
333
439
  <GroveShellContext.Provider value={shell}>
334
440
  <div
335
441
  className="grove-root"
336
442
  data-vw={vw}
337
443
  data-nav={navMode}
338
444
  data-grove-theme={theme === 'default' ? undefined : theme}
339
- data-theme={theme === 'default' && light ? 'light' : undefined}
445
+ data-theme={polarity}
340
446
  >
341
- <div className="device__scroll">
447
+ {/* R3-310: the ACTIVE theme's declared faces — the engine mints blob:
448
+ @font-face rules (R3-315's mechanism); switching themes revokes the
449
+ outgoing set and re-mints. */}
450
+ <ThemeAssets declarations={themeAssetsFor(theme)} />
451
+ <ContentTheme sheets={contentSheets} onRejected={(path, v) => setRejectedSheet(`${path}:${v.line} — ${v.reason}`)} />
452
+ {/* R3-627: remember where the reader was on the entry they leave, and put
453
+ them back there on Back. Grove's scroller is its own container, so the
454
+ ref goes across; `useScrollReset` above stands down on the same
455
+ traversal so the two do not fight. */}
456
+ <ScrollRestoration scroller={scrollRef} />
457
+ <div className="device__scroll" ref={scrollRef}>
458
+ {rejectedSheet ? (
459
+ <div className="grove-decl-error" role="status">
460
+ <strong>A stylesheet entry was rejected by the theme grammar.</strong>
461
+ <div>{rejectedSheet}</div>
462
+ </div>
463
+ ) : null}
342
464
  {rejectedComponents.length > 0 ? (
343
465
  <div className="grove-decl-error" role="status">
344
466
  <strong>This corpus declares components that could not be loaded.</strong>
@@ -364,10 +486,10 @@ export default function GroveWiki({
364
486
  onClose={() => setDrawerOpen(false)}
365
487
  />
366
488
  ) : null}
367
- <GroveAgent writable={writable} entryKey={entryKey} entryTitle={(meta?.title || 'this entry').replace(/\.$/, '')} />
489
+ <GroveAgent writable={writable} entryKey={entryKey} entryTitle={plainLabel(meta?.title || 'this entry')} />
368
490
  </div>
369
491
  </GroveShellContext.Provider>
370
- </CorpusContext>
492
+ </BundleContext>
371
493
  </LinkSpaceContext.Provider>
372
494
  );
373
495
  }
@@ -3,6 +3,7 @@ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
3
3
  import { MountImage } from '@immediately-run/sdk';
4
4
  import type { SandboxMount } from '@immediately-run/sdk';
5
5
  import { toFsPath } from '../lib/content';
6
+ import { resolvePath } from '../lib/assetPath';
6
7
 
7
8
  // MDX `img` override: display a mount-relative image by reading its bytes off the
8
9
  // sandbox fs (the opaque-origin iframe can't fetch a relative path). Resolves the
@@ -10,18 +11,6 @@ import { toFsPath } from '../lib/content';
10
11
  // then hands the file to the SDK's `MountImage`, which owns the read → object URL →
11
12
  // revoke lifecycle we used to hand-roll here.
12
13
 
13
- function resolvePath(basePath: string, relativePath: string): string {
14
- if (relativePath.startsWith('/')) return relativePath;
15
- const parts = basePath.split('/');
16
- parts.pop();
17
- for (const part of relativePath.split('/')) {
18
- if (part === '.' || part === '') continue;
19
- if (part === '..') parts.pop();
20
- else parts.push(part);
21
- }
22
- return parts.join('/');
23
- }
24
-
25
14
  // The whole sandbox fs, `/`-rooted. The resolved asset path is already absolute
26
15
  // (`/app/content/…`), so anchor at root and pass it as the mount-relative path
27
16
  // (leading slash stripped) — preserving the exact paths the old `fs.readFile` read.
@@ -5,6 +5,7 @@ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
5
5
  import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
6
  import { isContentEntry, keyToFsPath, keyToHref, sandboxPathToKey } from '../lib/content';
7
7
  import { backlinkSnippet, bodyLinksTo, crumb, queryPaths } from '../lib/wiki';
8
+ import InlineProse from './InlineProse';
8
9
 
9
10
  interface Hit {
10
11
  key: string;
@@ -14,11 +15,10 @@ interface Hit {
14
15
  // One linking entry: title + namespace crumb + the snippet around the link.
15
16
  function Row({ hit }: { hit: Hit }) {
16
17
  const meta = useFileMetadata(hit.key) as any;
17
- const title = (meta?.title || crumb(hit.key)).replace(/\.$/, '');
18
18
  return (
19
19
  <Link href={keyToHref(hit.key)} className="grove-bl">
20
20
  <div className="grove-bl__t">
21
- {title}
21
+ <InlineProse text={meta?.title || crumb(hit.key)} trimPeriod />
22
22
  <span className="crumb">/{crumb(hit.key)}</span>
23
23
  </div>
24
24
  <div className="grove-bl__snip" dangerouslySetInnerHTML={{ __html: hit.snippet }} />
@@ -0,0 +1,157 @@
1
+ // @vitest-environment jsdom
2
+ // R3-310's catalogue exits at the component:
3
+ // • BYTE-IDENTICAL MARKUP under all four themes — the claim the catalogue is
4
+ // for ("assert it, do not eyeball it"): the same wiki renders the same DOM,
5
+ // the theme being the single root-level attribute that differs;
6
+ // • the webfont looks declare their faces and mint them from LOCAL bytes
7
+ // (network-off rendering is byte-for-byte the same mechanism);
8
+ // • the catalogue exposes exactly the four shippable looks.
9
+ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
10
+ import { act } from 'react';
11
+ import { createRoot, type Root } from 'react-dom/client';
12
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
13
+ import { mintThemeAssets } from '../lib/themeAssets';
14
+ import { themeAssetsFor, ARCHIVE_THEME_ASSETS, JOURNAL_THEME_ASSETS } from '../data/themeFonts';
15
+ import { THEMES } from '../data/themes';
16
+
17
+ // GroveWiki reads viewport state at mount; jsdom has no matchMedia.
18
+ beforeAll(() => {
19
+ Object.defineProperty(window, 'matchMedia', {
20
+ writable: true,
21
+ value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
22
+ });
23
+ });
24
+
25
+ const readFile = vi.fn();
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 NAV = {
33
+ mode: 'github',
34
+ namespace: 'immediately-run',
35
+ provider: 'github',
36
+ repository: 'corpus',
37
+ ref: 'main',
38
+ sandboxPath: '/app/content/wiki/a.mdx',
39
+ hash: '',
40
+ search: '',
41
+ };
42
+
43
+ // `render: safe` — the interpreter body path, so the render exercises the real
44
+ // safe renderer over the fs double instead of the compiled-MDX evaluator, which
45
+ // exists only inside the sandbox bundler. (Both paths produce the same DOM shape;
46
+ // the compiled one is the bundler's, not the theme's.)
47
+ const META = {
48
+ '/app/content/wiki/a.mdx': { title: 'Reference entry.', tags: ['x'], render: 'safe' },
49
+ '/app/content/_layout.mdx': { site: 'Catalogue fixture' },
50
+ };
51
+
52
+ async function renderWiki(): Promise<{ container: HTMLElement; root: Root }> {
53
+ const container = document.createElement('div');
54
+ document.body.appendChild(container);
55
+ const root = createRoot(container);
56
+ await act(async () => {
57
+ root.render(
58
+ <TinkerableContext.Provider
59
+ value={{
60
+ outerHref: 'https://immediately.run/x',
61
+ navigationState: NAV,
62
+ routingSpec: { routes: [] } as never,
63
+ filesMetadata: META,
64
+ } as never}
65
+ >
66
+ <GroveWiki />
67
+ </TinkerableContext.Provider>,
68
+ );
69
+ });
70
+ // Flush the async bits the wiki mounts (the scan, the asset mint, observers).
71
+ for (let i = 0; i < 6; i++) await act(async () => {});
72
+ return { container, root };
73
+ }
74
+
75
+ /** Serialize the wiki with the THEME ATTRIBUTES MASKED — everything else must be
76
+ * byte-identical across themes. */
77
+ function serialized(container: HTMLElement, maskAttr: string[]): string {
78
+ const clone = container.querySelector('.grove-root')?.cloneNode(true) as HTMLElement | null;
79
+ if (!clone) return '(no .grove-root)';
80
+ // Drop engine-minted style URLs (they are per-mount object URLs by design).
81
+ clone.querySelectorAll('style[data-grove-theme-assets]').forEach((s) => s.remove());
82
+ const rootEl = clone;
83
+ for (const a of maskAttr) rootEl.removeAttribute(a);
84
+ return rootEl.innerHTML;
85
+ }
86
+
87
+ beforeEach(() => {
88
+ readFile.mockReset();
89
+ readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
90
+ localStorage.clear();
91
+ });
92
+
93
+ describe('byte-identical markup under all four themes', () => {
94
+ for (const theme of ['default', 'archive', 'journal', 'editorial']) {
95
+ it(`theme=${theme}: the same wiki, the same DOM`, async () => {
96
+ localStorage.setItem('grove:theme', theme);
97
+ const { container, root } = await renderWiki();
98
+ const html = serialized(container, ['data-grove-theme', 'data-theme']);
99
+ expect(container.querySelector('.grove-root')).toBeTruthy();
100
+ // The theme actually applied (the one assertion attr-specific):
101
+ const applied = container.querySelector('.grove-root')?.getAttribute('data-grove-theme');
102
+ expect(applied ?? 'default').toBe(theme);
103
+ (globalThis as unknown as Record<string, string>)[`__wiki_${theme}`] = html;
104
+ await act(async () => {
105
+ root.unmount();
106
+ });
107
+ });
108
+ }
109
+
110
+ it('all four serializations are byte-identical', () => {
111
+ const g = globalThis as unknown as Record<string, string>;
112
+ const set = new Set(['default', 'archive', 'journal', 'editorial'].map((t) => g[`__wiki_${t}`]));
113
+ expect(set.size).toBe(1);
114
+ });
115
+ });
116
+
117
+ describe('the webfont looks declare and mint their faces from local bytes', () => {
118
+ it('archive mints Source Serif 4 from the in-repo bytes (network-off by construction)', async () => {
119
+ const m = await mintThemeAssets(ARCHIVE_THEME_ASSETS, '/app/src/index.css', async (p) => {
120
+ // Only the theme's OWN additions resolve here; the default-set faces share
121
+ // the same mechanism (proven in ThemeAssets.test) — assert the serif legs.
122
+ if (!p.includes('source-serif-4')) throw new Error('ENOENT');
123
+ return new Uint8Array([1]);
124
+ });
125
+ expect(m.minted).toBe(4); // 400/500/600/700
126
+ expect(m.fontFaceCss).toContain('"Source Serif 4"');
127
+ });
128
+
129
+ it('journal mints Lora the same way', async () => {
130
+ const m = await mintThemeAssets(JOURNAL_THEME_ASSETS, '/app/src/index.css', async (p) => {
131
+ if (!p.includes('lora')) throw new Error('ENOENT');
132
+ return new Uint8Array([1]);
133
+ });
134
+ expect(m.minted).toBe(4);
135
+ expect(m.fontFaceCss).toContain('"Lora"');
136
+ });
137
+
138
+ it('editorial keeps the default set — loud through weight and colour, not a new face', () => {
139
+ expect(themeAssetsFor('editorial')).toEqual(themeAssetsFor('default'));
140
+ });
141
+
142
+ it('every declared face file actually ships in the repo (the offline guarantee)', async () => {
143
+ const { readdir } = await import('node:fs/promises');
144
+ const shipped = new Set(await readdir('assets/fonts'));
145
+ for (const set of [themeAssetsFor('default'), ARCHIVE_THEME_ASSETS, JOURNAL_THEME_ASSETS]) {
146
+ for (const f of set.fonts ?? []) {
147
+ expect(shipped.has(f.src.split('/').pop() as string)).toBe(true);
148
+ }
149
+ }
150
+ });
151
+ });
152
+
153
+ describe('the catalogue', () => {
154
+ it('is exactly the four shippable looks; the showcase skins are retired', () => {
155
+ expect(THEMES.map((t) => t.id)).toEqual(['default', 'archive', 'journal', 'editorial']);
156
+ });
157
+ });