@immediately-run/grove 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +5 -2
  3. package/package.json +10 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +484 -152
  6. package/src/GroveWiki.tsx +105 -30
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +23 -12
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -150
  21. package/src/components/GroveNav.test.tsx +145 -5
  22. package/src/components/GroveNav.tsx +51 -8
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +18 -3
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +16 -7
  40. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  41. package/src/hooks/useContentComponents.ts +1 -1
  42. package/src/hooks/useEditAffordance.ts +17 -4
  43. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  44. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  45. package/src/hooks/useScrollReset.test.tsx +132 -0
  46. package/src/hooks/useScrollReset.ts +52 -0
  47. package/src/index.css +9 -2
  48. package/src/lib/agentPrompt.test.ts +96 -0
  49. package/src/lib/agentPrompt.ts +86 -0
  50. package/src/lib/agentTools.test.ts +115 -0
  51. package/src/lib/agentTools.ts +132 -0
  52. package/src/lib/agentTranscript.ts +50 -0
  53. package/src/lib/assetPath.test.ts +36 -0
  54. package/src/lib/assetPath.ts +42 -0
  55. package/src/lib/collectionCalls.test.ts +69 -0
  56. package/src/lib/content.test.ts +10 -2
  57. package/src/lib/content.ts +7 -0
  58. package/src/lib/contentStylesheet.test.ts +80 -0
  59. package/src/lib/contentStylesheet.ts +103 -0
  60. package/src/lib/corpusScan.test.ts +37 -3
  61. package/src/lib/corpusScan.ts +17 -2
  62. package/src/lib/inlineProse.parity.test.ts +52 -0
  63. package/src/lib/layout.ts +29 -0
  64. package/src/lib/pageVariants.test.tsx +87 -0
  65. package/src/lib/queries.test.ts +33 -1
  66. package/src/lib/queries.ts +33 -1
  67. package/src/lib/reachCard.test.ts +94 -0
  68. package/src/lib/reachCard.ts +112 -0
  69. package/src/lib/shell.ts +7 -0
  70. package/src/lib/starterSweep.test.tsx +160 -0
  71. package/src/lib/starterSweep.ts +97 -0
  72. package/src/lib/themeAssets.test.ts +135 -0
  73. package/src/lib/themeAssets.ts +143 -0
  74. package/src/lib/themeSelection.test.ts +2 -2
  75. package/src/mdxComponents.ts +4 -0
  76. package/viewer-manifest.schema.json +37 -0
  77. package/viewer.manifest.json +132 -6
