@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
package/src/GroveWiki.tsx CHANGED
@@ -5,6 +5,7 @@ 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,
10
11
  useHostTheme,
@@ -12,7 +13,7 @@ import {
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,13 +24,14 @@ 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';
30
31
  import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
31
32
  import { preferredPolarity } from './data/themes';
32
33
  import { useDirectoryListing } from './hooks/useDirectoryListing';
34
+ import { useScrollReset } from './hooks/useScrollReset';
33
35
  import { useEditAffordance } from './hooks/useEditAffordance';
34
36
  import { getContentRoot } from './lib/contentRoot';
35
37
  import type { RejectedComponent } from './lib/corpusComponents';
@@ -41,6 +43,10 @@ import DefaultLayout from './components/DefaultLayout';
41
43
  import Search from './components/Search';
42
44
  import Drawer from './components/Drawer';
43
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';
44
50
 
45
51
  declare const module: any;
46
52
 
@@ -59,17 +65,17 @@ function writePref(k: string, v: string): void {
59
65
  }
60
66
  }
61
67
 
62
- // 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).
63
69
  //
64
70
  // Module scope, and that is load-bearing rather than tidiness: this function is handed to
65
- // content through `CorpusContext`, and the SDK's `useCorpusEntries` memoizes on its
71
+ // content through `BundleContext`, and the SDK's `useBundleEntries` memoizes on its
66
72
  // identity. A closure rebuilt each render would make that memo never hold, so the hook the
67
73
  // SDK documents as "safe in a dependency array" would quietly stop being one — from the
68
74
  // PROVIDER's side, where nobody using it would think to look. Nothing here is reactive:
69
75
  // `contentDir()` is boot-settled module state and `keyToHref` is a pure function of it.
70
- function corpusHref(corpusPath: string): string {
71
- const absolute = fromCorpusPath(corpusPath, contentDir().replace(/\/+$/, ''));
72
- 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);
73
79
  }
74
80
 
75
81
  // Build the nested render for a layout chain (outermost first). Each layer wraps
