@immediately-run/grove 0.1.10 → 0.2.2

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 (53) hide show
  1. package/llms.txt +13 -1
  2. package/package.json +3 -3
  3. package/src/GroveApp.css +11 -0
  4. package/src/GroveWiki.tsx +19 -60
  5. package/src/components/AssetImage.test.tsx +64 -0
  6. package/src/components/AssetImage.tsx +10 -10
  7. package/src/components/Backlinks.tsx +13 -6
  8. package/src/components/ChildPages.tsx +8 -6
  9. package/src/components/Directory.tsx +6 -1
  10. package/src/components/DirectoryList.tsx +16 -8
  11. package/src/components/DirectoryView.tsx +5 -2
  12. package/src/components/DocList.tsx +9 -2
  13. package/src/components/Drawer.tsx +14 -1
  14. package/src/components/EntryBody.tsx +138 -0
  15. package/src/components/EntryHeader.tsx +16 -3
  16. package/src/components/FamilyTree.tsx +3 -5
  17. package/src/components/GroveAgent.tsx +1 -1
  18. package/src/components/GroveEntry.test.tsx +200 -0
  19. package/src/components/GroveEntry.tsx +108 -0
  20. package/src/components/GroveFooter.tsx +5 -2
  21. package/src/components/GroveNav.tsx +11 -2
  22. package/src/components/PageMeta.tsx +3 -5
  23. package/src/components/PageView.tsx +13 -63
  24. package/src/components/Search.tsx +10 -1
  25. package/src/components/Sidebar.tsx +15 -7
  26. package/src/components/TableOfContents.tsx +1 -1
  27. package/src/components/TagCloud.test.tsx +103 -0
  28. package/src/components/TagCloud.tsx +47 -29
  29. package/src/components/Timeline.tsx +6 -1
  30. package/src/components/WikiLink.test.tsx +83 -0
  31. package/src/components/WikiLink.tsx +29 -8
  32. package/src/components/navigationPolicy.sweep.test.tsx +261 -0
  33. package/src/hooks/useEditAffordance.test.tsx +110 -0
  34. package/src/hooks/useEditAffordance.ts +51 -14
  35. package/src/hooks/useEntryKey.ts +21 -0
  36. package/src/hooks/useFollowLink.ts +11 -0
  37. package/src/hooks/useHeadings.test.tsx +93 -0
  38. package/src/hooks/useHeadings.ts +73 -12
  39. package/src/lib/assetPath.ts +4 -3
  40. package/src/lib/content.ts +0 -5
  41. package/src/lib/editTarget.test.ts +78 -18
  42. package/src/lib/editTarget.ts +39 -10
  43. package/src/lib/entryContext.test.ts +46 -0
  44. package/src/lib/entryContext.ts +35 -0
  45. package/src/lib/fragment.test.ts +21 -0
  46. package/src/lib/fragment.ts +9 -6
  47. package/src/lib/navigationPolicy.test.ts +65 -0
  48. package/src/lib/navigationPolicy.ts +57 -0
  49. package/src/lib/renderMode.ts +17 -0
  50. package/src/lib/shell.ts +4 -0
  51. package/src/lib/tagCloud.test.ts +115 -0
  52. package/src/lib/tagCloud.ts +58 -0
  53. package/src/lib.ts +18 -0
@@ -29,8 +29,15 @@ function headingText(node: Element): string {
29
29
  }
30
30
 
31
31
  /**
32
- * Scan `.grove-prose` for `h2`/`h3`, assigning the canonical id to any heading the kernel
33
- * did not emit one for, and re-scan as the prose mounts or swaps on navigation.
32
+ * Scan the entry's body for `h2`/`h3`, assigning the canonical id to any heading the
33
+ * kernel did not emit one for, and re-scan as the prose mounts or swaps on navigation.
34
+ *
35
+ * R3-872 (APP_CUSTOMIZATION §4.5): the scan is scoped to the ENTRY's marked body
36
+ * (`[data-entry="<entryKey>"]` — emitted on both render paths), never the whole
37
+ * document: on a multi-entry page the first `.grove-prose` is whichever entry mounted
38
+ * first, and its headings are the wrong entry's. Unscoped (no key, or no marked body
39
+ * anywhere) falls back to the document — the pre-marker behavior, for content that
40
+ * never renders through the marked paths.
34
41
  */