@@ -0,0 +1,63 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { mintThemeAssets, type MintedThemeAssets, type ThemeAssetDeclarations } from '../lib/themeAssets';
3
+ import { openFs } from '@immediately-run/sdk';
4
+
5
+ // The engine-owned emission of a declaration set's minted assets (R3-315): one
6
+ // `<style data-grove-theme-assets>` carrying the `@font-face` rules inside the
7
+ // ENGINE cascade layer and the `--asset-*` assignments on `.grove-root`. The
8
+ // revoke scope is the declaration set's lifetime — switching themes (or a reader
9
+ // override re-resolving the set) revokes the outgoing URLs before the incoming
10
+ // ones mint, so repeated switches leak nothing.
11
+ //
12
+ // Nothing here is content-styleable: the layer order in `index.css` namespaces
13
+ // these rules as `grove.engine`, below the reset but above theme and content.
14
+
15
+ /** The declaring-file anchor for the engine's own default set — the app repo root
16
+ * (fork AND dispatch load the engine from this repo; `/app` is its own tree). */
17
+ const ENGINE_DECLARING_FILE = '/app/src/index.css';
18
+
19
+ // The whole sandbox fs, `/`-rooted — the same anchor `AssetImage`/`EntryImage` use.
20
+ const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
21
+
22
+ interface Props {
23
+ declarations: ThemeAssetDeclarations;
24
+ /** Absolute fs path of the file the declarations live in (resolution base). */
25
+ basePath?: string;
26
+ }
27
+
28
+ export default function ThemeAssets({ declarations, basePath = ENGINE_DECLARING_FILE }: Props) {
29
+ const [minted, setMinted] = useState<MintedThemeAssets | null>(null);
30
+ // The CURRENT set, reached from the cleanup — a setState updater does NOT run
31
+ // on unmount (React 18+), so reaching the outgoing URLs only through state
32
+ // would leak them exactly there.
33
+ const mintedRef = useRef<MintedThemeAssets | null>(null);
34
+
35
+ useEffect(() => {
36
+ let alive = true;
37
+ // Read through the SDK's fs surface (the same one `MountImage` reads
38
+ // through): it resolves the sandbox's injected shared fs in every stance,
39
+ // where a bare `import fs` would only work where the bundler shims it.
40
+ const rootFs = openFs(ROOT_MOUNT);
41
+ void mintThemeAssets(declarations, basePath, async (p) => {
42
+ const bytes = await rootFs.readFile(p.replace(/^\/+/, ''));
43
+ return typeof bytes === 'string' ? new TextEncoder().encode(bytes) : new Uint8Array(bytes);
44
+ }).then((m) => {
45
+ if (!alive) {
46
+ m.revoke(); // the set changed/unmounted mid-mint — never leak the late arrival
47
+ return;
48
+ }
49
+ mintedRef.current = m;
50
+ setMinted(m);
51
+ });
52
+ return () => {
53
+ alive = false;
54
+ // Revoke the OUTGOING set on switch/unmount — the theme's lifetime ends here.
55
+ mintedRef.current?.revoke();
56
+ mintedRef.current = null;
57
+ setMinted(null);
58
+ };
59
+ }, [declarations, basePath]);
60
+
61
+ if (!minted || (!minted.fontFaceCss && !minted.assetVarsCss)) return null;
62
+ return <style data-grove-theme-assets="">{`@layer grove.engine{${minted.fontFaceCss}}\n${minted.assetVarsCss}`}</style>;
63
+ }
@@ -0,0 +1,31 @@
1
+ import { manifestThemes } from '../data/catalogue';
2
+ import { THEMES } from '../data/themes';
3
+
4
+ // The themes gallery (R3-311): one row per SHIPPED theme, enumerated from
5
+ // viewer.manifest.json — adding a theme to the manifest adds a row here with no
6
+ // edit to the entry. The swatch is the live gradient each catalogue entry
7
+ // declares (data/themes.ts), rendered with the theme's OWN tokens applied to the
8
+ // swatch chip, so the comparison is the real thing, not a screenshot of it.
9
+
10
+ export default function ThemeGallery() {
11
+ const rows = manifestThemes();
12
+ return (
13
+ <div className="gg-gallery" role="list" aria-label="The shipped themes">
14
+ {rows.map(([id, t]) => {
15
+ const swatch = THEMES.find((x) => x.id === id)?.swatch ?? 'linear-gradient(96deg,#888,#555)';
16
+ return (
17
+ <div className="gg-row" role="listitem" key={id} data-gallery-id={id}>
18
+ <span className="gg-swatch" style={{ background: swatch }} aria-hidden />
19
+ <div className="gg-body">
20
+ <div className="gg-title">
21
+ <code>{id}</code> — {t.label}
22
+ <span className="gg-meta">opens {t.preferred}</span>
23
+ </div>
24
+ <div className="gg-summary">{t.summary}</div>
25
+ </div>
26
+ </div>
27
+ );
28
+ })}
29
+ </div>
30
+ );
31
+ }
@@ -2,7 +2,9 @@
2
2
  import { useCallback } from 'react';
3
3
  import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
4
4
  import { isContentEntry, keyToHref } from '../lib/content';
5
+ import InlineProse from './InlineProse';
5
6
  import { queryPaths } from '../lib/wiki';
7
+ import EntryImage from './EntryImage';
6
8
 
7
9
  // One dated entry on the axis: mono date · node · card.
