@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,87 @@
1
+ // The bucket-B gate + the value-3 invariant (R3-309): page variants are CSS
2
+ // selected by data attributes, so the checks are consistency — every variant the
3
+ // CSS implements is reachable, every reachable value has a rule, an undeclared
4
+ // value falls back — plus the promise that deleting every `_layout.mdx` still
5
+ // yields the full default shell.
6
+ import { describe, it, expect } from 'vitest';
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
11
+ import DefaultLayout from '../components/DefaultLayout';
12
+ import { resolveNavMode, resolvePageLayout } from './layout';
13
+ import { GroveShellContext, OutletContext, type GroveShell } from './shell';
14
+
15
+ const css = readFileSync(join(process.cwd(), 'src', 'GroveApp.css'), 'utf8');
16
+
17
+ const dataLayoutRules = new Set([...css.matchAll(/\[data-layout="([^"]+)"\]/g)].map((m) => m[1]!));
18
+ const dataNavRules = new Set([...css.matchAll(/\[data-nav="([^"]+)"\]/g)].map((m) => m[1]!));
19
+
20
+ describe('page variants — every declared value has a rule, every rule is reachable (bucket B)', () => {
21
+ it('the CSS implements exactly the three layout variants the engine can emit', () => {
22
+ // resolvePageLayout is the closed emitter: doc | post | full. A CSS rule for a
23
+ // fourth value would be dead selectors; an emitter value without a rule would
24
+ // be a silently unstyled page. Pinned together so neither can drift alone.
25
+ expect(dataLayoutRules).toEqual(new Set(['doc', 'post', 'full']));
26
+ for (const v of ['doc', 'post', 'full'] as const) {
27
+ expect(resolvePageLayout({ layout: v })).toBe(v);
28
+ }
29
+ });
30
+
31
+ it('an undeclared layout value falls back to the reference look, never an unstyled page', () => {
32
+ expect(resolvePageLayout(undefined)).toBe('doc');
33
+ expect(resolvePageLayout({})).toBe('doc');
34
+ expect(resolvePageLayout({ layout: 'wide-but-unimplemented' })).toBe('doc');
35
+ expect(resolvePageLayout({ layout: 7 })).toBe('doc');
36
+ });
37
+
38
+ it('`frame: none` is the fourth variant and needs no layout rule — it removes the shell', () => {
39
+ // Bare-by-frontmatter is handled in GroveWiki (frameNone drops the chain); it
40
+ // selects no [data-layout] variant, so its absence from the CSS set is the
41
+ // contract, asserted here so a future "bare" CSS rule is a visible decision.
42
+ expect(dataLayoutRules.has('none')).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('nav arrangement — selectable, with both polarities real (R3-309)', () => {
47
+ it("'side' carries CSS rules; 'top' is the base .grove-shell grid they override", () => {
48
+ // The top arrangement is what .grove-shell IS (base grid); [data-nav='side'] is
49
+ // the variant. So the consistency check is: side has rules, and the base
50
+ // block exists for top to fall through to.
51
+ expect(dataNavRules.has('side')).toBe(true);
52
+ expect(css).toMatch(/^\.grove-shell\s*\{/m);
53
+ });
54
+
55
+ it('resolveNavMode: declared top selects top; anything else falls back to side', () => {
56
+ expect(resolveNavMode({ nav: 'top' })).toBe('top');
57
+ expect(resolveNavMode({ nav: 'side' })).toBe('side');
58
+ expect(resolveNavMode(undefined)).toBe('side');
59
+ expect(resolveNavMode({ nav: 'diagonal' })).toBe('side');
60
+ });
61
+ });
62
+
63
+ describe('deleting every _layout.mdx still yields the full default shell (value 3)', () => {
64
+ it('DefaultLayout renders nav, sidebar, footer AND frames a page at Outlet', () => {
65
+ // Asserted rather than assumed: the whole starter catalogue rests on the
66
+ // promise that starters are a convenience — remove them all and a bare folder
67
+ // of .mdx is still a whole site.
68
+ const SHELL = { navMode: 'side', vw: 'desktop', navItems: [] } as unknown as GroveShell;
69
+ const NAV = {
70
+ outerHref: 'https://example.immediately.run/app/x',
71
+ navigationState: { sandboxPath: '/app/x' },
72
+ };
73
+ const html = renderToStaticMarkup(
74
+ <TinkerableContext.Provider value={NAV as never}>
75
+ <GroveShellContext.Provider value={SHELL}>
76
+ <OutletContext.Provider value={<p>THE-PAGE</p>}>
77
+ <DefaultLayout />
78
+ </OutletContext.Provider>
79
+ </GroveShellContext.Provider>
80
+ </TinkerableContext.Provider>,
81
+ );
82
+ expect(html).toContain('grove-nav');
83
+ expect(html).toContain('grove-sidebar');
84
+ expect(html).toContain('grove-footer');
85
+ expect(html).toContain('THE-PAGE');
86
+ });
87
+ });
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { familyTreeQuery, navQuery, searchQuery, sidebarQuery } from './queries';
2
+ import {
3
+ familyTreeQuery,
4
+ matchesQuery,
5
+ navQuery,
6
+ searchQuery,
7
+ sidebarQuery,
8
+ toSearchEntries,
9
+ } from './queries';
3
10
 
4
11
  // The metadata queries select records (R3-276a). These tests pin the selections
5
12
  // themselves; the components are thin wiring around them. The store shape is the
@@ -26,11 +33,36 @@ describe('navQuery', () => {
26
33
  expect(navQuery({ '/app/content/a.mdx': { title: 'A.', tags: ['ui/nav'] } })[0].label).toBe('A');
27
34
  });
28
35
 
36
+ it('a label is the PLAIN form — frontmatter markers dropped, content kept (R3-531)', () => {
37
+ const rows = navQuery({ '/app/content/a.mdx': { title: 'Run `x` now.', tags: ['ui/nav'] } });
38
+ expect(rows[0]!.label).toBe('Run x now');
39
+ // An explicit `nav` is author intent and passes through the plain form too —
40
+ // a stray marker there gets the same treatment one level down.
41
+ expect(navQuery({ '/app/content/b.mdx': { title: 'T', nav: 'The **big** door', tags: ['ui/nav'] } })[0].label).toBe('The big door');
42
+ });
43
+
29
44
  it('never tab-encodes (the R3-276a regression: fake paths are gone)', () => {
30
45
  for (const r of navQuery(fm)) expect(JSON.stringify(r)).not.toContain('\t');
31
46
  });
32
47
  });
33
48
 
49
+ describe('the ⌘K index and matcher (R3-531)', () => {
50
+ it('toSearchEntries keeps the RAW title for rendering beside the plain form for matching', () => {
51
+ const [e] = toSearchEntries([{ path: '/app/content/a.mdx', title: 'A `cache.yml` entry.', desc: 'About **keys**.' }]);
52
+ expect(e.title).toBe('A `cache.yml` entry.');
53
+ expect(e.plain).toBe('A cache.yml entry');
54
+ expect(e.desc).toBe('About keys.');
55
+ });
56
+
57
+ it('matchesQuery reads the plain forms — a query typed over what the reader sees hits', () => {
58
+ const [e] = toSearchEntries([{ path: '/app/content/a.mdx', title: 'A `cache.yml` entry.', desc: '' }]);
59
+ expect(matchesQuery(e, 'cache.yml')).toBe(true);
60
+ expect(matchesQuery(e, '`cache`')).toBe(false); // the raw markers are not searchable text
61
+ expect(matchesQuery(e, 'entry')).toBe(true);
62
+ expect(matchesQuery(e, 'zzz')).toBe(false);
63
+ });
64
+ });
65
+
34
66
  describe('searchQuery', () => {
35
67
  it('indexes every content entry with title/desc and non-ui tags', () => {
36
68
  const rows = searchQuery(fm);
@@ -11,6 +11,7 @@
11
11
  // Every record field is derived data; `path`/`meta` are applied by the hook.
12
12
 
13
13
  import { isContentEntry } from './content';
14
+ import { plainProse } from '@immediately-run/mdx-plugins';
14
15
 
15
16
  /** A nav item derived from `ui/nav` frontmatter. */
16
17
  export type NavRecord = {
@@ -24,7 +25,38 @@ export function navQuery(fm: Record<string, any>): NavRecord[] {
24
25
  return Object.keys(fm)
25
26
  .filter((p) => isContentEntry(p) && Array.isArray(fm[p]?.tags) && fm[p].tags.includes('ui/nav'))
26
27
  .sort((a, b) => (fm[a].order ?? 999) - (fm[b].order ?? 999))
27
- .map((p) => ({ path: p, label: fm[p]?.nav || (fm[p]?.title || '').replace(/\.$/, '') }));
28
+ .map((p) => ({ path: p, label: plainLabel(fm[p]?.nav || fm[p]?.title || '') }));
29
+ }
30
+
31
+ /** A field bound for an attribute, a label, a prompt or an index — the plain
32
+ * form (R3-531): markers dropped, content kept, then the period trimmed. */
33
+ export function plainLabel(s: string): string {
34
+ return plainProse(s).replace(/\.$/, '');
35
+ }
36
+
37
+ /** One searchable entry: the RAW title (the palette renders it as inline prose)
38
+ * beside the plain form the matcher reads. */
39
+ export type SearchEntry = {
40
+ key: string;
41
+ title: string;
42
+ plain: string;
43
+ desc: string;
44
+ };
45
+
46
+ /** The ⌘K index over search records — pure, so the plain/raw split is testable. */
47
+ export function toSearchEntries(rows: { path: string; title: string; desc: string }[]): SearchEntry[] {
48
+ return rows.map(({ path, title, desc }) => ({
49
+ key: path,
50
+ title: title || path,
51
+ plain: plainLabel(title || path),
52
+ desc: plainProse(desc || ''),
53
+ }));
54
+ }
55
+
56
+ /** Match an entry against a lowercased query on its PLAIN forms — matching the
57
+ * raw markdown would miss queries typed over what the reader actually sees. */
58
+ export function matchesQuery(e: SearchEntry, ql: string): boolean {
59
+ return e.plain.toLowerCase().includes(ql) || e.desc.toLowerCase().includes(ql) || e.key.toLowerCase().includes(ql);
28
60
  }
29
61
 
30
62
  /** One search index row. `tags` excludes the `ui/*` furniture namespace. */
@@ -0,0 +1,94 @@
1
+ // R-GA-1 / G-GA-1 / G-GA-6 / G-GA-10 — the reach card is a pure function of the
2
+ // envelope; no pixel claims a capability the session lacks, causes are distinct and
3
+ // concrete, and state flips recompute rows AND chips.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { computeReachRows, reachChips, showEgressDisclosure } from './reachCard';
6
+ import type { ChatProviderState } from '@immediately-run/sdk';
7
+
8
+ const configured = (tools = true): ChatProviderState => ({
9
+ status: 'configured',
10
+ provider: { providerId: 'llm.chat.anthropic', hostVouched: true, features: { vision: false, tools, jsonMode: true, reasoning: false, maxContextTokens: 100000 } },
11
+ });
12
+
13
+ const row = (rows: ReturnType<typeof computeReachRows>, key: string) => rows.find((r) => r.key === key)!;
14
+
15
+ describe('G-GA-10 — the Q&A row renders the provider three-state honestly', () => {
16
+ it('unknown renders NEUTRAL — no cause, no connect copy (the R3-300 rule)', () => {
17
+ const rows = computeReachRows({ providerState: { status: 'unknown' }, chatGranted: true, writable: true, sourceShared: true });
18
+ expect(row(rows, 'answer').state).toBe('neutral');
19
+ expect(row(rows, 'answer').cause).toBeUndefined();
20
+ });
21
+
22
+ it('not-configured names the KEY cause; ungranted names the CONSENT cause — never conflated', () => {
23
+ const noKey = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
24
+ expect(row(noKey, 'answer').state).toBe('blocked');
25
+ expect(row(noKey, 'answer').cause).toContain('add one in Settings');
26
+
27
+ const forbidden = computeReachRows({ providerState: configured(), chatGranted: false, writable: true, sourceShared: false });
28
+ expect(row(forbidden, 'answer').state).toBe('blocked');
29
+ expect(row(forbidden, 'answer').cause).toContain("wasn't granted chat");
30
+ expect(row(forbidden, 'answer').cause).not.toContain('Settings');
31
+ });
32
+
33
+ it('configured + granted is ✓ and carries the read-flavored chips', () => {
34
+ const ok = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
35
+ expect(row(ok, 'answer').state).toBe('ok');
36
+ expect(row(ok, 'answer').chips).toBeDefined();
37
+ });
38
+ });
39
+
40
+ describe('G-GA-1 — the read/draft/apply rows derive from writability, never copy', () => {
41
+ it('read-only blocks Draft with the reader cause and yields NO write chip', () => {
42
+ const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
43
+ expect(row(rows, 'draft').state).toBe('blocked');
44
+ expect(row(rows, 'draft').cause).toContain('reader');
45
+ const chips = reachChips(rows);
46
+ expect(chips.some((c) => /add an entry|fix broken|reorganize|timeline|suggest an edit/i.test(c))).toBe(false);
47
+ });
48
+
49
+ it('writable turns Draft ✓ — and its chip is a DESCRIBE, not an apply', () => {
50
+ const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
51
+ expect(row(rows, 'draft').state).toBe('ok');
52
+ expect((row(rows, 'draft').chips ?? []).join(' ')).toMatch(/suggest an edit/i);
53
+ });
54
+
55
+ it('Apply is NEVER ok from this panel (R-GA-3) — the cause says where changes go', () => {
56
+ for (const writable of [true, false]) {
57
+ const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable, sourceShared: false });
58
+ expect(row(rows, 'apply').state).toBe('blocked');
59
+ expect(row(rows, 'apply').cause).toContain('editor / workbench');
60
+ }
61
+ });
62
+
63
+ it('the shared-source sentence rides the Apply cause only when trust says others can write', () => {
64
+ const shared = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: true });
65
+ expect(row(shared, 'apply').cause).toContain('treated as if others can write');
66
+ const solo = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
67
+ expect(row(solo, 'apply').cause).not.toContain('others can write');
68
+ });
69
+ });
70
+
71
+ describe('G-GA-6 — envelope flips recompute rows and chips together', () => {
72
+ it('disconnecting the provider flips answer ✓→blocked and drops its chips', () => {
73
+ const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
74
+ const after = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
75
+ expect(row(before, 'answer').state).toBe('ok');
76
+ expect(row(after, 'answer').state).toBe('blocked');
77
+ expect(reachChips(after)).not.toContain('Summarize this entry');
78
+ });
79
+
80
+ it('revoking write flips draft ✓→blocked on the next computation', () => {
81
+ const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
82
+ const after = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
83
+ expect(row(before, 'draft').state).toBe('ok');
84
+ expect(row(after, 'draft').state).toBe('blocked');
85
+ });
86
+ });
87
+
88
+ describe('R-GA-6 — the egress disclosure is unconditional when a provider is bound', () => {
89
+ it('shows for configured, not for unknown or not-configured, whatever the trust mode', () => {
90
+ expect(showEgressDisclosure(configured())).toBe(true);
91
+ expect(showEgressDisclosure({ status: 'unknown' })).toBe(false);
92
+ expect(showEgressDisclosure({ status: 'not-configured' })).toBe(false);
93
+ });
94
+ });
@@ -0,0 +1,112 @@
1
+ // The reach card (GROVE_AGENT_SPEC §6) — the agent's envelope, rendered as rows in
2
+ // the two-word vocabulary with a cause for every ✗ (R-SP-3). R-GA-1: every row is
3
+ // COMPUTED from the session's envelope (provider three-state, chat grant, mount
4
+ // writability, source trust); no capability claim on any pixel of this surface is
5
+ // hand-written copy. The four old banners collapse into these rows; chips render
6
+ // only for rows that are ✓ — derived, not curated.
7
+
8
+ import type { ChatProviderState } from '@immediately-run/sdk';
9
+
10
+ /** One reach-card row. `state: 'neutral'` renders neither ✓ nor ✗ — used only for
11
+ * the unknown provider state (rendering a cause there re-creates the false banner
12
+ * R3-300 fixed: `unknown` means unanswered, not ungranted). */
13
+ export interface ReachRow {
14
+ key: 'answer' | 'read' | 'draft' | 'apply';
15
+ label: string;
16
+ state: 'ok' | 'blocked' | 'neutral';
17
+ cause?: string;
18
+ /** Chips this row contributes when ✓ — the panel renders exactly these. */
19
+ chips?: string[];
20
+ }
21
+
22
+ export interface ReachInputs {
23
+ providerState: ChatProviderState;
24
+ /** Whether the grant-filtered catalog advertises `llm:chat` (the consented
25
+ * capability — absent on an ungranted fork, a distinct cause from "no key"). */
26
+ chatGranted: boolean;
27
+ writable: boolean;
28
+ /** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). */
29
+ sourceShared: boolean;
30
+ }
31
+
32
+ /** The rows, computed. Order is the card's display order. */
33
+ export function computeReachRows({ providerState, chatGranted, writable, sourceShared }: ReachInputs): ReachRow[] {
34
+ // Row 1 — Q&A. Three provider states × the grant, with the two NOT-causes never
35
+ // conflated (G-GA-10): "no key" is the user's to fix in Settings; "not granted"
36
+ // is this copy's consent state, and reading works either way.
37
+ let answer: ReachRow;
38
+ if (providerState.status === 'unknown') {
39
+ answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
40
+ } else if (providerState.status === 'not-configured') {
41
+ answer = {
42
+ key: 'answer',
43
+ label: 'Answer questions about this wiki',
44
+ state: 'blocked',
45
+ cause: 'no model key connected — add one in Settings',
46
+ };
47
+ } else if (!chatGranted) {
48
+ answer = {
49
+ key: 'answer',
50
+ label: 'Answer questions about this wiki',
51
+ state: 'blocked',
52
+ cause: "this Grove wasn't granted chat — reading works as normal",
53
+ };
54
+ } else {
55
+ answer = {
56
+ key: 'answer',
57
+ label: 'Answer questions about this wiki',
58
+ state: 'ok',
59
+ chips: ['Summarize this entry', 'What entries are tagged security?'],
60
+ };
61
+ }
62
+
63
+ // Row 2 — the body source. Both packagings define one post-S2 (the fork's own
64
+ // bundled corpus; the dispatched wiki mount), so this row is ✓ unless degenerate.
65
+ // It is computed, not assumed: the day a packaging lacks a source, the card says so.
66
+ const read: ReachRow = {
67
+ key: 'read',
68
+ label: 'Read entries and structure',
69
+ state: 'ok',
70
+ };
71
+
72
+ // Row 3 — drafting. Writability is the mount's answer; the chip proposes an edit
73
+ // the agent DESCRIBES (S4's host-confirmed diffs are not built — v1 drafts in chat).
74
+ const draft: ReachRow = writable
75
+ ? {
76
+ key: 'draft',
77
+ label: 'Draft changes',
78
+ state: 'ok',
79
+ chips: [`Suggest an edit for "${'this entry'}"`],
80
+ }
81
+ : { key: 'draft', label: 'Draft changes', state: 'blocked', cause: 'you’re a reader here' };
82
+
83
+ // Row 4 — applying. NEVER from this panel (R-GA-3): the widget renders content and
84
+ // is exactly the broker core_concepts §8a Axis D forbids. The cause is where
85
+ // changes go, plus the shared-source sentence when trust says others can write.
86
+ const apply: ReachRow = {
87
+ key: 'apply',
88
+ label: 'Apply changes',
89
+ state: 'blocked',
90
+ cause:
91
+ 'changes open in the editor / workbench, where you confirm them' +
92
+ (sourceShared ? ' — this repo is treated as if others can write (sole authorship can’t be verified yet), so agent actions go past you first' : ''),
93
+ };
94
+
95
+ return [answer, read, draft, apply];
96
+ }
97
+
98
+ /** The chips the panel shows: exactly the ✓ rows' chips, in card order (R-GA-1 —
99
+ * derived from the envelope, never curated). */
100
+ export function reachChips(rows: ReachRow[]): string[] {
101
+ return rows.flatMap((r) => (r.state === 'ok' ? r.chips ?? [] : []));
102
+ }
103
+
104
+ /** R-GA-6's unconditional egress line — shown whenever a provider is bound,
105
+ * whatever the wiki's trust mode: Q&A composes read + provider egress, and the
106
+ * confidentiality axis is grant-based, not sharedness-based. */
107
+ export const EGRESS_DISCLOSURE =
108
+ 'answers come from your connected model provider, which receives what the agent reads here';
109
+
110
+ export function showEgressDisclosure(providerState: ChatProviderState): boolean {
111
+ return providerState.status === 'configured';
112
+ }
package/src/lib/shell.ts CHANGED
@@ -41,6 +41,9 @@ export interface GroveShell {
41
41
  openEditor: (entryKey: string) => void;
42
42
  /** True while an editor is being summoned, for a busy label. */
43
43
  editBusy: boolean;
44
+ /** True when the host REFUSED the last edit request — render it where the
45
+ * affordance was offered (3.3.1, R3-608); a cancelled request never sets it. */
46
+ editRefused: boolean;
44
47
  /** What a save actually does, for the affordance's title — under dispatch it says that
45
48
  * proposing a change back to the content repo is not wired yet (R3-266's residual). */
46
49
  editHint: string;
@@ -65,6 +68,10 @@ export interface GroveShell {
65
68
  directory: DirectoryListing;
66
69
  }
67
70
 
71
+ /** The refusal sentence, ONE home (R6, R3-608): every surface that offers an edit
72
+ * renders this where the affordance was offered. */
73
+ export const EDIT_REFUSED_NOTICE = 'Could not open the editor — the host refused';
74
+
68
75
  export const GroveShellContext = createContext<GroveShell | null>(null);
69
76
 
70
77
  /** Read the shell context. Throws if used outside App's provider — which only
@@ -0,0 +1,160 @@
1
+ // The layout-starter gate (R3-309, bucket A) — every starter under
2
+ // `content/_layouts/` rendered through the REAL published safe renderer with
3
+ // Grove's own SAFE_MDX map, exactly what SafeLayout sends a `_layout.mdx` through
4
+ // in interpreter mode. Proven by fault injection on all quiet failure modes.
5
+ import { describe, it, expect } from 'vitest';
6
+ import { readFileSync, readdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { parseSafeMdast, renderMdast } from '@immediately-run/sdk/safeContent/index';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
11
+ import { SAFE_MDX } from '../mdxComponents';
12
+ import { GroveShellContext, OutletContext, type GroveShell } from './shell';
13
+ import {
14
+ starterViolations,
15
+ STARTER_MARKERS,
16
+ stripStarterFrontmatter,
17
+ } from './starterSweep';
18
+
19
+ const LAYOUTS = join(process.cwd(), 'content', '_layouts');
20
+
21
+ /** A complete-enough shell for the chrome primitives to render statically (they
22
+ * read state through useShell; a bare render throws in the provider check, which
23
+ * is correct — the sweep must run them the way the wiki does, with state). */
24
+ const SHELL: GroveShell = {
25
+ theme: 'default',
26
+ setTheme: () => {},
27
+ light: false,
28
+ setLight: () => {},
29
+ menuOpen: false,
30
+ setMenuOpen: () => {},
31
+ searchOpen: false,
32
+ setSearchOpen: () => {},
33
+ drawerOpen: false,
34
+ setDrawerOpen: () => {},
35
+ vw: 'desktop',
36
+ navMode: 'side',
37
+ writable: false,
38
+ openEditor: () => {},
39
+ editBusy: false,
40
+ editHint: '',
41
+ siteTitle: 'Grove',
42
+ safe: false,
43
+ navItems: [],
44
+ entryKey: '/app/content/index.mdx',
45
+ includePath: 'index.mdx',
46
+ layout: 'doc',
47
+ showRails: true,
48
+ mins: 0,
49
+ missing: false,
50
+ directory: { status: 'idle' },
51
+ } as unknown as GroveShell;
52
+
53
+ /** The real interpreter render, with the Outlet sentinel a starter's `<Outlet/>`
54
+ * renders — a static render has no inner page, so the sentinel is how presence
55
+ * is distinguished from a tag that quietly collapsed to nothing. The host
56
+ * navigation provider stands in for the sandbox, exactly as it would supply it. */
57
+ const NAV = {
58
+ outerHref: 'https://example.immediately.run/app/x',
59
+ navigationState: { sandboxPath: '/app/x' },
60
+ };
61
+
62
+ const renderBody = async (body: string): Promise<string> => {
63
+ const tree = await parseSafeMdast(body);
64
+ const el = renderMdast(tree, { components: SAFE_MDX as never });
65
+ return renderToStaticMarkup(
66
+ <TinkerableContext.Provider value={NAV as never}>
67
+ <GroveShellContext.Provider value={SHELL}>
68
+ <OutletContext.Provider value={<p>OUTLET-SENTINEL</p>}>{el as never}</OutletContext.Provider>
69
+ </GroveShellContext.Provider>
70
+ </TinkerableContext.Provider>,
71
+ );
72
+ };
73
+
74
+ const knownTags = new Set(Object.keys(SAFE_MDX) as string[]);
75
+
76
+ describe('the layout-starter sweep (R3-309 bucket A)', () => {
77
+ const starters = readdirSync(LAYOUTS).filter((f) => f.endsWith('.mdx'));
78
+
79
+ it('found the starters — a sweep that examines nothing defends nothing (checked > 0)', () => {
80
+ expect(starters.length).toBe(3);
81
+ expect(new Set(starters.map((f) => f.replace(/\.mdx$/, '')))).toEqual(
82
+ new Set(['shell', 'section', 'hero']),
83
+ );
84
+ });
85
+
86
+ it.each(starters)('%s survives the real safe renderer', async (file) => {
87
+ const src = readFileSync(join(LAYOUTS, file), 'utf8');
88
+ const violations = await starterViolations(src, { render: renderBody, knownTags });
89
+ expect(violations).toEqual([]);
90
+ // And the Outlet sentinel came through: the starter actually frames a page.
91
+ expect(await renderBody(stripStarterFrontmatter(src))).toContain('OUTLET-SENTINEL');
92
+ });
93
+
94
+ it('the hero starter selects the top arrangement and omits the sidebar', async () => {
95
+ // Exit criterion: a shell starter selecting `top` renders without the sidebar.
96
+ // The hero IS that starter — `nav: top` in frontmatter, no `<GroveSidebar/>`
97
+ // in the body.
98
+ const src = readFileSync(join(LAYOUTS, 'hero.mdx'), 'utf8');
99
+ expect(src).toMatch(/^nav:\s*top$/m);
100
+ expect(stripStarterFrontmatter(src)).not.toContain('<GroveSidebar');
101
+ const markup = await renderBody(stripStarterFrontmatter(src));
102
+ expect(markup).not.toContain('grove-sidebar');
103
+ });
104
+
105
+ // ── fault injection: each quiet failure mode, planted and caught ────────────
106
+ const GOOD = readFileSync(join(LAYOUTS, 'shell.mdx'), 'utf8');
107
+
108
+ it('baseline passes, so the injections below mean the PLANT', async () => {
109
+ expect(await starterViolations(GOOD, { render: renderBody, knownTags })).toEqual([]);
110
+ });
111
+
112
+ it('a planted raw-HTML block is caught (it renders as literal angle brackets, never markup)', async () => {
113
+ // The shape safeRender.test.ts proved: a raw `<script>` survives as ESCAPED TEXT.
114
+ // Starters carry no raw HTML, so ANY '<' in the visible text is this failure —
115
+ // and the script must not have executed, either.
116
+ const planted = stripStarterFrontmatter(GOOD).replace(
117
+ '<GroveFooter />',
118
+ '<script>globalThis.__pwnedByStarter = 1</script>',
119
+ );
120
+ const v = await starterViolations(planted, { render: renderBody, knownTags });
121
+ expect(v.some((x) => /angle brackets/.test(x))).toBe(true);
122
+ expect((globalThis as Record<string, unknown>).__pwnedByStarter).toBeUndefined();
123
+ });
124
+
125
+ it('a planted import line is caught (it renders as visible prose)', async () => {
126
+ const planted = stripStarterFrontmatter(GOOD).replace(
127
+ '<GroveNav />',
128
+ 'import GroveNav from "./components/GroveNav"\n\n<GroveNav />',
129
+ );
130
+ const v = await starterViolations(planted, { render: renderBody, knownTags });
131
+ expect(v.some((x) => /import\/export line/.test(x))).toBe(true);
132
+ });
133
+
134
+ it('a planted off-vocabulary wrapper is caught (it collapses to its children)', async () => {
135
+ const planted = stripStarterFrontmatter(GOOD).replace(
136
+ '<GroveFooter />',
137
+ '<NotARealWrapper className="x">\n\nTrapped text.\n\n</NotARealWrapper>',
138
+ );
139
+ const v = await starterViolations(planted, { render: renderBody, knownTags });
140
+ expect(v.some((x) => /not in the component map/.test(x))).toBe(true);
141
+ });
142
+
143
+ it('a planted JSX comment is caught — under the interpreter its braces are prose', async () => {
144
+ // The shipped sample layouts carry `{/* … */}` teaching comments, which the
145
+ // COMPILED path drops and the interpreter renders as literal text. Starters
146
+ // must not teach that: their teaching lives in frontmatter, which never renders.
147
+ const planted = stripStarterFrontmatter(GOOD).replace(
148
+ '<GroveNav />',
149
+ '{/* a teaching comment */}\n\n<GroveNav />',
150
+ );
151
+ const v = await starterViolations(planted, { render: renderBody, knownTags });
152
+ expect(v.some((x) => /braces render as visible text/.test(x))).toBe(true);
153
+ });
154
+
155
+ it('every marker the sweep keys on exists in the vocabulary it checks', () => {
156
+ for (const tag of Object.keys(STARTER_MARKERS)) {
157
+ expect(knownTags.has(tag)).toBe(true);
158
+ }
159
+ });
160
+ });