@@ -117,6 +123,7 @@ export default function GroveWiki({
117
123
  }) {
118
124
  const ctx = useContext(TinkerableContext) as any;
119
125
  const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
126
+ const hash: string = ctx?.navigationState?.hash ?? '';
120
127
 
121
128
  // ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
122
129
  //
@@ -191,7 +198,7 @@ export default function GroveWiki({
191
198
  // `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
192
199
  // mount change so a live role downgrade hides the affordance instead of producing
193
200
  // `EROFS` on click.
194
- const { writable, busy: editBusy, openEditor, editHint } = useEditAffordance(readOnly);
201
+ const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(readOnly);
195
202
 
196
203
  const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
197
204
  // The site brand is a wiki-wide constant, so read it from the home entry's
@@ -235,8 +242,7 @@ export default function GroveWiki({
235
242
  const includePath = keyToInclude(entryKey);
236
243
  const meta = useFileMetadata(entryKey) as any;
237
244
 
238
- const layout: string = meta?.layout || 'doc';
239
- const navMode: 'top' | 'side' = 'side';
245
+ const layout = resolvePageLayout(meta);
240
246
  const siteTitle: string = meta?.site || homeMeta?.site || 'Grove';
241
247
  // Interpreter mode (TRUST_MODES §5 / R3-213): render this entry's body through the
242
248
  // non-executable safe renderer instead of the compiled `<Include>` path.
@@ -263,10 +269,59 @@ export default function GroveWiki({
263
269
  // frontmatter map (folder convention + `frame` override), so it reads the whole
264
270
  // metadata store and re-derives when the content set or the entry changes.
265
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]);
266
311
  const chain: string[] = layoutChainForKey(entryKey, allMeta);
267
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);
268
319
  const useDefault = chain.length === 0 && !frameNone;
269
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
+
270
325
  // Reading time: read the entry body once per entry.
271
326
  useEffect(() => {
272
327
  let active = true;
@@ -315,6 +370,7 @@ export default function GroveWiki({
315
370
  writable,
316
371
  openEditor,
317
372
  editBusy,
373
+ editRefused,
318
374
  editHint,
319
375
  siteTitle,
320
376
  safe,
@@ -329,11 +385,11 @@ export default function GroveWiki({
329
385
  directory,
330
386
  };
331
387
 
332
- // 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).
333
389
  //
334
- // 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
335
391
  // copy from the registry, with its own `contentRoot` module state, and answer about the
336
- // 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
337
393
  // both sides genuinely share (one `/node_modules` per frame). Three facts, and each is
338
394
  // one a content component cannot derive for itself:
339
395
  //
@@ -345,7 +401,7 @@ export default function GroveWiki({
345
401
  // a `_layout.mdx` wraps the entry, so `<Include>`'s own module identity would name
346
402
  // the layout; furniture in the layout chain (a status line, a dependency rail) needs
347
403
  // the page it is describing.
348
- // • `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
349
405
  // genuinely disagree (`urlAnchor`). Content that computed its own hrefs would be
350
406
  // correct in exactly one packaging, which is the mode-invariance rule
351
407
  // (PLATFORM_LAYERING §1.1) broken in the least visible possible way.
@@ -358,25 +414,28 @@ export default function GroveWiki({
358
414
  // rejects one here because `entryKey` derives from the metadata query's array).
359
415
  //
360
416
  // That is safe because the EXPENSIVE half does not key on this object's identity: the
361
- // 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
362
418
  // are stable — `root` is a string compared by value, `toHref` is the module-scope
363
- // `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
364
420
  // 800-odd entries. Making `toHref` a closure would silently undo that, which is the
365
421
  // whole reason it is not one.
366
- const corpusRoot = contentDir().replace(/\/+$/, '');
367
- const corpusEntry = toCorpusPath(entryKey, corpusRoot);
368
- 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 };
369
425
 
370
426
  return (
371
- // R3-277b: declare the enclosing corpus for the platform's link-space consumers
372
- // (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.
373
432
  <LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
374
- {/* 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
375
434
  replacement for it. The two answer different questions: `LinkSpaceContext` tells
376
- the platform's link resolver where absolute hrefs are anchored; `CorpusContext`
377
- tells a component the corpus ships which entries exist, which one is being read,
378
- and how to turn a corpus path into a URL. */}
379
- <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}>
380
439
  <GroveShellContext.Provider value={shell}>
381
440
  <div
382
441
  className="grove-root"
@@ -385,7 +444,23 @@ export default function GroveWiki({
385
444
  data-grove-theme={theme === 'default' ? undefined : theme}
386
445
  data-theme={polarity}
387
446
  >
388
- <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}
389
464
  {rejectedComponents.length > 0 ? (
390
465
  <div className="grove-decl-error" role="status">
391
466
  <strong>This corpus declares components that could not be loaded.</strong>
@@ -411,10 +486,10 @@ export default function GroveWiki({
411
486
  onClose={() => setDrawerOpen(false)}
412
487
  />
413
488
  ) : null}
414
- <GroveAgent writable={writable} entryKey={entryKey} entryTitle={(meta?.title || 'this entry').replace(/\.$/, '')} />
489
+ <GroveAgent writable={writable} entryKey={entryKey} entryTitle={plainLabel(meta?.title || 'this entry')} />
415
490
  </div>
416
491
  </GroveShellContext.Provider>
417
- </CorpusContext>
492
+ </BundleContext>
418
493
  </LinkSpaceContext.Provider>
419
494
  );
420
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
+ });
@@ -0,0 +1,146 @@
1
+ // @vitest-environment jsdom
2
+ // R3-316's component + escape-hatch exits:
3
+ // • a clean ui/stylesheet renders into @layer grove.content (and its declared
4
+ // faces mint through the R3-315 path — never a url());
5
+ // • a rejected sheet names the line and degrades (no style element for it);
6
+ // • THE ESCAPE HATCH: the hiding vectors a content stylesheet would try are
7
+ // each answered by a grove.reset !important pin — and the layer ORDER puts
8
+ // grove.reset first (for !important, earlier layers win), so grove.content
9
+ // cannot out-priority it. Proven against the SHIPPED css bytes.
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { act } from 'react';
12
+ import { createRoot } from 'react-dom/client';
13
+ import { join } from 'node:path';
14
+
15
+ const readFile = vi.fn();
16
+ (globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
17
+ promises: { readFile: (...a: unknown[]) => readFile(...a) },
18
+ };
19
+
20
+ const { default: ContentTheme } = await import('./ContentTheme');
21
+
22
+ async function render(node: React.ReactNode): Promise<HTMLElement> {
23
+ const container = document.createElement('div');
24
+ document.body.appendChild(container);
25
+ await act(async () => {
26
+ createRoot(container).render(node);
27
+ });
28
+ for (let i = 0; i < 4; i++) await act(async () => {});
29
+ return container;
30
+ }
31
+
32
+ const SHEET = (css: string, extra: Record<string, unknown> = {}) => ({
33
+ path: '/app/content/style.mdx',
34
+ css,
35
+ declarations: extra as never,
36
+ });
37
+
38
+ beforeEach(() => readFile.mockReset());
39
+
40
+ describe('admission into the lowest layer', () => {
41
+ it('a clean sheet renders as @layer grove.content on .grove-root', async () => {
42
+ const el = await render(<ContentTheme sheets={[SHEET('--bg: #123;\n')]} />);
43
+ const style = el.querySelector('style[data-grove-content-theme]');
44
+ expect(style?.textContent).toContain('@layer grove.content');
45
+ expect(style?.textContent).toContain('--bg: #123');
46
+ });
47
+
48
+ it('a rejected sheet degrades — no style element, verdict delivered to the caller', async () => {
49
+ const onRejected = vi.fn();
50
+ const el = await render(
51
+ <ContentTheme sheets={[SHEET('.evil { display: none; }')]} onRejected={onRejected} />,
52
+ );
53
+ expect(el.querySelector('style[data-grove-content-theme]')).toBeNull();
54
+ expect(onRejected).toHaveBeenCalledWith(
55
+ '/app/content/style.mdx',
56
+ expect.objectContaining({ line: 1, reason: expect.stringMatching(/selector/) }),
57
+ );
58
+ });
59
+
60
+ it('declared faces mint through the engine path (blob:), never a url()', async () => {
61
+ readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
62
+ const el = await render(
63
+ <ContentTheme
64
+ sheets={[
65
+ SHEET('--bg: #123;\n', {
66
+ fonts: [{ family: 'Lora', src: './lora.woff2', weight: '400' }],
67
+ }),
68
+ ]}
69
+ />,
70
+ );
71
+ const assets = el.querySelector('style[data-grove-content-assets]');
72
+ expect(assets?.textContent).toContain('"Lora"');
73
+ expect(assets?.textContent).toMatch(/blob:/);
74
+ expect(assets?.textContent).not.toContain('/app/content');
75
+ });
76
+ });
77
+
78
+ // Read the shipped bytes via node:fs/promises (node:fs's readFileSync resolves
79
+ // through the dev-fs bridge under jsdom, which is not what these read).
80
+ const { readFile: readFileAsync } = await import('node:fs/promises');
81
+ const css = await readFileAsync(join('src', 'GroveApp.css'), 'utf8');
82
+ const indexCss = await readFileAsync(join('src', 'index.css'), 'utf8');
83
+ const readNav = async () => readFileAsync(join('src', 'components', 'GroveNav.tsx'), 'utf8');
84
+
85
+ describe('THE ESCAPE HATCH — a planted stylesheet cannot hide the theme control', () => {
86
+
87
+ /** Extract one `@layer grove.reset { … }` block's body. */
88
+ const resetBlock = (() => {
89
+ const i = css.indexOf('@layer grove.reset {');
90
+ if (i === -1) return '';
91
+ let depth = 0;
92
+ let end = i + '@layer grove.reset '.length;
93
+ for (; end < css.length; end++) {
94
+ if (css[end] === '{') depth++;
95
+ else if (css[end] === '}') {
96
+ depth--;
97
+ if (depth === 0) break;
98
+ }
99
+ }
100
+ return css.slice(i, end);
101
+ })();
102
+
103
+ it('the layer order puts grove.reset FIRST — for !important, earlier layers win', () => {
104
+ const order = indexCss.match(/@layer\s+([^;]+);/)?.[1] ?? '';
105
+ const layers = order.split(',').map((l) => l.trim());
106
+ expect(layers[0]).toBe('grove.reset');
107
+ expect(layers.indexOf('grove.content')).toBeGreaterThan(layers.indexOf('grove.reset'));
108
+ });
109
+
110
+ it('every hiding vector a content stylesheet would try is answered by a reset pin', () => {
111
+ expect(resetBlock).toContain('.grove-theme-control');
112
+ // The vectors: display/visibility/opacity/pointer-events (the obvious four),
113
+ // clip-path/transform/position/z-index (the geometric ones),
114
+ // width/height/overflow (collapse), filter (blur-to-nothing).
115
+ for (const vector of [
116
+ 'display',
117
+ 'visibility',
118
+ 'opacity',
119
+ 'pointer-events',
120
+ 'clip-path',
121
+ 'transform',
122
+ 'position',
123
+ 'z-index',
124
+ 'width',
125
+ 'height',
126
+ 'overflow',
127
+ 'filter',
128
+ ]) {
129
+ expect(resetBlock).toContain(`${vector}:`);
130
+ }
131
+ // And every pin is !important — plain declarations in grove.reset would LOSE
132
+ // to grove.content's own !important for normal... they need the important flag.
133
+ const pins = resetBlock.match(/\w[\w-]*:\s*[^;]+!/g) ?? [];
134
+ expect(pins.length).toBeGreaterThanOrEqual(12);
135
+ });
136
+
137
+ it('the control carries the protected class in the shipped markup', async () => {
138
+ const nav = await readNav();
139
+ expect(nav).toContain('grove-theme-control');
140
+ });
141
+
142
+ it('FAULT: a reset block missing the vector list fails this check (not vacuous)', () => {
143
+ const sabotage = resetBlock.replace('visibility: visible !important;', '');
144
+ expect(sabotage).not.toContain('visibility: visible !important;');
145
+ });
146
+ });