35
42
  export function useHeadings(entryKey?: string): Heading[] {
36
43
  const [heads, setHeads] = useState<Heading[]>([]);
@@ -38,9 +45,39 @@ export function useHeadings(entryKey?: string): Heading[] {
38
45
  useEffect(() => {
39
46
  // eslint-disable-next-line react-hooks/set-state-in-effect
40
47
  setHeads([]);
48
+ // The scan root: this entry's marked body when markers exist; the document only
49
+ // when NO body is marked at all (the pre-marker world). A page with markers but
50
+ // none for this key (the body is still compiling) scans nothing — the observer
51
+ // watches the DOCUMENT until this entry's marker lands, then re-scopes (a
52
+ // late-committing entry's ToC must not stay empty for the mount's lifetime).
53
+ // NOTE: the marker sits INSIDE `.grove-prose` (both render paths), so the
54
+ // scoped query runs over the marker's own subtree — searching the marker FOR
55
+ // '.grove-prose' would find nothing (review round 1, G-CUST-2 regression).
56
+ const markedRoot = (): HTMLElement | null =>
57
+ entryKey === undefined
58
+ ? null
59
+ : Array.from(document.querySelectorAll<HTMLElement>('[data-entry]')).find(
60
+ (n) => n.getAttribute('data-entry') === entryKey,
61
+ ) ?? null;
62
+ const anyMarked = () => document.querySelectorAll('[data-entry]').length > 0;
63
+ const root = (): ParentNode | null => {
64
+ const marked = markedRoot();
65
+ if (marked) return marked;
66
+ if (entryKey === undefined) return document;
67
+ return anyMarked() ? null : document;
68
+ };
41
69
  const scan = () => {
42
- const prose = document.querySelector('.grove-prose');
43
- const nodes = prose ? Array.from(prose.querySelectorAll('h2, h3')) : [];
70
+ const r = root();
71
+ // Scoped: the marker's subtree holds the entry's headings. Unscoped (no
72
+ // markers anywhere): the document's `.grove-prose`, as before.
73
+ const nodes = !r
74
+ ? []
75
+ : r === document
76
+ ? (() => {
77
+ const prose = document.querySelector('.grove-prose');
78
+ return prose ? Array.from(prose.querySelectorAll('h2, h3')) : [];
79
+ })()
80
+ : Array.from(r.querySelectorAll('h2, h3'));
44
81
  const found: Heading[] = nodes.map((n) => {
45
82
  const text = headingText(n);
46
83
  // Prefer the kernel-emitted id (§15.5); `headingId` reproduces the same canon
@@ -56,15 +93,29 @@ export function useHeadings(entryKey?: string): Heading[] {
56
93
  prev.length === found.length && prev.every((h, i) => h.id === found[i].id) ? prev : found
57
94
  );
58
95
  };
96
+ // Observe the SCOPED root when it exists; while our marker is absent (the
97
+ // body still compiling), observe the DOCUMENT so the marker's arrival
98
+ // triggers the rescan that re-scopes — then move the observer onto the
99
+ // marker (a whole-document watch would re-scan on every mutation anywhere,
100
+ // the churn the identity guard exists to absorb).
101
+ let observing: ParentNode | null = null;
102
+ const obs = new MutationObserver(() => {
103
+ const wanted = root() ?? document;
104
+ if (wanted !== observing) {
105
+ obs.disconnect();
106
+ obs.observe(wanted, { childList: true, subtree: true });
107
+ observing = wanted;
108
+ }
109
+ scan();
110
+ });
111
+ observing = root() ?? document;
112
+ obs.observe(observing, { childList: true, subtree: true });
59
113
  scan();
60
- const prose = document.querySelector('.grove-prose');
61
- const obs = prose ? new MutationObserver(scan) : null;
62
- if (prose && obs) obs.observe(prose, { childList: true, subtree: true });
63
114
  // `<Include>` resolves asynchronously, and the observer only fires if `.grove-prose`
64
115
  // already existed — these catch the window where it did not.
65
116
  const timers = [120, 300, 600].map((d) => setTimeout(scan, d));
66
117
  return () => {
67
- obs?.disconnect();
118
+ obs.disconnect();
68
119
  timers.forEach(clearTimeout);
69
120
  };
70
121
  }, [entryKey]);
@@ -72,12 +123,21 @@ export function useHeadings(entryKey?: string): Heading[] {
72
123
  return heads;
73
124
  }
74
125
 
75
- /** Which heading the reader is currently in — scroll-spied off the document. */
76
- export function useActiveHeading(heads: Heading[]): string {
126
+ /** Which heading the reader is currently in — scroll-spied. R3-872: scoped to the
127
+ * entry's marked body when a key is given — duplicate section ids across entries on
128
+ * one page are the norm (every spec numbers from 1), and `document.getElementById`
129
+ * would return the FIRST entry's heading, spying on the wrong entry. */
130
+ export function useActiveHeading(heads: Heading[], entryKey?: string): string {
77
131
  const [cur, setCur] = useState<string>('');
78
132
 
79
133
  useEffect(() => {
80
134
  if (!heads.length) return;
135
+ const scope: ParentNode =
136
+ entryKey === undefined
137
+ ? document
138
+ : Array.from(document.querySelectorAll<HTMLElement>('[data-entry]')).find(
139
+ (n) => n.getAttribute('data-entry') === entryKey,
140
+ ) ?? document;
81
141
  const obs = new IntersectionObserver(
82
142
  (entries) => {
83
143
  const visible = entries.filter((e) => e.isIntersecting);
@@ -86,11 +146,12 @@ export function useActiveHeading(heads: Heading[]): string {
86
146
  { rootMargin: '-64px 0px -70% 0px', threshold: 0 }
87
147
  );
88
148
  heads.forEach((h) => {
89
- const el = document.getElementById(h.id);
149
+ // find the element INSIDE the scope (a heading id is only unique per entry)
150
+ const el = scope === document ? document.getElementById(h.id) : scope.querySelector(`#${CSS.escape(h.id)}`);
90
151
  if (el) obs.observe(el);
91
152
  });
92
153
  return () => obs.disconnect();
93
- }, [heads]);
154
+ }, [heads, entryKey]);
94
155
 
95
156
  return cur;
96
157
  }
@@ -2,9 +2,10 @@
2
2
  // `cover:`/`img src`" into the fs path the SDK's `MountImage` reads.
3
3
  //
4
4
  // The distinction this module exists for: a body image resolves against the entry
5
- // CURRENTLY BEING RENDERED (`navigationState.sandboxPath` — `AssetImage`), while a
6
- // cover resolves against its OWNING entry — the entry the card/row/tile is ABOUT,
7
- // which under dispatch is usually a DIFFERENT base. Getting this wrong is not a bug
5
+ // currently being rendered (the entry context's key via useEntryKey — AssetImage;
6
+ // the routed key only as the no-provider fallback), while a
7
+ // cover resolves against its owning entry — the entry the card/row/tile is about,
8
+ // which under dispatch is usually a different base. Getting this wrong is not a bug
8
9
  // in `AssetImage`; it is the second half of design-pass gap 7: a `<DocList>` card or
9
10
  // `<Timeline>` row showing another entry's picture resolved against the wrong base
10
11
  // by construction.
@@ -286,8 +286,3 @@ export function sandboxPathToKey(sandboxPath: string): string {
286
286
  // HOME rather than becoming a key that reads some other file.
287
287
  return p.startsWith(contentDir()) ? p : homeKey();
288
288
  }
289
-
290
- /** A sandboxPath → the absolute fs base for resolving relative assets. */
291
- export function toFsPath(sandboxPath: string): string {
292
- return keyToFsPath(sandboxPathToKey(sandboxPath));
293
- }
@@ -4,21 +4,29 @@
4
4
  // viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
5
5
  // offer one at all must be the corpus mount's CURRENT mode rather than a property of the
6
6
  // packaging or a flag latched at boot.
7
- import { describe, expect, it } from 'vitest';
7
+ import { describe, expect, it, afterEach } from 'vitest';
8
8
  import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
9
9
  import type { CorpusIdentity } from './editTarget';
10
10
  import type { SandboxMount } from '@immediately-run/sdk/mounts';
11
-
12
- const fork: CorpusIdentity = {
13
- dispatched: false,
14
- contentRoot: '/app/content/',
15
- mountId: null,
11
+ import { getContentRoot, getCorpusMountId, isDispatched, resetContentRoot, setContentRoot } from './contentRoot';
12
+
13
+ // The corpus identities come from the real producer (R3-877 round 1, R2): the
14
+ // trailing-slash normalization every `slice` in editTarget depends on lives in
15
+ // `setContentRoot` — a hand-typed literal would keep passing while it broke.
16
+ const forkFor = (): CorpusIdentity => {
17
+ resetContentRoot();
18
+ return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
16
19
  };
17
- const dispatched: CorpusIdentity = {
18
- dispatched: true,
19
- contentRoot: '/task/t1/dir/',
20
- mountId: '/task/t1/dir',
20
+ const dispatchedFor = (): CorpusIdentity => {
21
+ setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
22
+ return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
21
23
  };
24
+ afterEach(() => resetContentRoot());
25
+
26
+ const fork: CorpusIdentity = forkFor();
27
+ const dispatched: CorpusIdentity = dispatchedFor();
28
+ // The dispatched entry keys, built off the producer's root — never a hand-typed prefix.
29
+ const IN = (rel: string) => `${dispatched.contentRoot}${rel}`;
22
30
 
23
31
  const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
24
32
  ({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
@@ -32,7 +40,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
32
40
  });
33
41
 
34
42
  it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
35
- expect(editTarget('/task/t1/dir/plot/the-rail.mdx', dispatched)).toEqual({
43
+ expect(editTarget(IN('plot/the-rail.mdx'), dispatched)).toEqual({
36
44
  via: 'delegate',
37
45
  mountId: '/task/t1/dir',
38
46
  relPath: 'plot/the-rail.mdx',
@@ -40,7 +48,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
40
48
  });
41
49
 
42
50
  it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
43
- const t = editTarget('/task/t1/dir/home.mdx', dispatched);
51
+ const t = editTarget(IN('home.mdx'), dispatched);
44
52
  expect(t).toMatchObject({ relPath: 'home.mdx' });
45
53
  // The fork's `content/` segment must NOT leak into a corpus-relative path: the
46
54
  // delegated chroot is minted AT the content directory.
@@ -52,11 +60,11 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
52
60
  });
53
61
 
54
62
  it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
55
- expect(editTarget('/task/t1/dir/home.mdx', { ...dispatched, mountId: null })).toBeNull();
63
+ expect(editTarget(IN('home.mdx'), { ...dispatched, mountId: null })).toBeNull();
56
64
  });
57
65
 
58
66
  it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
59
- expect(editTarget('/task/t1/dir/', dispatched)).toBeNull();
67
+ expect(editTarget(IN(''), dispatched)).toBeNull();
60
68
  });
61
69
 
62
70
  it('never throws on a junk key', () => {
@@ -81,13 +89,19 @@ describe('corpusWritable — the mount decides, live', () => {
81
89
  expect(corpusWritable([mount()], dispatched)).toBe(true);
82
90
  });
83
91
 
84
- it('a ro corpus is not writable, so the affordance is hidden rather than EROFS-ing', () => {
85
- expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
92
+ it('a ro corpus with no hint is still offerable (R3-877: the workbench class) — never EROFS, a refusal tells', () => {
93
+ // Pre-R3-877 this was `false` — an ro mount hid the affordance outright. The
94
+ // workbench class edits under the reader's authority, so our ro mount is the
95
+ // normal case, not a refusal. An explicit `false` hint still hides it (below).
96
+ expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
86
97
  });
87
98
 
88
- it('follows a LIVE downgrade: the same mount re-announced ro flips the answer', () => {
99
+ it('follows a live downgrade: re-announced ro flips the delivery, and a false hint flips the offer', () => {
89
100
  expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
90
- expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
101
+ // ro with no hint: still offerable, now via the workbench (reader's authority).
102
+ expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
103
+ // ro with the host saying the reader cannot edit: hidden.
104
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
91
105
  });
92
106
 
93
107
  it('a corpus mount that has vanished is not writable', () => {
@@ -106,3 +120,49 @@ describe('corpusWritable — the mount decides, live', () => {
106
120
  expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
107
121
  });
108
122
  });
123
+
124
+ // R3-877 — the third outcome: an ro delegation edits via the workbench, under the
125
+ // reader's authority (`requestEdit({ bundleFile })`, R3-876). Order of preference:
126
+ // self → delegate (rw) → workbench (ro).
127
+ describe('editTarget — the workbench class for a read-only delegation (R3-877)', () => {
128
+ // The corpus identity's contentRoot mirrors the real getContentRoot() shape: the
129
+ // delegated chroot root, trailing slash.
130
+ const roCorpus: CorpusIdentity = { ...dispatched, mountMode: 'ro' };
131
+
132
+ it('an `ro` dispatched corpus yields workbench with the leading-slash bundle-relative path', () => {
133
+ expect(editTarget(IN('plot/the-rail.mdx'), roCorpus)).toEqual({
134
+ via: 'workbench',
135
+ relPath: '/plot/the-rail.mdx',
136
+ });
137
+ });
138
+
139
+ it('an `rw` delegation still yields delegate (the edit-file overlay is unchanged)', () => {
140
+ expect(editTarget(IN('plot/the-rail.mdx'), { ...dispatched, mountMode: 'rw' })).toEqual({
141
+ via: 'delegate',
142
+ mountId: '/task/t1/dir',
143
+ relPath: 'plot/the-rail.mdx',
144
+ });
145
+ });
146
+
147
+ it('an UNKNOWN mode (an older host announces none) keeps the pre-R3-877 delegate behavior', () => {
148
+ expect(editTarget(IN('home.mdx'), dispatched)).toEqual({
149
+ via: 'delegate',
150
+ mountId: '/task/t1/dir',
151
+ relPath: 'home.mdx',
152
+ });
153
+ });
154
+
155
+ it('the corpus root itself is still nothing to edit, workbench included', () => {
156
+ expect(editTarget(IN(''), roCorpus)).toBeNull();
157
+ });
158
+ });
159
+
160
+ describe('corpusWritable — the ro delegation is offerable on the hint (R3-877)', () => {
161
+ it('ro + readerCanEdit true → offered', () => {
162
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: true })], dispatched)).toBe(true);
163
+ });
164
+
165
+ it('ro + readerCanEdit false → not offered (never show a control that refuses)', () => {
166
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
167
+ });
168
+ });
@@ -20,8 +20,11 @@
20
20
  //
21
21
  // **The mount decides.** Writability is a property of the delegation's current mode, not
22
22
  // of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
23
- // than the boot-time flag: a role downgrade re-announces the mount `ro`, and the
24
- // affordance must disappear rather than surface `EROFS` when clicked.
23
+ // than the boot-time flag. Since R3-877 a role downgrade no longer hides the affordance —
24
+ // it reroutes the delivery: `rw` hands the file to the `edit-file` overlay, `ro` asks the
25
+ // workbench under the reader's authority (`requestEdit({ bundleFile })`). The offer hides
26
+ // only when the host's `readerCanEdit` hint says the reader cannot edit, or a `read-only`
27
+ // refusal proved it.
25
28
  //
26
29
  // Pure — no SDK, no React — so all of the above is testable without a host.
27
30
 
@@ -31,8 +34,14 @@ import type { SandboxMount } from '@immediately-run/sdk/mounts';
31
34
  export type EditTarget =
32
35
  /** The fork: our own repo, via the self-scoped present→edit transition. */
33
36
  | { via: 'self'; path: string }
34
- /** Dispatch: one file of the delegated corpus, handed to the platform editor. */
35
- | { via: 'delegate'; mountId: string; relPath: string };
37
+ /** Dispatch, writable delegation: one file of the corpus, handed to the platform
38
+ * editor as a narrowed `edit-file` delegation (the overlay). */
39
+ | { via: 'delegate'; mountId: string; relPath: string }
40
+ /** Dispatch, read-only delegation (an app-declared opener's chroot): ask the
41
+ * workbench to open the entry's source in the main-pane editor under the
42
+ * reader's authority — `requestEdit({ bundleFile })` (R3-876 / APP_CUSTOMIZATION
43
+ * §5a). The path is bundle-relative with a leading slash. */
44
+ | { via: 'workbench'; relPath: string };
36
45
 
37
46
  export interface CorpusIdentity {
38
47
  /** Whether the corpus is a mount rather than this app's own repo. */
@@ -41,6 +50,11 @@ export interface CorpusIdentity {
41
50
  contentRoot: string;
42
51
  /** The corpus mount id, when dispatched (`getCorpusMountId()`). */
43
52
  mountId: string | null;
53
+ /** The corpus delegation's current mode, read off the live mount list (R3-877):
54
+ * `ro` routes the edit to the workbench under the reader's authority; `rw` keeps
55
+ * the `edit-file` overlay. Absent/unknown keeps the pre-R3-877 behavior
56
+ * (`delegate` — an `rw`-assuming host that announces no mode). */
57
+ mountMode?: 'ro' | 'rw' | null;
44
58
  }
45
59
 
46
60
  /** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
@@ -62,17 +76,25 @@ export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget
62
76
  if (!corpus.mountId) return null;
63
77
  if (!entryKey.startsWith(corpus.contentRoot)) return null;
64
78
  const relPath = entryKey.slice(corpus.contentRoot.length);
65
- return relPath ? { via: 'delegate', mountId: corpus.mountId, relPath } : null;
79
+ if (!relPath) return null;
80
+ // An `ro` delegation (the opener's chroot — the only mode an app-declared opener
81
+ // ever holds) goes to the workbench: the reader's authority, not ours — the mount
82
+ // is never upgraded and nothing is minted for us. Leading-slash, the host's
83
+ // `bundleFile` grammar.
84
+ if (corpus.mountMode === 'ro') return { via: 'workbench', relPath: `/${relPath}` };
85
+ return { via: 'delegate', mountId: corpus.mountId, relPath };
66
86
  }
67
87
 
68
88
  /**
69
89
  * May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
70
90
  *
71
91
  * A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
72
- * mount — and asks the LIVE mount list, not the boot-time flag, so a live `rw → ro`
73
- * downgrade (a role change the host re-announces on the same mount id) hides the
74
- * affordance on the next render. That is the whole difference between "hidden because you
75
- * may not" and "shown, then `EROFS` when you try".
92
+ * mount — and asks the live mount list, not the boot-time flag, so a live `rw → ro`
93
+ * downgrade (a role change the host re-announces on the same mount id) reroutes the
94
+ * delivery on the next render (to the workbench class) and an explicit
95
+ * `readerCanEdit: false` hides the offer — rather than surfacing `EROFS` on click.
96
+ * That is the whole difference between "hidden because you may not" and "shown, then
97
+ * `EROFS` when you try".
76
98
  *
77
99
  * A corpus mount that has vanished from the list answers `false`: no mount, no write.
78
100
  */
@@ -89,5 +111,12 @@ export function corpusWritable(
89
111
  // `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
90
112
  // mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
91
113
  // it, so the two never disagree about the same mount.
92
- return !!mount && mount.mode !== 'ro';
114
+ if (!mount) return false;
115
+ if (mount.mode !== 'ro') return true;
116
+ // R3-877 (APP_CUSTOMIZATION §5a.5): an `ro` delegation can still offer the
117
+ // workbench edit — the reader's authority — gated on the host's advisory
118
+ // `readerCanEdit` hint: offer when it is true, OR when the host sent no hint
119
+ // (absent = unknown — the refusal would tell, and `read-only` hides it after).
120
+ // Never offer on an explicit `false`.
121
+ return mount.readerCanEdit !== false;
93
122
  }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ // R3-871 — the entry context's actual behaviour, over the real key grammar.
3
+ // The context key wins when a provider is present; the routed key (real
4
+ // sandboxPathToKey) otherwise; and the component-level case — a relative link
5
+ // inside a non-routed entry resolving against that entry — is driven in
6
+ // WikiLink.test.tsx.
7
+ import { describe, it, expect } from 'vitest';
8
+ import { entryKeyOr } from './entryContext';
9
+ import { sandboxPathToKey } from './content';
10
+
11
+ describe('entryKeyOr — the one fallback rule (R3-871)', () => {
12
+ it('returns the context key when a provider is present', () => {
13
+ expect(entryKeyOr({ entryKey: '/app/content/teams/engineering.mdx' }, '/files/content/home.mdx')).toBe(
14
+ '/app/content/teams/engineering.mdx',
15
+ );
16
+ });
17
+
18
+ it('falls back to the routed key when the context is null (no provider)', () => {
19
+ expect(entryKeyOr(null, '/files/content/handbook/onboarding.mdx')).toBe(
20
+ sandboxPathToKey('/files/content/handbook/onboarding.mdx'),
21
+ );
22
+ });
23
+
24
+ it('treats an absent context the same as null', () => {
25
+ expect(entryKeyOr(undefined, '/files/content/handbook/onboarding.mdx')).toBe(
26
+ entryKeyOr(null, '/files/content/handbook/onboarding.mdx'),
27
+ );
28
+ });
29
+
30
+ it('the fallback is computed by the REAL sandboxPathToKey — the routed cases', () => {
31
+ // the folder URL form and the bare root both route HOME (the exact rule
32
+ // sandboxPathToKey encodes), so the default rendering is unchanged.
33
+ expect(entryKeyOr(null, '/')).toBe(sandboxPathToKey('/'));
34
+ expect(entryKeyOr(null, '')).toBe(sandboxPathToKey('/'));
35
+ // the /files-prefixed form strips to the content key
36
+ expect(entryKeyOr(null, '/files/content/a/b.mdx')).toBe('/app/content/a/b.mdx');
37
+ });
38
+ });
39
+
40
+ describe('entryKeyOr — a provider that says nothing', () => {
41
+ it('an empty-string entryKey is not a key: it falls back to the routed key', () => {
42
+ expect(entryKeyOr({ entryKey: '' }, '/files/content/handbook/onboarding.mdx')).toBe(
43
+ sandboxPathToKey('/files/content/handbook/onboarding.mdx'),
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,35 @@
1
+ // The entry context (APP_CUSTOMIZATION_SPEC §4.2; R3-871).
2
+ //
3
+ // Seven-plus Grove components used to work out "the entry I am in" from the
4
+ // URL (`sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/')`). Inside
5
+ // anything but the routed entry — an included fragment, a layout, and next a
6
+ // story-river card — a relative `[[link]]` or `![](img.png)` resolved against
7
+ // the wrong base, and "self" was judged against the URL. The provider placed
8
+ // by `GroveWiki` (R3-872 moves it into `GroveEntry`) says which entry a
9
+ // subtree renders; this module owns the context shape and the pure fallback
10
+ // rule so both are testable without React.
11
+ import { createContext } from 'react';
12
+ import { sandboxPathToKey } from './content';
13
+
14
+ /** What a provider subtree declares: the metadata key of the entry it renders. */
15
+ export interface EntryContextValue {
16
+ entryKey: string;
17
+ }
18
+
19
+ /** The provider seam itself (§4.2): null — not the routed key — when no
20
+ * provider is above, so the pure fallback rule below decides, exactly once.
21
+ * Lives here, beside the rule it carries, per the item's placement; it is a
22
+ * context object, not a component, so the Fast Refresh rule is not engaged. */
23
+ export const EntryContext = createContext<EntryContextValue | null>(null);
24
+
25
+ /**
26
+ * The one fallback rule: the context's entry when a provider is present, the
27
+ * routed key otherwise — computed by the real `sandboxPathToKey`, so the
28
+ * default rendering is byte-identical to before the context existed
29
+ * (R-CUST-3, default-preserving).
30
+ */
31
+ export function entryKeyOr(contextValue: EntryContextValue | null | undefined, sandboxPath: string): string {
32
+ // `||` (not `??`): an empty-string entryKey is "a provider that says
33
+ // nothing", and falls back — '' is not a key anything can resolve against.
34
+ return contextValue?.entryKey || sandboxPathToKey(sandboxPath || '/');
35
+ }
@@ -86,3 +86,24 @@ describe('resolveFragmentTarget — the stale-document guard (R3-249)', () => {
86
86
  expect(resolveFragmentTarget(doc, INCOMING, '')).toBeNull();
87
87
  });
88
88
  });
89
+
90
+ describe('G-CUST-4 — entry-scoped resolution on a multi-entry page (R3-872)', () => {
91
+ const FIRST = '/app/content/specs/A_SPEC.mdx';
92
+ const SECOND = '/app/content/specs/B_SPEC.mdx';
93
+
94
+ it('two marked bodies, both with #sec-4 (one per render path — both carry data-entry since R3-872): scoping to the second lands in the second', () => {
95
+ const doc = makeDoc([
96
+ { entry: FIRST, ids: ['sec-4'] },
97
+ { entry: SECOND, ids: ['sec-4'] },
98
+ ]);
99
+ const el = resolveFragmentTarget(doc, SECOND, 'sec-4') as unknown as { entry: string };
100
+ expect(el?.entry).toBe(SECOND);
101
+ });
102
+
103
+ it('the document-wide fallback fires only when NO body is marked at all (pre-marker content)', () => {
104
+ const doc = makeDoc([]);
105
+ // no marked bodies → the fallback is the whole document (today's compiled-path
106
+ // behavior for content that never renders the marked paths)
107
+ expect(resolveFragmentTarget(doc, FIRST, 'sec-4')).toBeNull(); // nothing anywhere
108
+ });
109
+ });
@@ -24,9 +24,12 @@ export function fragmentOf(hash: string | undefined | null): string {
24
24
  * about. Returns null while the previous entry is still rendered, which is the signal to
25
25
  * wait rather than to scroll.
26
26
  *
27
- * Falls back to the whole document when no marked body is present — the compiled
28
- * (`<Include>`) path does not carry the marker, and scrolling imperfectly there is better
29
- * than not at all.
27
+ * R3-872: BOTH render paths carry the `data-entry` marker now (the compiled path's
28
+ * omission predates entry-scoped resolution) — the marker means "this region belongs to
29
+ * entry X", and a fragment not yet inside it is not-found, so the caller keeps waiting
30
+ * through the compile window (ScrollToFragment retries; claim-too-early has no path to
31
+ * fire). The document-wide fallback serves only content rendered with NO marked bodies
32
+ * at all (pre-marker content), never a multi-entry page.
30
33
  */
31
34
  export function resolveFragmentTarget(doc: Document, entryKey: string, frag: string): HTMLElement | null {
32
35
  if (!frag) return null;
@@ -37,9 +40,9 @@ export function resolveFragmentTarget(doc: Document, entryKey: string, frag: str
37
40
  // strings has no such failure mode, and there are only a handful of marked bodies.
38
41
  const marked = Array.from(doc.querySelectorAll<HTMLElement>('[data-entry]'));
39
42
  const scope: ParentNode | null = marked.find((n) => n.getAttribute('data-entry') === entryKey) ?? null;
40
- // No marked body at all: the compiled (`<Include>`) path does not carry the marker, so
41
- // fall back to the document. A marked body for a DIFFERENT entry means the previous
42
- // document is still on screen — return null and wait, rather than scroll the wrong page.
43
+ // No marked body at all (content rendered outside the marked paths): fall back to
44
+ // the document. A marked body for a DIFFERENT entry means the previous document is
45
+ // still on screen — return null and wait, rather than scroll the wrong page.
43
46
  const root: ParentNode | null = scope ?? (marked.length === 0 ? doc : null);
44
47
  if (!root) return null;
45
48
  const byId = root.querySelector<HTMLElement>(`[id="${cssEscape(frag)}"]`);
@@ -0,0 +1,65 @@
1
+ // The navigation policy's defaults + click guard (APP_CUSTOMIZATION_SPEC §4.3,
2
+ // R3-872): the stock policy navigates the resolved href; a plain click routes to
3
+ // the policy with the browser default prevented; a modified click falls through
4
+ // to the real href; a throwing policy fails LOUDLY (logged, never silently
5
+ // retried by the default — R-CUST-5).
6
+ import { describe, it, expect, vi } from 'vitest';
7
+
8
+ const navigate = vi.fn();
9
+ vi.mock('@immediately-run/sdk', () => ({ navigate: (...a: unknown[]) => navigate(...(a as [])) }));
10
+
11
+ import { defaultFollowLink, followLinkOnClick } from './navigationPolicy';
12
+
13
+ const click = (over: Partial<Parameters<ReturnType<typeof followLinkOnClick>>[0]> = {}) => ({
14
+ button: 0,
15
+ metaKey: false,
16
+ ctrlKey: false,
17
+ shiftKey: false,
18
+ altKey: false,
19
+ preventDefault: vi.fn(),
20
+ ...over,
21
+ });
22
+
23
+ describe('navigationPolicy', () => {
24
+ it('defaultFollowLink calls the SDK navigate with the resolved href', () => {
25
+ defaultFollowLink({ key: '/app/content/a.mdx', href: '/content/a' });
26
+ expect(navigate).toHaveBeenCalledWith('/content/a');
27
+ });
28
+
29
+ it('a plain click (primary button, no modifier) reaches the policy and prevents the default', () => {
30
+ const follow = vi.fn();
31
+ const e = click();
32
+ followLinkOnClick(follow, { key: 'k', fragment: 'sec-4', href: '/h#sec-4', from: 'here' })(e);
33
+ expect(follow).toHaveBeenCalledWith({ key: 'k', fragment: 'sec-4', href: '/h#sec-4', from: 'here' });
34
+ expect(e.preventDefault).toHaveBeenCalled();
35
+ });
36
+
37
+ it.each([
38
+ ['meta', { metaKey: true }],
39
+ ['ctrl', { ctrlKey: true }],
40
+ ['shift', { shiftKey: true }],
41
+ ['alt', { altKey: true }],
42
+ ['non-primary button', { button: 1 }],
43
+ ])('a %s click never reaches the policy — the real href handles it', (_name, over) => {
44
+ const follow = vi.fn();
45
+ const e = click(over);
46
+ followLinkOnClick(follow, { key: 'k', href: '/h' })(e);
47
+ expect(follow).not.toHaveBeenCalled();
48
+ expect(e.preventDefault).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('a throwing policy is logged with the target and NOT silently fallen back from', () => {
52
+ const boom = new Error('policy broke');
53
+ const follow = vi.fn(() => {
54
+ throw boom;
55
+ });
56
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ const e = click();
58
+ expect(() => followLinkOnClick(follow, { key: 'k', href: '/h' })(e)).not.toThrow();
59
+ expect(err).toHaveBeenCalledWith(expect.stringContaining('k'), boom);
60
+ // and no default navigation happened in place of the policy (the click was
61
+ // already preventDefault'd — the shell's bug is visible, never papered over)
62
+ expect(e.preventDefault).toHaveBeenCalled();
63
+ err.mockRestore();
64
+ });
65
+ });