8
10
  function Row({ path }: { path: string }) {
@@ -14,8 +16,9 @@ function Row({ path }: { path: string }) {
14
16
  <div className="gtl-date">{m.date}</div>
15
17
  <div className="gtl-node" />
16
18
  <div className="gtl-card">
17
- <div className="gtl-title">{(m.title || path).replace(/\.$/, '')}</div>
18
- {m.description && <div className="gtl-desc">{m.description}</div>}
19
+ <EntryImage entryPath={path} src={m.cover} alt="" className="gtl-cover" degrade={null} />
20
+ <div className="gtl-title"><InlineProse text={m.title || path} trimPeriod /></div>
21
+ {m.description && <div className="gtl-desc"><InlineProse text={m.description} /></div>}
19
22
  {tags.length ? (
20
23
  <div className="gtl-tags">
21
24
  {tags.slice(0, 3).map((t) => (
@@ -79,7 +79,7 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
79
79
  );
80
80
  }
81
81
  return (
82
- <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
82
+ <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`} aria-label={`No entry at ${href}`}>
83
83
  <Icon name="unlink" />
84
84
  {children}
85
85
  </span>
@@ -97,7 +97,7 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
97
97
  const exists = !loaded || keys.includes(targetKey); // optimistic until loaded
98
98
  if (!exists) {
99
99
  return (
100
- <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
100
+ <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`} aria-label={`No entry at ${href}`}>
101
101
  <Icon name="unlink" />
102
102
  {children}
103
103
  </span>
@@ -0,0 +1,54 @@
1
+ // Typed views over viewer.manifest.json's catalogue sections — the manifest is
2
+ // the contract (R3-309/R3-311): every shipped theme, page variant, starter and
3
+ // collection shape is enumerated THERE, and the gallery entries render FROM it,
4
+ // so adding an entry to the manifest adds a row with no edit to any entry.
5
+
6
+ import manifest from '../../viewer.manifest.json';
7
+
8
+ export interface ManifestTheme {
9
+ label: string;
10
+ preferred: 'light' | 'dark';
11
+ ships: boolean;
12
+ summary: string;
13
+ }
14
+ export interface ManifestPageVariant {
15
+ key: string;
16
+ value: string;
17
+ ships: boolean;
18
+ summary: string;
19
+ }
20
+ export interface ManifestLayout {
21
+ layoutRole: string;
22
+ arranges: string;
23
+ ships: boolean;
24
+ summary: string;
25
+ }
26
+ export interface ManifestCollection {
27
+ component?: string;
28
+ props?: Record<string, string>;
29
+ ships: boolean;
30
+ summary: string;
31
+ }
32
+
33
+ const cat = manifest as unknown as {
34
+ themes: Record<string, ManifestTheme>;
35
+ pageVariants: Record<string, ManifestPageVariant>;
36
+ layouts: Record<string, ManifestLayout>;
37
+ collections: Record<string, ManifestCollection>;
38
+ };
39
+
40
+ /** The shipped themes, manifest order. */
41
+ export const manifestThemes = (): Array<[string, ManifestTheme]> =>
42
+ Object.entries(cat.themes).filter(([, t]) => t.ships);
43
+
44
+ /** The shipped page variants (bucket B — a frontmatter key on one entry). */
45
+ export const manifestPageVariants = (): Array<[string, ManifestPageVariant]> =>
46
+ Object.entries(cat.pageVariants).filter(([, v]) => v.ships);
47
+
48
+ /** The shipped layout starters (bucket A — a copyable `_layout.mdx`). */
49
+ export const manifestLayouts = (): Array<[string, ManifestLayout]> =>
50
+ Object.entries(cat.layouts).filter(([, l]) => l.ships);
51
+
52
+ /** The shipped collection shapes (bucket C — a component call). */
53
+ export const manifestCollections = (): Array<[string, ManifestCollection]> =>
54
+ Object.entries(cat.collections).filter(([, c]) => c.ships);
@@ -0,0 +1,58 @@
1
+ // The per-theme face sets (R3-310; the R3-315 declared-asset mechanism): the
2
+ // engine default's families plus the two catalogue looks that change the reading
3
+ // face. `archive` puts a serif in `--sans` — the contract's documented role rule
4
+ // ("`--sans` is the body face, whatever face that is"); `journal` reads warm in
5
+ // Lora; `editorial` is loud through weight, tracking and colour, not through a
6
+ // new face, so it keeps the default set. Data, not components.
7
+
8
+ import type { ThemeAssetDeclarations } from '../lib/themeAssets';
9
+
10
+ const F = (family: string, file: string, weight: string) => ({ family, src: `/app/assets/fonts/${file}`, weight, style: 'normal' });
11
+
12
+ /** The engine default: Gabarito (display) · Public Sans (body) · Space Mono. */
13
+ export const DEFAULT_THEME_ASSETS: ThemeAssetDeclarations = {
14
+ fonts: [
15
+ F('Gabarito', 'gabarito-latin-400-normal.woff2', '400'),
16
+ F('Gabarito', 'gabarito-latin-500-normal.woff2', '500'),
17
+ F('Gabarito', 'gabarito-latin-600-normal.woff2', '600'),
18
+ F('Gabarito', 'gabarito-latin-700-normal.woff2', '700'),
19
+ F('Gabarito', 'gabarito-latin-800-normal.woff2', '800'),
20
+ F('Gabarito', 'gabarito-latin-900-normal.woff2', '900'),
21
+ F('Public Sans', 'public-sans-latin-400-normal.woff2', '400'),
22
+ F('Public Sans', 'public-sans-latin-500-normal.woff2', '500'),
23
+ F('Public Sans', 'public-sans-latin-600-normal.woff2', '600'),
24
+ F('Public Sans', 'public-sans-latin-700-normal.woff2', '700'),
25
+ F('Space Mono', 'space-mono-latin-400-normal.woff2', '400'),
26
+ F('Space Mono', 'space-mono-latin-700-normal.woff2', '700'),
27
+ ],
28
+ };
29
+
30
+ /** `archive` — the reading face is Source Serif 4 (dense reference); display
31
+ * keeps Gabarito so nav and furniture stay legible-brand. */
32
+ export const ARCHIVE_THEME_ASSETS: ThemeAssetDeclarations = {
33
+ fonts: [
34
+ ...DEFAULT_THEME_ASSETS.fonts!,
35
+ F('Source Serif 4', 'source-serif-4-latin-400-normal.woff2', '400'),
36
+ F('Source Serif 4', 'source-serif-4-latin-500-normal.woff2', '500'),
37
+ F('Source Serif 4', 'source-serif-4-latin-600-normal.woff2', '600'),
38
+ F('Source Serif 4', 'source-serif-4-latin-700-normal.woff2', '700'),
39
+ ],
40
+ };
41
+
42
+ /** `journal` — the reading face is Lora (warm, personal); display keeps Gabarito. */
43
+ export const JOURNAL_THEME_ASSETS: ThemeAssetDeclarations = {
44
+ fonts: [
45
+ ...DEFAULT_THEME_ASSETS.fonts!,
46
+ F('Lora', 'lora-latin-400-normal.woff2', '400'),
47
+ F('Lora', 'lora-latin-500-normal.woff2', '500'),
48
+ F('Lora', 'lora-latin-600-normal.woff2', '600'),
49
+ F('Lora', 'lora-latin-700-normal.woff2', '700'),
50
+ ],
51
+ };
52
+
53
+ /** The declaration set a theme mints — default/editorial share the engine set. */
54
+ export function themeAssetsFor(theme: string): ThemeAssetDeclarations {
55
+ if (theme === 'archive') return ARCHIVE_THEME_ASSETS;
56
+ if (theme === 'journal') return JOURNAL_THEME_ASSETS;
57
+ return DEFAULT_THEME_ASSETS;
58
+ }
@@ -1,6 +1,10 @@
1
1
  // The theme catalogue for the theme menu (id → label + swatch gradient). Data,
2
2
  // not components — kept out of the chrome components per the Fast-Refresh rule.
3
3
  //
4
+ // R3-310: the catalogue is four shippable looks; the three showcase skins
5
+ // (pixies/family/lotr — drawn for a fictional handbook, reading as costumes) are
6
+ // retired. archive/journal/editorial are keyed to uses a real corpus has.
7
+ //
4
8
  // R3-308: every theme declares BOTH polarities (its CSS block pair) and a
5
9
  // `preferred` one — the polarity a wiki opens in when neither the reader nor the
6
10
  // host has said otherwise (02-theme-contract §4). The catalogue ids must match
@@ -23,19 +27,24 @@ export const THEMES: Theme[] = [
23
27
  swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)',
24
28
  preferred: 'dark',
25
29
  },
26
- { id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)', preferred: 'dark' },
27
30
  {
28
- id: 'family',
29
- label: 'Family journal',
30
- swatch: 'linear-gradient(96deg,#f3cf9a,#e09a6a 50%,#c8744f)',
31
+ id: 'archive',
32
+ label: 'Archive',
33
+ swatch: 'linear-gradient(96deg,#b89a56,#7a5c20 55%,#3f5a63)',
31
34
  preferred: 'light',
32
35
  },
33
36
  {
34
- id: 'lotr',
35
- label: 'Middle-earth',
36
- swatch: 'linear-gradient(96deg,#b89a56,#8a6a36 50%,#4a5a38)',
37
+ id: 'journal',
38
+ label: 'Journal',
39
+ swatch: 'linear-gradient(96deg,#e8c9a0,#c08552 50%,#a25a3c)',
37
40
  preferred: 'light',
38
41
  },
42
+ {
43
+ id: 'editorial',
44
+ label: 'Editorial',
45
+ swatch: 'linear-gradient(96deg,#ffd64f,#ff4d2e 55%,#37d4a8)',
46
+ preferred: 'dark',
47
+ },
39
48
  ];
40
49
 
41
50
  /** The preferred polarity of a palette id — unknown ids fall back to dark, the
@@ -1,9 +1,9 @@
1
- // The dispatched corpus's frontmatter index (R3-265).
1
+ // The dispatched bundle's frontmatter index (R3-265).
2
2
  //
3
3
  // A FORK gets its index from the bundler and this hook does nothing. A DISPATCHED viewer
4
- // has to build it: the corpus is a mount the bundler never scanned, so without this the
4
+ // has to build it: the bundle is a mount the bundler never scanned, so without this the
5
5
  // wiki renders with empty nav, empty sidebar, no search, no backlinks and no routing —
6
- // silently, because an absent index is indistinguishable from an empty corpus.
6
+ // silently, because an absent index is indistinguishable from an empty bundle.
7
7
  //
8
8
  // The scan runs ONCE per root and the result is handed to `TinkerableContext.filesMetadata`,
9
9
  // which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata` already read
@@ -13,21 +13,21 @@ import { useEffect, useState } from 'react';
13
13
  import fs from 'fs';
14
14
  import { scanCorpus, type CorpusMetadata, type ScanFs } from '../lib/corpusScan';
15
15
 
16
- export interface CorpusIndex {
16
+ export interface BundleIndex {
17
17
  /** `idle` — a fork, nothing to scan · `scanning` — hold the render · `ready` — use it. */
18
18
  status: 'idle' | 'scanning' | 'ready';
19
19
  metadata: CorpusMetadata | null;
20
20
  }
21
21
 
22
22
  /** Scan `root`, or do nothing when it is null (the fork packaging). */
23
- export function useCorpusMetadata(root: string | null): CorpusIndex {
23
+ export function useBundleMetadata(root: string | null): BundleIndex {
24
24
  const [index, setIndex] = useState<{ root: string; metadata: CorpusMetadata } | null>(null);
25
25
 
26
26
  useEffect(() => {
27
27
  if (!root || index?.root === root) return;
28
28
  let cancelled = false;
29
29
  void scanCorpus(root, fs.promises as unknown as ScanFs).then((metadata) => {
30
- // A corpus that resolves to nothing is still a result: `ready` with an empty map
30
+ // A bundle that resolves to nothing is still a result: `ready` with an empty map
31
31
  // renders the 404 index, which tells the reader the folder has no entries. Staying
32
32
  // in `scanning` forever would show a spinner and say nothing.
33
33
  if (!cancelled) setIndex({ root, metadata });
@@ -96,7 +96,7 @@ async function loadDeclared(root: string): Promise<Omit<ContentComponents, 'stat
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
98
  * paints, never render into a partial one — and it costs nothing here because Grove
99
- * already gates on `useOpenWikiBoot` and `useCorpusMetadata`. Rendering into a
99
+ * already gates on `useOpenWikiBoot` and `useBundleMetadata`. 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
  */
@@ -21,7 +21,11 @@ export interface EditAffordance {
21
21
  writable: boolean;
22
22
  /** True while an editor is being summoned (for a busy label). */
23
23
  busy: boolean;
24
- /** Open `entryKey` in the platform editor. Never throws; a refusal is a no-op. */
24
+ /** True when the host REFUSED the last edit request — callers render it as
25
+ * text where the affordance was offered (3.3.1 / 4.1.3, R3-608). A
26
+ * `cancelled` rejection (the reader closed the editor) never sets this. */
27
+ refused: boolean;
28
+ /** Open `entryKey` in the platform editor. Never throws; a refusal is reported. */
25
29
  openEditor: (entryKey: string) => void;
26
30
  /**
27
31
  * What a save actually does, so the affordance can say so.
@@ -41,6 +45,7 @@ export interface EditAffordance {
41
45
  export function useEditAffordance(readOnly: boolean): EditAffordance {
42
46
  const mounts = useMounts();
43
47
  const [busy, setBusy] = useState(false);
48
+ const [refused, setRefused] = useState(false);
44
49
 
45
50
  // Read the corpus identity through the mount list's identity, so the memo re-runs when
46
51
  // the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
@@ -53,16 +58,24 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
53
58
 
54
59
  const writable = !readOnly && corpusWritable(mounts, corpus);
55
60
 
61
+ // A refusal surfaces where the affordance was offered (3.3.1, R3-608);
62
+ // `cancelled` — the reader closing the editor — stays silent by contract.
63
+ const refusedUnlessCancelled = (e: unknown): undefined => {
64
+ if ((e as { code?: string } | null)?.code !== 'cancelled') setRefused(true);
65
+ return undefined;
66
+ };
67
+
56
68
  const openEditor = useCallback(
57
69
  (entryKey: string) => {
58
70
  const target = editTarget(entryKey, corpus);
59
71
  if (!target) return;
60
72
  setBusy(true);
73
+ setRefused(false);
61
74
  const done = () => setBusy(false);
62
75
  if (target.via === 'self') {
63
76
  // The fork: the present→edit transition on our own source. Self-scoped by
64
77
  // contract, which is exactly right when the corpus IS our repo.
65
- requestEdit({ path: target.path }).catch(() => undefined).finally(done);
78
+ requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
66
79
  return;
67
80
  }
68
81
  // Dispatch: attenuate the corpus delegation down to this one file and hand it to
@@ -72,7 +85,7 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
72
85
  invokeTask('edit-file', {
73
86
  file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
74
87
  })
75
- .catch(() => undefined) // `cancelled` is how a reader closes the editor
88
+ .catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
76
89
  .finally(done);
77
90
  },
78
91
  [corpus],
@@ -82,5 +95,5 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
82
95
  ? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
83
96
  : 'Edit this entry';
84
97
 
85
- return { writable, busy, openEditor, editHint };
98
+ return { writable, busy, refused, openEditor, editHint };
86
99
  }
@@ -0,0 +1,139 @@
1
+ // @vitest-environment jsdom
2
+ // useOverlayFocusDismiss (R3-608): the dialog contract AND the stack — one
3
+ // Escape closes only the TOP overlay; after it closes, the next.
4
+ import { useState } from 'react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { act } from 'react';
7
+ import { createRoot, type Root } from 'react-dom/client';
8
+ import { useOverlayFocusDismiss } from './useOverlayFocusDismiss';
9
+
10
+ function Overlay({ name, onClose }: { name: string; onClose: () => void }) {
11
+ const ref = useOverlayFocusDismiss(true, onClose);
12
+ return (
13
+ <div data-testid={name} ref={ref} tabIndex={-1}>
14
+ <button>{name} first</button>
15
+ <button>{name} last</button>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ /** One open overlay with a real trigger — the contract's basics. */
21
+ function Single({ onClose }: { onClose: () => void }) {
22
+ const [open, setOpen] = useState(false);
23
+ return (
24
+ <div>
25
+ <button
26
+ onClick={(e) => {
27
+ setOpen(true);
28
+ void e;
29
+ }}
30
+ >
31
+ open
32
+ </button>
33
+ {open ? (
34
+ <Overlay
35
+ name="one"
36
+ onClose={() => {
37
+ setOpen(false);
38
+ onClose();
39
+ }}
40
+ />
41
+ ) : null}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ /** Two stacked overlays (a below, b above) — the stack discipline. */
47
+ function Stacked({ aClose, bClose }: { aClose: () => void; bClose: () => void }) {
48
+ const [a, setA] = useState(true);
49
+ const [b, setB] = useState(true);
50
+ return (
51
+ <div>
52
+ {a ? (
53
+ <Overlay
54
+ name="a"
55
+ onClose={() => {
56
+ setA(false);
57
+ aClose();
58
+ }}
59
+ />
60
+ ) : null}
61
+ {b ? (
62
+ <Overlay
63
+ name="b"
64
+ onClose={() => {
65
+ setB(false);
66
+ bClose();
67
+ }}
68
+ />
69
+ ) : null}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ async function mount(ui: React.ReactNode): Promise<{ root: Root; container: HTMLElement }> {
75
+ const container = document.createElement('div');
76
+ document.body.appendChild(container);
77
+ const root = createRoot(container);
78
+ await act(async () => {
79
+ root.render(ui);
80
+ });
81
+ return { root, container };
82
+ }
83
+
84
+ const esc = async (): Promise<void> => {
85
+ await act(async () => {
86
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
87
+ });
88
+ };
89
+
90
+ describe('useOverlayFocusDismiss (R3-608)', () => {
91
+ it('focus moves IN on open and RETURNS to the trigger on Escape-close', async () => {
92
+ const close = vi.fn();
93
+ const { container } = await mount(<Single onClose={close} />);
94
+ const trigger = container.querySelector('button') as HTMLButtonElement;
95
+ trigger.focus();
96
+ expect(document.activeElement).toBe(trigger);
97
+ await act(async () => {
98
+ trigger.click();
99
+ });
100
+ // Focus moved IN — to the overlay's first control.
101
+ expect(document.activeElement?.textContent).toBe('one first');
102
+ await esc();
103
+ expect(close).toHaveBeenCalledTimes(1);
104
+ expect(container.querySelector('[data-testid="one"]')).toBeNull();
105
+ expect(document.activeElement).toBe(trigger);
106
+ });
107
+
108
+ it('Tab is trapped at the overlay edges', async () => {
109
+ const close = vi.fn();
110
+ const { container } = await mount(<Single onClose={close} />);
111
+ const trigger = container.querySelector('button') as HTMLButtonElement;
112
+ trigger.focus();
113
+ await act(async () => {
114
+ trigger.click();
115
+ });
116
+ const buttons = [...container.querySelectorAll('[data-testid="one"] button')] as HTMLButtonElement[];
117
+ buttons[1].focus();
118
+ await act(async () => {
119
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
120
+ });
121
+ expect(document.activeElement).toBe(buttons[0]); // wrapped forward→first
122
+ });
123
+
124
+ it('one Escape closes only the TOP overlay; the next closes the one below', async () => {
125
+ const aClose = vi.fn();
126
+ const bClose = vi.fn();
127
+ const { container } = await mount(<Stacked aClose={aClose} bClose={bClose} />);
128
+ expect(container.querySelector('[data-testid="a"]')).toBeTruthy();
129
+ expect(container.querySelector('[data-testid="b"]')).toBeTruthy();
130
+ await esc();
131
+ expect(bClose).toHaveBeenCalledTimes(1);
132
+ expect(aClose).not.toHaveBeenCalled();
133
+ expect(container.querySelector('[data-testid="b"]')).toBeNull();
134
+ expect(container.querySelector('[data-testid="a"]')).toBeTruthy();
135
+ await esc();
136
+ expect(aClose).toHaveBeenCalledTimes(1);
137
+ expect(container.querySelector('[data-testid="a"]')).toBeNull();
138
+ });
139
+ });
@@ -0,0 +1,118 @@
1
+ // The dialog/menu contract for Grove's overlays (R3-608, R-IX-1): focus moves
2
+ // IN on open, Tab is trapped while open (unless the surface is a menu — menus
3
+ // do not trap Tab), Escape closes only the TOP of a module-level stack, and
4
+ // focus RETURNS to the element that opened the overlay. The stack exists
5
+ // because the surfaces overlap (GroveNav's own comment names drawer, search
6
+ // and theme menu as shared interactive state): one Escape closes one overlay.
7
+
8
+ import { useEffect, useRef } from 'react';
9
+
10
+ /** Elements that can hold focus inside the overlay. */
11
+ const FOCUSABLE =
12
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
13
+
14
+ /** The open-overlay stack, newest last. Only the top entry answers Escape —
15
+ * a nested pair closes one per key, never both at once. */
16
+ const stack: { close: () => void }[] = [];
17
+
18
+ interface OverlayFocusDismissOptions {
19
+ /** Menus do not trap Tab (the APG menu pattern keeps Tab as leave); dialogs
20
+ * do. Default true. */
21
+ trapTab?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Wire the contract to an overlay root. Attach the returned ref to the element
26
+ * that bounds the overlay (its panel/menu box).
27
+ *
28
+ * @param open the overlay's open state — the effect arms on true and cleans
29
+ * up (returning focus) on false/unmount.
30
+ * @param onClose Escape's meaning: close without committing.
31
+ */
32
+ export function useOverlayFocusDismiss(
33
+ open: boolean,
34
+ onClose: () => void,
35
+ opts: OverlayFocusDismissOptions = {},
36
+ ) {
37
+ const rootRef = useRef<HTMLDivElement | null>(null);
38
+ // The latest close callback, kept current in an effect (never a render-time
39
+ // ref write — the react-hooks compiler forbids it, correctly).
40
+ const onCloseRef = useRef(onClose);
41
+ useEffect(() => {
42
+ onCloseRef.current = onClose;
43
+ });
44
+ const trapTab = opts.trapTab !== false;
45
+
46
+ useEffect(() => {
47
+ if (!open) return;
48
+ // The trigger to return to, captured BEFORE focus moves in.
49
+ const trigger = document.activeElement as HTMLElement | null;
50
+ const entry = {
51
+ close: () => onCloseRef.current(),
52
+ };
53
+ stack.push(entry);
54
+ // Set when a MENU surface closes because Tab left it: the browser's own
55
+ // focus move must stand (APG Menu Button), so the cleanup below must NOT
56
+ // yank focus back to the trigger.
57
+ let closedViaTab = false;
58
+ const root = rootRef.current;
59
+ const focusables = root ? [...root.querySelectorAll<HTMLElement>(FOCUSABLE)] : [];
60
+ // Focus IN: the first control; a panel with no focusable content takes
61
+ // focus itself so Escape has a home.
62
+ (focusables[0] ?? root)?.focus();
63
+
64
+ const onKeyDown = (e: KeyboardEvent) => {
65
+ const isTop = stack[stack.length - 1] === entry;
66
+ if (e.key === 'Escape') {
67
+ if (!isTop) return; // a nested overlay above owns this key
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ onCloseRef.current();
71
+ return;
72
+ }
73
+ if (!trapTab && e.key === 'Tab') {
74
+ // APG Menu: Tab is LEAVE — the focus move proceeds (no preventDefault)
75
+ // and the menu closes behind it, never stranded over a scrim. The
76
+ // flag keeps the cleanup from pulling focus back to the trigger: the
77
+ // move the browser just made is the contract.
78
+ if (isTop) {
79
+ closedViaTab = true;
80
+ onCloseRef.current();
81
+ }
82
+ return;
83
+ }
84
+ if (!trapTab || e.key !== 'Tab' || !root) return;
85
+ const list = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => !el.hasAttribute('disabled'));
86
+ if (!list.length) {
87
+ e.preventDefault();
88
+ return;
89
+ }
90
+ const first = list[0];
91
+ const last = list[list.length - 1];
92
+ const active = document.activeElement;
93
+ if (!root.contains(active)) {
94
+ e.preventDefault();
95
+ first.focus();
96
+ } else if (e.shiftKey && active === first) {
97
+ e.preventDefault();
98
+ last.focus();
99
+ } else if (!e.shiftKey && active === last) {
100
+ e.preventDefault();
101
+ first.focus();
102
+ }
103
+ };
104
+ // Capture phase: the contract runs before any app-level key handling.
105
+ document.addEventListener('keydown', onKeyDown, true);
106
+ return () => {
107
+ document.removeEventListener('keydown', onKeyDown, true);
108
+ const i = stack.indexOf(entry);
109
+ if (i >= 0) stack.splice(i, 1);
110
+ // Focus RETURNS to the trigger (R-IX-1) — unless the menu closed BECAUSE
111
+ // Tab left it: that move is the contract and stands. Guarded: the
112
+ // trigger may be gone.
113
+ if (!closedViaTab && trigger && document.contains(trigger)) trigger.focus();
114
+ };
115
+ }, [open, trapTab]);
116
+
117
+ return rootRef;
118
+ }