@immediately-run/grove 0.1.1 → 0.1.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.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @immediately-run/grove — the viewer kit for directory-as-content wikis
2
2
 
3
- > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.1)
3
+ > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.2)
4
4
 
5
5
  Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
6
6
  spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
@@ -94,6 +94,7 @@ is one corpus's conventions (declared by that corpus, not this repo).
94
94
  ## Frontmatter keys the engine reads
95
95
 
96
96
  - `site`
97
+ - `theme`
97
98
  - `layout`
98
99
  - `view`
99
100
  - `frame`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/grove",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -10,7 +10,16 @@
10
10
  "task": "open-wiki",
11
11
  "version": "1.0"
12
12
  }
13
- ]
13
+ ],
14
+ "invokes": [
15
+ {
16
+ "task": "edit-file",
17
+ "version": "^1"
18
+ }
19
+ ],
20
+ "requests": {
21
+ "task:invoke": {}
22
+ }
14
23
  },
15
24
  "scripts": {
16
25
  "dev": "vite",
@@ -18,7 +27,8 @@
18
27
  "lint": "eslint .",
19
28
  "test": "vitest run",
20
29
  "preview": "vite preview",
21
- "verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run lint && npm run build && npm run test",
30
+ "verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run check:theme-contrast && npm run lint && npm run build && npm run test",
31
+ "check:theme-contrast": "node scripts/check-theme-contrast.mjs --self-test && node scripts/check-theme-contrast.mjs",
22
32
  "check:manifest": "node scripts/check-manifest.mjs",
23
33
  "check:deps": "node scripts/check-app-dependencies.mjs --self-test && node scripts/check-app-dependencies.mjs",
24
34
  "check:engine-api": "node scripts/check-engine-api.mjs",
package/src/GroveApp.css CHANGED
@@ -67,6 +67,11 @@
67
67
  }
68
68
 
69
69
  /* —— alternate themes: identical DOM, CSS-only re-skin —— */
70
+ /* R3-308: every theme ships BOTH polarities. The bare [data-grove-theme] block is
71
+ the theme's PREFERRED polarity (the one it opens in absent other opinions); the
72
+ paired block with an explicit [data-theme] is the other one. GroveWiki always
73
+ emits data-theme, so a wiki follows the reader or the host into either polarity
74
+ of ANY theme — alternates are no longer single-polarity by construction. */
70
75
  .grove-root[data-grove-theme="pixies"] {
71
76
  --bg: #0c0410;
72
77
  --panel: #1a0a1e;
@@ -88,6 +93,27 @@
88
93
  --radius-shape: 8px;
89
94
  --wash: radial-gradient(70% 55% at 85% -5%, rgba(255, 45, 142, .2), transparent 60%), radial-gradient(55% 45% at 4% 2%, rgba(157, 41, 255, .16), transparent 60%);
90
95
  }
96
+ .grove-root[data-grove-theme="pixies"][data-theme="light"] {
97
+ --bg: #fdf6fb;
98
+ --panel: #ffffff;
99
+ --panel-2: #f9edf7;
100
+ --line: rgba(123, 47, 247, .18);
101
+ --line-2: rgba(123, 47, 247, .32);
102
+ --ink: #2c1030;
103
+ --ink-2: #6d3f76;
104
+ --ink-3: #7d5f84;
105
+ --accent: #d10f74;
106
+ --accent-2: #6b21c9;
107
+ --accent-3: #8a5a00;
108
+ --grad: linear-gradient(96deg, #ffe14d 0%, #ff2d8e 50%, #9d29ff 100%);
109
+ --glow: 0 0 0 1px rgba(209, 15, 116, .35), 0 8px 22px rgba(209, 15, 116, .18);
110
+ --accent-pink: #d10f74;
111
+ --accent-violet: #6b21c9;
112
+ --disp-weight: 900;
113
+ --prose-measure: 66ch;
114
+ --radius-shape: 8px;
115
+ --wash: radial-gradient(70% 55% at 85% -5%, rgba(209, 15, 116, .08), transparent 60%), radial-gradient(55% 45% at 4% 2%, rgba(107, 33, 201, .06), transparent 60%);
116
+ }
91
117
  .grove-root[data-grove-theme="family"] {
92
118
  --bg: #faf6f1;
93
119
  --panel: #fffdfa;
@@ -95,7 +121,7 @@
95
121
  --line: rgba(120, 90, 60, .16);
96
122
  --line-2: rgba(120, 90, 60, .28);
97
123
  --ink: #3b342c;
98
- --ink-2: #7c7062;
124
+ --ink-2: #6b5f51;
99
125
  --ink-3: #a89c8c;
100
126
  --accent: #c8744f;
101
127
  --accent-2: #9a8f5e;
@@ -109,6 +135,27 @@
109
135
  --radius-shape: 20px;
110
136
  --wash: radial-gradient(60% 50% at 82% -2%, rgba(224, 154, 106, .14), transparent 60%);
111
137
  }
138
+ .grove-root[data-grove-theme="family"][data-theme="dark"] {
139
+ --bg: #241d16;
140
+ --panel: #2d251c;
141
+ --panel-2: #372c21;
142
+ --line: rgba(224, 154, 106, .16);
143
+ --line-2: rgba(224, 154, 106, .3);
144
+ --ink: #f2e9de;
145
+ --ink-2: #c9b8a5;
146
+ --ink-3: #a2937f;
147
+ --accent: #e09a6a;
148
+ --accent-2: #c8a06a;
149
+ --accent-3: #d9b98a;
150
+ --grad: linear-gradient(96deg, #f3cf9a 0%, #e09a6a 50%, #c8744f 100%);
151
+ --glow: 0 0 0 1px rgba(224, 154, 106, .4), 0 10px 26px rgba(224, 154, 106, .2);
152
+ --accent-pink: #e09a6a;
153
+ --accent-violet: #c8a06a;
154
+ --disp-weight: 700;
155
+ --prose-measure: 64ch;
156
+ --radius-shape: 20px;
157
+ --wash: radial-gradient(60% 50% at 82% -2%, rgba(224, 154, 106, .1), transparent 60%);
158
+ }
112
159
  .grove-root[data-grove-theme="lotr"] {
113
160
  --bg: #ece2cc;
114
161
  --panel: #f4ebd6;
@@ -130,6 +177,27 @@
130
177
  --radius-shape: 4px;
131
178
  --wash: radial-gradient(70% 60% at 50% -5%, rgba(122, 90, 42, .1), transparent 65%);
132
179
  }
180
+ .grove-root[data-grove-theme="lotr"][data-theme="dark"] {
181
+ --bg: #1d1810;
182
+ --panel: #262014;
183
+ --panel-2: #302818;
184
+ --line: rgba(184, 154, 86, .18);
185
+ --line-2: rgba(184, 154, 86, .32);
186
+ --ink: #ede2c8;
187
+ --ink-2: #c4b48e;
188
+ --ink-3: #9a8a6c;
189
+ --accent: #c9a45c;
190
+ --accent-2: #8aa468;
191
+ --accent-3: #b08a48;
192
+ --grad: linear-gradient(96deg, #b89a56 0%, #8a6a36 50%, #4a5a38 100%);
193
+ --glow: 0 0 0 1px rgba(201, 164, 92, .4), 0 8px 20px rgba(201, 164, 92, .22);
194
+ --accent-pink: #c9a45c;
195
+ --accent-violet: #8aa468;
196
+ --disp-weight: 700;
197
+ --prose-measure: 72ch;
198
+ --radius-shape: 4px;
199
+ --wash: radial-gradient(70% 60% at 50% -5%, rgba(201, 164, 92, .08), transparent 65%);
200
+ }
133
201
 
134
202
  /* ============ SHELL ============ */
135
203
  .grove-root a {
package/src/GroveWiki.tsx CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  Include,
8
8
  useAllMetadata,
9
9
  useFileMetadata,
10
+ useHostTheme,
10
11
  useMetadataQuery,
11
- useMounts,
12
12
  } from '@immediately-run/sdk';
13
13
  import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
14
14
  import { LinkSpaceContext } from '@immediately-run/sdk/linkSpace';
@@ -27,8 +27,11 @@ import { navQuery } from './lib/queries';
27
27
  import type { NavRecord } from './lib/queries';
28
28
  import { layoutChainForKey } from './lib/layout';
29
29
  import { folderIndexKey } from './lib/directory';
30
+ import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
31
+ import { preferredPolarity } from './data/themes';
30
32
  import { useDirectoryListing } from './hooks/useDirectoryListing';
31
- import { getContentRoot, isDispatched } from './lib/contentRoot';
33
+ import { useEditAffordance } from './hooks/useEditAffordance';
34
+ import { getContentRoot } from './lib/contentRoot';
32
35
  import type { RejectedComponent } from './lib/corpusComponents';
33
36
  import { GroveShellContext, OutletContext } from './lib/shell';
34
37
  import type { GroveShell, NavItem } from './lib/shell';
@@ -114,10 +117,44 @@ export default function GroveWiki({
114
117
  }) {
115
118
  const ctx = useContext(TinkerableContext) as any;
116
119
  const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
117
- const mounts = useMounts() as any[];
118
120
 
119
- const [theme, setTheme] = useState(() => readPref('grove:theme') || 'default');
120
- const [light, setLight] = useState(() => readPref('grove:appearance') === 'light');
121
+ // ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
122
+ //
123
+ // Two INDEPENDENT axes with three sources, resolved through ONE module
124
+ // (lib/themeSelection) so no surface re-derives the precedence:
125
+ //
126
+ // palette = reader override, else the author's `theme:` on the home entry, else default
127
+ // polarity = reader override, else the host's theme, else the palette's preferred
128
+ //
129
+ // The stored prefs are OVERRIDES, nullable by nature: absent until the reader
130
+ // acts, which is what gives the author's declaration its turn. They are written
131
+ // by the user actions (chooseTheme/choosePolarity), NEVER by an effect on mount
132
+ // — the old effects promoted the initial value to a reader choice on first
133
+ // visit, which is exactly why a `theme:` declaration could never have won.
134
+ // (Visitors from before this change carry a mount-written pref; it stands —
135
+ // they are Grove readers, and a reader outranks an author.)
136
+ const [readerTheme, setReaderTheme] = useState<string | null>(() => readPref('grove:theme'));
137
+ const [readerAppearance, setReaderAppearance] = useState<Polarity | null>(() => {
138
+ const p = readPref('grove:appearance');
139
+ return p === 'light' || p === 'dark' ? p : null;
140
+ });
141
+ const chooseTheme = (id: string) => {
142
+ setReaderTheme(id);
143
+ writePref('grove:theme', id);
144
+ };
145
+ const choosePolarity = (wantLight: boolean) => {
146
+ const p: Polarity = wantLight ? 'light' : 'dark';
147
+ setReaderAppearance(p);
148
+ writePref('grove:appearance', p);
149
+ };
150
+ // The host drives POLARITY ONLY (`theme:read` is the one theme capability the
151
+ // open-wiki binding holds) — and only when there IS a host. `useHostTheme`'s
152
+ // channel reports an `initial: 'dark'` before any host speaks, so an unframed
153
+ // standalone `vite dev` render would otherwise carry a phantom host opinion
154
+ // and flip light-preferred themes. Framed === a host exists to have one.
155
+ const framed = typeof window !== 'undefined' && window.parent !== window;
156
+ const hostTheme = useHostTheme();
157
+ const hostPolarity: Polarity | null = framed ? hostTheme : null;
121
158
  const [menuOpen, setMenuOpen] = useState(false);
122
159
  const [searchOpen, setSearchOpen] = useState(false);
123
160
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -132,8 +169,6 @@ export default function GroveWiki({
132
169
  mq.addEventListener('change', on);
133
170
  return () => mq.removeEventListener('change', on);
134
171
  }, []);
135
- useEffect(() => writePref('grove:theme', theme), [theme]);
136
- useEffect(() => writePref('grove:appearance', light ? 'light' : 'dark'), [light]);
137
172
 
138
173
  // ⌘K / Ctrl-K opens search.
139
174
  useEffect(() => {
@@ -147,28 +182,37 @@ export default function GroveWiki({
147
182
  return () => window.removeEventListener('keydown', on);
148
183
  }, []);
149
184
 
150
- // ⚠ TEMPORARY, and not a design: dispatched content IS writable — R3-266.
151
- //
152
- // The affordance below calls `requestEdit`, which is **self-scoped by contract** ("v1
153
- // supports only a repo-relative path in the CURRENT repo … editing a file in one of your
154
- // mounts is the `edit-file` task, not this"). Under dispatch the corpus is a mount, so
155
- // that call would edit GROVE rather than the corpus on screen. Offering it would be
156
- // wrong; withholding it *as a design* is also wrong, and this comment exists so the next
157
- // reader does not conclude the second from the first.
185
+ // R3-266 — dispatched content IS writable, and the MOUNT decides.
158
186
  //
159
- // The fix is a verb swap, not a withheld capability: delegate the entry to
160
- // `invokeTask('edit-file', { file: capFile({ mountId, relPath }, { mode: 'rw' }) })`,
161
- // declare `invokes: edit-file`, and gate on the CORPUS MOUNT's mode rather than on the
162
- // packaging. A task callee already holds the minted delegated grant, so nothing new has
163
- // to be minted. Tracked in R3-266; `docs/specs/REPO_CONTENT_DISPATCH_SPEC.mdx` §5.
164
- const writable =
165
- !isDispatched() && !readOnly && (mounts?.some((m) => m.type === 'worktree' && m.mode !== 'ro') ?? false);
187
+ // This used to read `!isDispatched() && …`, withholding every edit affordance from a
188
+ // dispatched viewer. The reason was real but the conclusion was not: `requestEdit` is
189
+ // **self-scoped by contract**, so under dispatch it names a path in GROVE's repo rather
190
+ // than in the corpus on screen. The fix is a verb swap, not a withheld capability — see
191
+ // `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
192
+ // mount change so a live role downgrade hides the affordance instead of producing
193
+ // `EROFS` on click.
194
+ const { writable, busy: editBusy, openEditor, editHint } = useEditAffordance(readOnly);
166
195
 
167
196
  const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
168
197
  // The site brand is a wiki-wide constant, so read it from the home entry's
169
198
  // `site` frontmatter — not the current entry's (which only home would carry),
170
199
  // else the brand flips to the 'Grove' fallback on every sub-page.
171
200
  const homeMeta = useFileMetadata(homeKey()) as any;
201
+ // R3-308: the author's palette declaration — `theme:` on the home entry, the
202
+ // wiki-wide sibling of `site:`. Like `site`, it is read from HOME and not the
203
+ // current entry, so a sub-page never flips the wiki's look back to `default`.
204
+ const authorTheme: string | null =
205
+ typeof homeMeta?.theme === 'string' && homeMeta.theme ? homeMeta.theme : null;
206
+ // The two axes, resolved through the one module that owns the precedence. `theme`
207
+ // and `light` below are the RESOLVED values every surface renders from — the raw
208
+ // reader overrides live only in state and in the menu handlers.
209
+ const theme = resolvePalette({ reader: readerTheme, author: authorTheme });
210
+ const polarity: Polarity = resolvePolarity({
211
+ reader: readerAppearance,
212
+ host: hostPolarity,
213
+ preferred: preferredPolarity(theme),
214
+ });
215
+ const light = polarity === 'light';
172
216
  // Existence / 404: the whole index tells us if a followed link is dead. Layout
173
217
  // files are structure, not entries, so they're excluded here (and everywhere).
174
218
  const allKeysQuery = useCallback((fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry), []);
@@ -257,9 +301,9 @@ export default function GroveWiki({
257
301
 
258
302
  const shell: GroveShell = {
259
303
  theme,
260
- setTheme,
304
+ setTheme: chooseTheme,
261
305
  light,
262
- setLight,
306
+ setLight: choosePolarity,
263
307
  menuOpen,
264
308
  setMenuOpen,
265
309
  searchOpen,
@@ -269,6 +313,9 @@ export default function GroveWiki({
269
313
  vw,
270
314
  navMode,
271
315
  writable,
316
+ openEditor,
317
+ editBusy,
318
+ editHint,
272
319
  siteTitle,
273
320
  safe,
274
321
  navItems,
@@ -336,7 +383,7 @@ export default function GroveWiki({
336
383
  data-vw={vw}
337
384
  data-nav={navMode}
338
385
  data-grove-theme={theme === 'default' ? undefined : theme}
339
- data-theme={theme === 'default' && light ? 'light' : undefined}
386
+ data-theme={polarity}
340
387
  >
341
388
  <div className="device__scroll">
342
389
  {rejectedComponents.length > 0 ? (
@@ -1,7 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useState } from 'react';
3
- import { requestEdit, useFileMetadata } from '@immediately-run/sdk';
4
- import { keyToRepoRel } from '../lib/content';
2
+ import { useFileMetadata } from '@immediately-run/sdk';
3
+ import { useShell } from '../lib/shell';
5
4
  import { crumb } from '../lib/wiki';
6
5
  import Icon from './Icon';
7
6
 
@@ -17,17 +16,11 @@ export default function EntryHeader({
17
16
  writable: boolean;
18
17
  mins: number;
19
18
  }) {
19
+ const { openEditor, editBusy, editHint } = useShell();
20
20
  const meta = useFileMetadata(entryKey) as any;
21
- const [busy, setBusy] = useState(false);
22
21
  if (!meta) return null;
23
22
  const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
24
23
  const cr = crumb(entryKey);
25
- const edit = () => {
26
- setBusy(true);
27
- requestEdit({ path: keyToRepoRel(entryKey) })
28
- .catch(() => undefined)
29
- .finally(() => setBusy(false));
30
- };
31
24
  return (
32
25
  <header className="grove-entry-header">
33
26
  {cr.includes('/') ? <nav className="crumb">{cr}</nav> : null}
@@ -40,9 +33,14 @@ export default function EntryHeader({
40
33
  <span key={t} className="grove-tag">#{t}</span>
41
34
  ))}
42
35
  {writable && (
43
- <button className="grove-edit-affordance" data-busy={busy ? '1' : '0'} onClick={edit}>
36
+ <button
37
+ className="grove-edit-affordance"
38
+ data-busy={editBusy ? '1' : '0'}
39
+ title={editHint}
40
+ onClick={() => openEditor(entryKey)}
41
+ >
44
42
  <Icon name="pencil" />
45
- {busy ? 'Opening editor…' : 'Edit'}
43
+ {editBusy ? 'Opening editor…' : 'Edit'}
46
44
  </button>
47
45
  )}
48
46
  </div>
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { chat, requestEdit, useChatProvider } from '@immediately-run/sdk';
4
- import { keyToRepoRel } from '../lib/content';
3
+ import { chat, useChatProvider } from '@immediately-run/sdk';
4
+ import { useShell } from '../lib/shell';
5
5
  import Icon from './Icon';
6
6
 
7
7
  interface Msg {
@@ -27,6 +27,7 @@ const CHIPS = [
27
27
  // banners rather than faking a host surface.
28
28
  export default function GroveAgent({ writable, entryKey, entryTitle }: { writable: boolean; entryKey: string; entryTitle: string }) {
29
29
  const provider = useChatProvider();
30
+ const { openEditor } = useShell();
30
31
  const [open, setOpen] = useState(false);
31
32
  const [detent, setDetent] = useState<'half' | 'full'>('half');
32
33
  const [resting, setResting] = useState('');
@@ -212,7 +213,7 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
212
213
  <div className="ga-foot__hand">
213
214
  <span>Grove's own agent · scoped to your grants</span>
214
215
  <a
215
- onClick={() => requestEdit({ path: keyToRepoRel(entryKey) }).catch(() => undefined)}
216
+ onClick={() => openEditor(entryKey)}
216
217
  role="button"
217
218
  tabIndex={0}
218
219
  >
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ // The appearance control is offered for EVERY theme (R3-308) — the bug this pins
3
+ // is "single-polarity by construction": the control used to render only when
4
+ // `theme === 'default'`, which is precisely how the alternates stayed
5
+ // light/dark-or-nothing. Rendered for a NON-default theme through the real
6
+ // component, so the gate cannot quietly come back.
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { act } from 'react';
9
+ import { createRoot } from 'react-dom/client';
10
+ import { GroveShellContext, type GroveShell } from '../lib/shell';
11
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
12
+
13
+ const { default: GroveNav } = await import('./GroveNav');
14
+
15
+ // The SDK's <Link> resolves hrefs against the host navigation state; without a
16
+ // provider `outerHref` is undefined and URL construction throws before any
17
+ // assertion runs. A minimal provider stands in for the host, exactly as the
18
+ // sandbox would supply it.
19
+ const NAV = {
20
+ outerHref: 'https://example.immediately.run/app/x',
21
+ navigationState: { sandboxPath: '/app/x' },
22
+ };
23
+
24
+ const mount = async (shell: Partial<GroveShell>) => {
25
+ const host = document.createElement('div');
26
+ document.body.appendChild(host);
27
+ const full: GroveShell = {
28
+ theme: 'default',
29
+ setTheme: vi.fn(),
30
+ light: false,
31
+ setLight: vi.fn(),
32
+ menuOpen: false,
33
+ setMenuOpen: vi.fn(),
34
+ searchOpen: false,
35
+ setSearchOpen: vi.fn(),
36
+ drawerOpen: false,
37
+ setDrawerOpen: vi.fn(),
38
+ vw: 'desktop',
39
+ navMode: 'top',
40
+ writable: false,
41
+ openEditor: vi.fn(),
42
+ editBusy: false,
43
+ editHint: '',
44
+ siteTitle: 'Grove',
45
+ safe: false,
46
+ navItems: [{ key: 'a', href: '/a', label: 'A' }],
47
+ entryKey: '/a',
48
+ includePath: 'a.mdx',
49
+ layout: 'doc',
50
+ showRails: false,
51
+ mins: 0,
52
+ missing: false,
53
+ directory: { status: 'idle' },
54
+ ...shell,
55
+ } as unknown as GroveShell;
56
+ await act(async () => {
57
+ createRoot(host).render(
58
+ <TinkerableContext.Provider value={NAV as never}>
59
+ <GroveShellContext.Provider value={full}>
60
+ <GroveNav />
61
+ </GroveShellContext.Provider>
62
+ </TinkerableContext.Provider>,
63
+ );
64
+ });
65
+ return host;
66
+ };
67
+
68
+ describe('the theme menu (R3-308 — two independent axes)', () => {
69
+ it('offers the appearance control for a NON-default theme', async () => {
70
+ const host = await mount({ theme: 'pixies', menuOpen: true, light: false });
71
+ const seg = host.querySelector('.gtm__seg');
72
+ expect(seg).not.toBeNull();
73
+ expect(seg!.querySelectorAll('button')).toHaveLength(2);
74
+ });
75
+
76
+ it('marks the RESOLVED polarity, not only a reader override', async () => {
77
+ // `light` is the resolved value the shell hands down (lib/themeSelection) —
78
+ // the control must reflect whatever the resolution produced, including the
79
+ // host-driven or preferred cases where no reader override exists.
80
+ const host = await mount({ theme: 'family', menuOpen: true, light: true });
81
+ const on = [...host.querySelectorAll('.gtm__seg button')].find((b) => b.getAttribute('data-on') === '1');
82
+ expect(on?.textContent).toMatch(/Light/);
83
+ });
84
+
85
+ it('lists every catalogue theme — the menu is how a reader reaches them', async () => {
86
+ const host = await mount({ menuOpen: true });
87
+ expect(host.querySelectorAll('.gtm__row').length).toBeGreaterThan(1);
88
+ });
89
+ });
@@ -1,5 +1,6 @@
1
- import { Link, requestEdit } from '@immediately-run/sdk';
1
+ import { Link } from '@immediately-run/sdk';
2
2
  import { useShell } from '../lib/shell';
3
+ import { getContentRoot } from '../lib/contentRoot';
3
4
  import { THEMES } from '../data/themes';
4
5
  import Icon from './Icon';
5
6
 
@@ -12,6 +13,7 @@ export default function GroveNav() {
12
13
  navItems,
13
14
  entryKey,
14
15
  writable,
16
+ openEditor,
15
17
  theme,
16
18
  setTheme,
17
19
  light,
@@ -26,7 +28,10 @@ export default function GroveNav() {
26
28
  const el = (document.querySelector('.ga-foot input') || document.querySelector('.ga-line input')) as HTMLElement | null;
27
29
  el?.focus();
28
30
  };
29
- const newEntry = () => requestEdit({ path: 'content/untitled.mdx' }).catch(() => undefined);
31
+ // The new entry belongs to whichever corpus is mounted, so it is named from the
32
+ // content ROOT rather than the fork's `content/` literal — under dispatch the latter
33
+ // would create a file in Grove's own repo (R3-266).
34
+ const newEntry = () => openEditor(`${getContentRoot()}untitled.mdx`);
30
35
 
31
36
  return (
32
37
  <nav className="grove-nav">
@@ -79,19 +84,20 @@ export default function GroveNav() {
79
84
  </button>
80
85
  ))}
81
86
  </div>
82
- {theme === 'default' ? (
83
- <div className="gtm__appearance">
84
- <div className="gtm__sub">Appearance</div>
85
- <div className="gtm__seg">
86
- <button data-on={!light ? '1' : '0'} onClick={() => setLight(false)}>
87
- <Icon name="moon" /> Dark
88
- </button>
89
- <button data-on={light ? '1' : '0'} onClick={() => setLight(true)}>
90
- <Icon name="sun" /> Light
91
- </button>
92
- </div>
87
+ {/* R3-308: the appearance control is offered for EVERY theme — each
88
+ catalogue entry ships both polarities, so gating it on
89
+ `default` would be single-polarity by construction again. */}
90
+ <div className="gtm__appearance">
91
+ <div className="gtm__sub">Appearance</div>
92
+ <div className="gtm__seg">
93
+ <button data-on={!light ? '1' : '0'} onClick={() => setLight(false)}>
94
+ <Icon name="moon" /> Dark
95
+ </button>
96
+ <button data-on={light ? '1' : '0'} onClick={() => setLight(true)}>
97
+ <Icon name="sun" /> Light
98
+ </button>
93
99
  </div>
94
- ) : null}
100
+ </div>
95
101
  </div>
96
102
  </>
97
103
  ) : null}
@@ -3,7 +3,6 @@ import { Include, Link } from '@immediately-run/sdk';
3
3
  import { useShell } from '../lib/shell';
4
4
  import { keyToHref, keyToRepoRel } from '../lib/content';
5
5
  import { crumb } from '../lib/wiki';
6
- import { requestEdit } from '@immediately-run/sdk';
7
6
  import DirectoryView from './DirectoryView';
8
7
  import EntryHeader from './EntryHeader';
9
8
  import SafeEntryBody from './SafeEntryBody';
@@ -20,7 +19,8 @@ declare const module: any;
20
19
  // site chrome (nav / sidebar / footer) — that's the layout's job — so the page
21
20
  // stays free of shell concerns.
22
21
  export default function PageView() {
23
- const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, vw, safe, directory } = useShell();
22
+ const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, vw, safe, directory } =
23
+ useShell();
24
24
 
25
25
  // A folder URL. `checking` renders nothing rather than the 404: the readdir that
26
26
  // decides between them is one RPC away, and a 404 that appears and then turns into a
@@ -39,7 +39,7 @@ export default function PageView() {
39
39
  </p>
40
40
  <div className="grove-state__actions">
41
41
  <Link className="btn-ghost" href="/"><Icon name="chevron-right" /> Back to home</Link>
42
- {writable ? <button className="btn-primary" onClick={() => requestEdit({ path: keyToRepoRel(entryKey) }).catch(() => undefined)}><Icon name="file-plus" /> Create it</button> : null}
42
+ {writable ? <button className="btn-primary" onClick={() => openEditor(entryKey)}><Icon name="file-plus" /> Create it</button> : null}
43
43
  </div>
44
44
  </div>
45
45
  );
@@ -1,14 +1,45 @@
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
+ //
4
+ // R3-308: every theme declares BOTH polarities (its CSS block pair) and a
5
+ // `preferred` one — the polarity a wiki opens in when neither the reader nor the
6
+ // host has said otherwise (02-theme-contract §4). The catalogue ids must match
7
+ // the `[data-grove-theme="…"]` selectors in GroveApp.css; the contrast check in
8
+ // `scripts/check-theme-contrast.mjs` reads both and fails if they drift apart.
9
+ import type { Polarity } from '../lib/themeSelection';
10
+
3
11
  export interface Theme {
4
12
  id: string;
5
13
  label: string;
6
14
  swatch: string;
15
+ /** The polarity this theme opens in absent a reader/host opinion. */
16
+ preferred: Polarity;
7
17
  }
8
18
 
9
19
  export const THEMES: Theme[] = [
10
- { id: 'default', label: 'immediately.run', swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)' },
11
- { id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)' },
12
- { id: 'family', label: 'Family journal', swatch: 'linear-gradient(96deg,#f3cf9a,#e09a6a 50%,#c8744f)' },
13
- { id: 'lotr', label: 'Middle-earth', swatch: 'linear-gradient(96deg,#b89a56,#8a6a36 50%,#4a5a38)' },
20
+ {
21
+ id: 'default',
22
+ label: 'immediately.run',
23
+ swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)',
24
+ preferred: 'dark',
25
+ },
26
+ { id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)', preferred: 'dark' },
27
+ {
28
+ id: 'family',
29
+ label: 'Family journal',
30
+ swatch: 'linear-gradient(96deg,#f3cf9a,#e09a6a 50%,#c8744f)',
31
+ preferred: 'light',
32
+ },
33
+ {
34
+ id: 'lotr',
35
+ label: 'Middle-earth',
36
+ swatch: 'linear-gradient(96deg,#b89a56,#8a6a36 50%,#4a5a38)',
37
+ preferred: 'light',
38
+ },
14
39
  ];
40
+
41
+ /** The preferred polarity of a palette id — unknown ids fall back to dark, the
42
+ * long-standing Grove default, rather than throwing in a render path. */
43
+ export function preferredPolarity(id: string): Polarity {
44
+ return THEMES.find((t) => t.id === id)?.preferred ?? 'dark';
45
+ }
package/src/devfs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- // Pull in the `fs` module types provided by @immediately-run/dev-fs, so app
2
- // code can `import fs from 'fs'` and type-check against the async-only surface
3
- // immediately.run exposes. See https://github.com/immediately-run/dev-fs
4
- /// <reference types="@immediately-run/dev-fs/fs" />
1
+ // The ambient `fs` + `module` types the immediately.run SANDBOX provides to app
2
+ // code — declared by the package that owns the surface: `@immediately-run/sdk`
3
+ // (R3-276b moved them there from `@immediately-run/dev-fs`, whose job is the local
4
+ // `vite dev` disk bridge, not the contract). One line, complete from sdk 0.49.0.
5
+ /// <reference types="@immediately-run/sdk/ambient" />
@@ -0,0 +1,86 @@
1
+ // R3-266 — the one place Grove decides whether to offer an edit, and how to deliver it.
2
+ //
3
+ // Every edit affordance in the wiki (the entry header's pencil, the 404's "Create it",
4
+ // the agent panel's link, the nav's "New entry") used to call `requestEdit` directly with
5
+ // a repo-relative path. That is correct for a FORK and wrong under DISPATCH, where it
6
+ // names a path in Grove's own repo rather than in the corpus on screen — so the affordance
7
+ // was withheld entirely and the wiki became read-only for the one packaging where the
8
+ // content is most obviously somebody's to edit.
9
+ //
10
+ // The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the LIVE
11
+ // mount list so a role downgrade hides the affordance on the next render rather than
12
+ // producing `EROFS` on click, and it hands back one `openEditor(entryKey)` the chrome
13
+ // calls without knowing which packaging it is in.
14
+ import { useCallback, useMemo, useState } from 'react';
15
+ import { capFile, invokeTask, requestEdit, useMounts } from '@immediately-run/sdk';
16
+ import { getContentRoot, getCorpusMountId, isDispatched } from '../lib/contentRoot';
17
+ import { corpusWritable, editTarget } from '../lib/editTarget';
18
+
19
+ export interface EditAffordance {
20
+ /** Whether to render an edit affordance at all — the MOUNT's answer, live. */
21
+ writable: boolean;
22
+ /** True while an editor is being summoned (for a busy label). */
23
+ busy: boolean;
24
+ /** Open `entryKey` in the platform editor. Never throws; a refusal is a no-op. */
25
+ openEditor: (entryKey: string) => void;
26
+ /**
27
+ * What a save actually does, so the affordance can say so.
28
+ *
29
+ * **A stated residual (2026-08-27, R3-266).** The CoW overlay and the contribute (PR)
30
+ * flow are anchored on the APP's repo. Under a fork the app and the corpus are one
31
+ * repo, so "save" and "propose a change" are one story. Under dispatch they are two:
32
+ * the write lands in the corpus mount correctly, and *"open a PR against the content
33
+ * repo"* has no wired target. That is real remaining work — and it is not a reason to
34
+ * withhold editing, because a viewer that saves but cannot yet propose is strictly
35
+ * better than one that refuses to save. It IS a reason not to imply otherwise, so the
36
+ * chrome labels the dispatched case for what it is.
37
+ */
38
+ editHint: string;
39
+ }
40
+
41
+ export function useEditAffordance(readOnly: boolean): EditAffordance {
42
+ const mounts = useMounts();
43
+ const [busy, setBusy] = useState(false);
44
+
45
+ // Read the corpus identity through the mount list's identity, so the memo re-runs when
46
+ // the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
47
+ // the MODE is not, and that is the half this hook exists to keep current.
48
+ const corpus = useMemo(
49
+ () => ({ dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() }),
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ [mounts],
52
+ );
53
+
54
+ const writable = !readOnly && corpusWritable(mounts, corpus);
55
+
56
+ const openEditor = useCallback(
57
+ (entryKey: string) => {
58
+ const target = editTarget(entryKey, corpus);
59
+ if (!target) return;
60
+ setBusy(true);
61
+ const done = () => setBusy(false);
62
+ if (target.via === 'self') {
63
+ // The fork: the present→edit transition on our own source. Self-scoped by
64
+ // contract, which is exactly right when the corpus IS our repo.
65
+ requestEdit({ path: target.path }).catch(() => undefined).finally(done);
66
+ return;
67
+ }
68
+ // Dispatch: attenuate the corpus delegation down to this one file and hand it to
69
+ // the platform editor. Nothing new is minted — we already hold the directory, and
70
+ // `edit-file` is one hop further along a chain §5.7.1 bounds at depth 4. The host
71
+ // resolves the cap against OUR grants, so this can only ever narrow.
72
+ invokeTask('edit-file', {
73
+ file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
74
+ })
75
+ .catch(() => undefined) // `cancelled` is how a reader closes the editor
76
+ .finally(done);
77
+ },
78
+ [corpus],
79
+ );
80
+
81
+ const editHint = corpus.dispatched
82
+ ? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
83
+ : 'Edit this entry';
84
+
85
+ return { writable, busy, openEditor, editHint };
86
+ }
@@ -44,7 +44,7 @@ export function useOpenWikiBoot(): OpenWikiBoot {
44
44
  // effect would run AFTER the first content render, which is the whole failure this
45
45
  // gate exists to prevent. Idempotent and purely derived, so a StrictMode double
46
46
  // render sets the same value twice.
47
- setContentRoot(resolution.root, { readOnly: resolution.readOnly });
47
+ setContentRoot(resolution.root, { readOnly: resolution.readOnly, mountId: resolution.mountId });
48
48
  }
49
49
 
50
50
  // The module IS the latch: once a root is set, the delegation is final for the life of
@@ -24,6 +24,7 @@ export const APP_CONTENT_ROOT = '/app/content/';
24
24
 
25
25
  let root: string = APP_CONTENT_ROOT;
26
26
  let readOnly = false;
27
+ let mountId: string | null = null;
27
28
 
28
29
  /** Where this instance's corpus lives, with a trailing slash. Read at CALL time. */
29
30
  export function getContentRoot(): string {
@@ -35,10 +36,23 @@ export function getContentRoot(): string {
35
36
  * with the delegated directory, or not at all (the fork, which keeps the default).
36
37
  * Normalizes the trailing slash so every `startsWith`/`slice` in the helpers holds.
37
38
  */
38
- export function setContentRoot(dir: string, opts: { readOnly?: boolean } = {}): void {
39
+ export function setContentRoot(dir: string, opts: { readOnly?: boolean; mountId?: string | null } = {}): void {
39
40
  if (!dir) return;
40
41
  root = dir.endsWith('/') ? dir : `${dir}/`;
41
42
  readOnly = opts.readOnly ?? false;
43
+ mountId = opts.mountId ?? null;
44
+ }
45
+
46
+ /**
47
+ * The mount id of the corpus, or null for a fork (whose corpus is its own repo, not a
48
+ * mount). R3-266: this is what an onward delegation NAMES — `capFile({ mountId, relPath })`
49
+ * — when Grove hands a content file to the platform editor. It lives here with the root
50
+ * for the same reason the read-only flag does: it is the same fact, decided once by the
51
+ * same delegation, and every consumer that asks "may I offer an edit, and of what?"
52
+ * already reads the root.
53
+ */
54
+ export function getCorpusMountId(): string | null {
55
+ return mountId;
42
56
  }
43
57
 
44
58
  /** Whether the mounted corpus was delegated read-only. Lives here rather than in React
@@ -58,4 +72,5 @@ export function isDispatched(): boolean {
58
72
  export function resetContentRoot(): void {
59
73
  root = APP_CONTENT_ROOT;
60
74
  readOnly = false;
75
+ mountId = null;
61
76
  }
@@ -0,0 +1,108 @@
1
+ // R3-266 — dispatched content is writable, and the MOUNT decides.
2
+ //
3
+ // The two things these tests pin are the two things that were wrong before: a dispatched
4
+ // viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
5
+ // offer one at all must be the corpus mount's CURRENT mode rather than a property of the
6
+ // packaging or a flag latched at boot.
7
+ import { describe, expect, it } from 'vitest';
8
+ import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
9
+ import type { CorpusIdentity } from './editTarget';
10
+ import type { SandboxMount } from '@immediately-run/sdk/mounts';
11
+
12
+ const fork: CorpusIdentity = {
13
+ dispatched: false,
14
+ contentRoot: '/app/content/',
15
+ mountId: null,
16
+ };
17
+ const dispatched: CorpusIdentity = {
18
+ dispatched: true,
19
+ contentRoot: '/task/t1/dir/',
20
+ mountId: '/task/t1/dir',
21
+ };
22
+
23
+ const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
24
+ ({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
25
+
26
+ describe('editTarget — the verb follows the authority, not the packaging', () => {
27
+ it('a FORK edits its own source through the self-scoped present→edit transition', () => {
28
+ expect(editTarget('/app/content/handbook/onboarding.mdx', fork)).toEqual({
29
+ via: 'self',
30
+ path: 'content/handbook/onboarding.mdx',
31
+ });
32
+ });
33
+
34
+ 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({
36
+ via: 'delegate',
37
+ mountId: '/task/t1/dir',
38
+ relPath: 'plot/the-rail.mdx',
39
+ });
40
+ });
41
+
42
+ it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
43
+ const t = editTarget('/task/t1/dir/home.mdx', dispatched);
44
+ expect(t).toMatchObject({ relPath: 'home.mdx' });
45
+ // The fork's `content/` segment must NOT leak into a corpus-relative path: the
46
+ // delegated chroot is minted AT the content directory.
47
+ expect((t as { relPath: string }).relPath.startsWith('content/')).toBe(false);
48
+ });
49
+
50
+ it('offers nothing for a key outside the mounted corpus (a leftover from the viewer)', () => {
51
+ expect(editTarget('/app/content/home.mdx', dispatched)).toBeNull();
52
+ });
53
+
54
+ 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();
56
+ });
57
+
58
+ it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
59
+ expect(editTarget('/task/t1/dir/', dispatched)).toBeNull();
60
+ });
61
+
62
+ it('never throws on a junk key', () => {
63
+ expect(editTarget('', dispatched)).toBeNull();
64
+ expect(editTarget(undefined as unknown as string, fork)).toBeNull();
65
+ });
66
+
67
+ it('keyToSelfPath strips the app anchor exactly as the fork URLs require', () => {
68
+ expect(keyToSelfPath('/app/content/x.mdx')).toBe('content/x.mdx');
69
+ expect(keyToSelfPath('/content/x.mdx')).toBe('content/x.mdx');
70
+ });
71
+ });
72
+
73
+ describe('corpusWritable — the mount decides, live', () => {
74
+ it('a fork asks about its working tree, as before', () => {
75
+ expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'rw' } as SandboxMount], fork)).toBe(true);
76
+ expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'ro' } as SandboxMount], fork)).toBe(false);
77
+ expect(corpusWritable([], fork)).toBe(false);
78
+ });
79
+
80
+ it('a DISPATCHED viewer on an rw corpus is writable — packaging is not trust', () => {
81
+ expect(corpusWritable([mount()], dispatched)).toBe(true);
82
+ });
83
+
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);
86
+ });
87
+
88
+ it('follows a LIVE downgrade: the same mount re-announced ro flips the answer', () => {
89
+ expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
90
+ expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
91
+ });
92
+
93
+ it('a corpus mount that has vanished is not writable', () => {
94
+ expect(corpusWritable([mount({ id: 'space:other', path: '/mnt/x' })], dispatched)).toBe(false);
95
+ expect(corpusWritable([], dispatched)).toBe(false);
96
+ expect(corpusWritable(null, dispatched)).toBe(false);
97
+ });
98
+
99
+ it('matches a mount that carries no id by its path (what the host publishes)', () => {
100
+ expect(corpusWritable([{ type: 'firestore', path: '/task/t1/dir', mode: 'rw' } as SandboxMount], dispatched)).toBe(
101
+ true,
102
+ );
103
+ });
104
+
105
+ it('never reports writable when there is no mount id at all', () => {
106
+ expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,93 @@
1
+ // R3-266 — WHERE an edit goes, and whether one may be offered at all.
2
+ //
3
+ // Grove ships in two packagings, and the edit verb differs between them because the
4
+ // AUTHORITY does, not because dispatched content is somehow less editable:
5
+ //
6
+ // • FORK — the corpus is this app's own repo, so "edit this entry" is the
7
+ // present→edit transition on our own source: `requestEdit({ path })`,
8
+ // which is **self-scoped by contract** ("v1 supports only a repo-relative
9
+ // path in the CURRENT repo").
10
+ // • DISPATCH — the corpus is a MOUNT somebody handed us. `requestEdit` there would
11
+ // name a path in GROVE's repo, so the same call would offer to edit the
12
+ // viewer instead of the wiki on screen. The right verb is the one for a
13
+ // file in a mount: `invokeTask('edit-file', { file: capFile(...) })`,
14
+ // attenuating the corpus delegation down to the single entry.
15
+ //
16
+ // The previous code withheld the affordance under dispatch and said so in a comment that
17
+ // was careful to call it temporary. It was still the wrong outcome: dispatch changes the
18
+ // PACKAGING, not the authority — the same corpus, forked, is editable — so a read-only
19
+ // dispatched viewer breaks packaging-is-not-trust exactly where a reader would notice.
20
+ //
21
+ // **The mount decides.** Writability is a property of the delegation's current mode, not
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.
25
+ //
26
+ // Pure — no SDK, no React — so all of the above is testable without a host.
27
+
28
+ import type { SandboxMount } from '@immediately-run/sdk/mounts';
29
+
30
+ /** How an edit of a content entry is delivered. */
31
+ export type EditTarget =
32
+ /** The fork: our own repo, via the self-scoped present→edit transition. */
33
+ | { via: 'self'; path: string }
34
+ /** Dispatch: one file of the delegated corpus, handed to the platform editor. */
35
+ | { via: 'delegate'; mountId: string; relPath: string };
36
+
37
+ export interface CorpusIdentity {
38
+ /** Whether the corpus is a mount rather than this app's own repo. */
39
+ dispatched: boolean;
40
+ /** The content root, with a trailing slash (`getContentRoot()`). */
41
+ contentRoot: string;
42
+ /** The corpus mount id, when dispatched (`getCorpusMountId()`). */
43
+ mountId: string | null;
44
+ }
45
+
46
+ /** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
47
+ export function keyToSelfPath(key: string): string {
48
+ return key.replace(/^\/app\//, '').replace(/^\//, '');
49
+ }
50
+
51
+ /**
52
+ * Where an edit of `entryKey` should go, or null when there is nowhere to send it.
53
+ *
54
+ * Null is not "read-only" — that is {@link corpusWritable}'s question. Null means the key
55
+ * does not name a file in this corpus at all, or a dispatched viewer has no mount id to
56
+ * delegate from (an older host that published the corpus without one). Either way there is
57
+ * nothing to offer, and offering it anyway would produce a refusal the reader must decode.
58
+ */
59
+ export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget | null {
60
+ if (typeof entryKey !== 'string' || entryKey === '') return null;
61
+ if (!corpus.dispatched) return { via: 'self', path: keyToSelfPath(entryKey) };
62
+ if (!corpus.mountId) return null;
63
+ if (!entryKey.startsWith(corpus.contentRoot)) return null;
64
+ const relPath = entryKey.slice(corpus.contentRoot.length);
65
+ return relPath ? { via: 'delegate', mountId: corpus.mountId, relPath } : null;
66
+ }
67
+
68
+ /**
69
+ * May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
70
+ *
71
+ * 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".
76
+ *
77
+ * A corpus mount that has vanished from the list answers `false`: no mount, no write.
78
+ */
79
+ export function corpusWritable(
80
+ mounts: readonly SandboxMount[] | null | undefined,
81
+ corpus: CorpusIdentity,
82
+ ): boolean {
83
+ const list = mounts ?? [];
84
+ if (!corpus.dispatched) {
85
+ return list.some((m) => m.type === 'worktree' && m.mode !== 'ro');
86
+ }
87
+ if (!corpus.mountId) return false;
88
+ const mount = list.find((m) => (m.id ?? m.path) === corpus.mountId);
89
+ // `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
90
+ // mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
91
+ // it, so the two never disagree about the same mount.
92
+ return !!mount && mount.mode !== 'ro';
93
+ }
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { resolveOpenWiki, OPEN_WIKI_TASK, CONTENT_MOUNT_TYPE } from './openWiki';
3
- import { getContentRoot, setContentRoot, resetContentRoot, isDispatched, APP_CONTENT_ROOT } from './contentRoot';
3
+ import {
4
+ getContentRoot,
5
+ getCorpusMountId,
6
+ setContentRoot,
7
+ resetContentRoot,
8
+ isDispatched,
9
+ APP_CONTENT_ROOT,
10
+ } from './contentRoot';
4
11
  import { slugToKey, isContentEntry, homeKey, contentDir, keyToHref, sandboxPathToKey } from './content';
5
12
  import { layoutChainForKey } from './layout';
6
13
  import type { SandboxMount } from '@immediately-run/sdk/mounts';
@@ -13,14 +20,14 @@ afterEach(resetContentRoot);
13
20
  describe('resolveOpenWiki — the delegated corpus', () => {
14
21
  it('resolves the dir param mounted at the host-minted chroot', () => {
15
22
  const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/task/t1/dir')]);
16
- expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task' });
23
+ expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task', mountId: '/task/t1/dir' });
17
24
  });
18
25
 
19
26
  it('reports a read-only delegation without refusing it', () => {
20
27
  // Sharing a corpus read-only is legitimate — the reader still reads. Only the WRITE
21
28
  // affordances may consult this; refusing the whole open would break the ordinary case.
22
29
  const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir', { mode: 'ro' })]);
23
- expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task' });
30
+ expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task', mountId: '/task/t1/dir' });
24
31
  });
25
32
 
26
33
  it('is not a callee when there is no task input — the ordinary fork boot', () => {
@@ -48,7 +55,7 @@ describe('resolveOpenWiki — the delegated corpus', () => {
48
55
  // The host owns the `/task/<slot>/<param>` grammar; if it ever renames the segment,
49
56
  // suffix-matching alone would cancel a task the user really asked for.
50
57
  const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/mnt/abc123')]);
51
- expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task' });
58
+ expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task', mountId: '/mnt/abc123' });
52
59
  });
53
60
 
54
61
  it('does not guess between two foreign mounts', () => {
@@ -72,7 +79,7 @@ describe('repo-load dispatch — a cold URL load, with no task input at all', ()
72
79
  mount('/app'),
73
80
  mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, name: 'neumark/book-nine-from-here' }),
74
81
  ]);
75
- expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load' });
82
+ expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load', mountId: '/mnt/deadbeef' });
76
83
  });
77
84
 
78
85
  it('carries a read-only delegation through', () => {
@@ -214,3 +221,37 @@ describe('routing — the URL space follows the packaging', () => {
214
221
  expect(key).toBe('/task/t1/dir/app/content/home.mdx');
215
222
  });
216
223
  });
224
+
225
+ // R3-266 — the corpus mount ID, which is what an onward delegation NAMES. Without it a
226
+ // dispatched viewer can locate the corpus and still not hand one of its files to the
227
+ // platform editor, which is the whole of the dispatched write path.
228
+ describe('resolveOpenWiki — the corpus mount id (the onward-delegation handle)', () => {
229
+ it('prefers the host-published id over the path', () => {
230
+ const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [
231
+ mount('/task/t1/dir', { id: 'space:abc' }),
232
+ ]);
233
+ expect(r).toMatchObject({ ok: true, mountId: 'space:abc' });
234
+ });
235
+
236
+ it('falls back to the mount PATH, which is exactly what the host publishes for a chroot', () => {
237
+ // `mintDelegations` names the descriptor `{ path, type: 'task-delegation', id: path }`,
238
+ // so path and id coincide for a task delegation — the fallback is the same answer, not
239
+ // a guess, and it keeps working against a host that publishes no id at all.
240
+ const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir')]);
241
+ expect(r).toMatchObject({ ok: true, mountId: '/task/t1/dir' });
242
+ });
243
+
244
+ it('carries the id through the repo-load branch too', () => {
245
+ const r = resolveOpenWiki(null, [
246
+ mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, id: 'github:neumark/book@main' }),
247
+ ]);
248
+ expect(r).toMatchObject({ ok: true, via: 'repo-load', mountId: 'github:neumark/book@main' });
249
+ });
250
+
251
+ it('reaches the contentRoot module, so the affordance can read it back', () => {
252
+ setContentRoot('/task/t1/dir', { readOnly: false, mountId: 'space:abc' });
253
+ expect(getCorpusMountId()).toBe('space:abc');
254
+ resetContentRoot();
255
+ expect(getCorpusMountId()).toBeNull();
256
+ });
257
+ });
@@ -25,7 +25,16 @@ export const DIR_PARAM = 'dir';
25
25
  export const CONTENT_MOUNT_TYPE = 'content';
26
26
 
27
27
  export type OpenWikiResolution =
28
- | { ok: true; root: string; readOnly: boolean; via: 'task' | 'repo-load' }
28
+ | {
29
+ ok: true;
30
+ root: string;
31
+ readOnly: boolean;
32
+ via: 'task' | 'repo-load';
33
+ /** The corpus mount's id — what an onward delegation names (R3-266). Falls back to
34
+ * the mount PATH, which is exactly what the host publishes as the id for a task
35
+ * chroot (`mintDelegations` uses the mount point as the descriptor id). */
36
+ mountId: string;
37
+ }
29
38
  | { ok: false; reason: 'not-a-callee' | 'wrong-task' | 'no-mount' };
30
39
 
31
40
  /**
@@ -53,7 +62,13 @@ export function resolveOpenWiki(
53
62
  // input and would otherwise fall out as `not-a-callee` and render our own corpus.
54
63
  const marked = mounts.find((m) => m.type === CONTENT_MOUNT_TYPE);
55
64
  if (marked) {
56
- return { ok: true, root: marked.path, readOnly: marked.mode === 'ro', via: 'repo-load' };
65
+ return {
66
+ ok: true,
67
+ root: marked.path,
68
+ readOnly: marked.mode === 'ro',
69
+ via: 'repo-load',
70
+ mountId: marked.id ?? marked.path,
71
+ };
57
72
  }
58
73
 
59
74
  if (!input) return { ok: false, reason: 'not-a-callee' };
@@ -67,7 +82,7 @@ export function resolveOpenWiki(
67
82
  // `mode` is absent on the primary repo mount and rw by default elsewhere. A read-only
68
83
  // delegation is a legitimate way to share a corpus — the reader still reads — so it
69
84
  // resolves normally and only the WRITE affordances consult this flag.
70
- return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task' };
85
+ return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task', mountId: hit.id ?? hit.path };
71
86
  }
72
87
 
73
88
  /** The message a failed resolution should show, in the reader's terms rather than the
package/src/lib/shell.ts CHANGED
@@ -33,7 +33,17 @@ export interface GroveShell {
33
33
  // Environment
34
34
  vw: 'mobile' | 'desktop';
35
35
  navMode: 'top' | 'side';
36
+ /** Whether to render an edit affordance — the corpus MOUNT's answer, re-read live
37
+ * (R3-266), never a property of how this instance was packaged. */
36
38
  writable: boolean;
39
+ /** Open a content entry in the platform editor. Which verb that takes differs by
40
+ * packaging (`lib/editTarget`); the chrome never has to know which. */
41
+ openEditor: (entryKey: string) => void;
42
+ /** True while an editor is being summoned, for a busy label. */
43
+ editBusy: boolean;
44
+ /** What a save actually does, for the affordance's title — under dispatch it says that
45
+ * proposing a change back to the content repo is not wired yet (R3-266's residual). */
46
+ editHint: string;
37
47
  siteTitle: string;
38
48
  /** Interpreter mode (TRUST_MODES §5): render this entry's body through the
39
49
  * non-executable safe renderer (R3-213) instead of the compiled/executable `<Include>`
@@ -0,0 +1,52 @@
1
+ // The selection precedence (02-theme-contract §4), pinned as a decision table —
2
+ // pure, so the whole cross-product of sources is cheap to assert. R3-308.
3
+ import { describe, expect, it } from 'vitest';
4
+ import { resolvePalette, resolvePolarity } from './themeSelection';
5
+ import { preferredPolarity, THEMES } from '../data/themes';
6
+
7
+ describe('resolvePalette — reader, else author, else default', () => {
8
+ it('a reader override wins over the author declaration', () => {
9
+ expect(resolvePalette({ reader: 'pixies', author: 'lotr' })).toBe('pixies');
10
+ });
11
+
12
+ it("a reader's explicit 'default' outranks a declaration — it is a choice, not an absence", () => {
13
+ expect(resolvePalette({ reader: 'default', author: 'lotr' })).toBe('default');
14
+ });
15
+
16
+ it('the author declaration is the default a reader falls into', () => {
17
+ expect(resolvePalette({ reader: null, author: 'family' })).toBe('family');
18
+ });
19
+
20
+ it('no reader, no author → default', () => {
21
+ expect(resolvePalette({})).toBe('default');
22
+ expect(resolvePalette({ reader: '', author: null })).toBe('default');
23
+ });
24
+ });
25
+
26
+ describe('resolvePolarity — reader, else host, else the palette’s own preference', () => {
27
+ it('a reader override wins over the host', () => {
28
+ expect(resolvePolarity({ reader: 'light', host: 'dark', preferred: 'dark' })).toBe('light');
29
+ });
30
+
31
+ it('the host drives polarity when the reader is silent — palette untouched (theme:read is polarity-only)', () => {
32
+ expect(resolvePolarity({ reader: null, host: 'light', preferred: 'dark' })).toBe('light');
33
+ expect(resolvePolarity({ reader: null, host: 'dark', preferred: 'light' })).toBe('dark');
34
+ });
35
+
36
+ it('no reader, no host → the palette’s preferred polarity', () => {
37
+ expect(resolvePolarity({ reader: null, host: null, preferred: 'light' })).toBe('light');
38
+ });
39
+
40
+ it('an unparseable stored override is silence, not a crash', () => {
41
+ expect(resolvePolarity({ reader: null, host: 'light', preferred: 'dark' })).toBe('light');
42
+ });
43
+ });
44
+
45
+ describe('the catalogue carries a preferred polarity for every theme (R3-308)', () => {
46
+ it('every theme declares one, and unknown ids fall back to dark', () => {
47
+ for (const t of THEMES) expect(['light', 'dark']).toContain(t.preferred);
48
+ expect(preferredPolarity('pixies')).toBe('dark');
49
+ expect(preferredPolarity('family')).toBe('light');
50
+ expect(preferredPolarity('never-heard-of')).toBe('dark');
51
+ });
52
+ });
@@ -0,0 +1,59 @@
1
+ // Theme selection — the ONE resolution of who picks what a reader sees
2
+ // (plans/grove-layouts-and-themes/02-theme-contract.mdx §4, R3-308).
3
+ //
4
+ // Two INDEPENDENT axes, three sources, stated once here so no surface re-derives
5
+ // them (ways_of_working §5: one resolution entry point per concern):
6
+ //
7
+ // palette = reader override, else author declaration, else 'default'
8
+ // polarity = reader override, else host theme, else the theme's own preferred
9
+ //
10
+ // The host has an opinion about POLARITY ONLY — it holds `theme:read` for exactly
11
+ // that and nothing else, so it never appears in the palette chain. A reader's
12
+ // choice outranks everyone and persists; an author's declaration is the default a
13
+ // reader falls into, not a wall.
14
+ //
15
+ // PURE: takes already-read inputs, returns a decision. Where each input comes from
16
+ // (localStorage, home-entry frontmatter, the host channel) is wiring, not policy,
17
+ // and lives in the components.
18
+
19
+ /** Light/dark — the axis that selects WITHIN a palette family. */
20
+ export type Polarity = 'light' | 'dark';
21
+
22
+ /** A palette family id — a `Theme['id']`, or any string a reader's override holds. */
23
+ export type PaletteId = string;
24
+
25
+ export interface PaletteInputs {
26
+ /** The reader's stored override (`grove:theme`), if any. */
27
+ reader?: PaletteId | null;
28
+ /** The author's `theme:` declaration on the home entry, if any. */
29
+ author?: PaletteId | null;
30
+ }
31
+
32
+ /**
33
+ * Resolve the palette. An empty/absent override is NOT an override — `''` would
34
+ * otherwise beat a real declaration while meaning nothing. `'default'` as a READER
35
+ * choice is meaningful ("the brand palette, even though this wiki declares another"),
36
+ * so any explicit reader value — `default` included — outranks the author.
37
+ */
38
+ export function resolvePalette({ reader, author }: PaletteInputs): PaletteId {
39
+ if (reader) return reader;
40
+ if (author) return author;
41
+ return 'default';
42
+ }
43
+
44
+ export interface PolarityInputs {
45
+ /** The reader's stored override (`grove:appearance`), if any. */
46
+ reader?: Polarity | null;
47
+ /** The host's current theme, when the host has one (standalone: the channel's
48
+ * initial — the host axis simply has no live source there). */
49
+ host?: Polarity | null;
50
+ /** The resolved palette's own preferred polarity. */
51
+ preferred: Polarity;
52
+ }
53
+
54
+ /** Resolve the polarity. Reader, then host, then the palette's own preference. */
55
+ export function resolvePolarity({ reader, host, preferred }: PolarityInputs): Polarity {
56
+ if (reader === 'light' || reader === 'dark') return reader;
57
+ if (host === 'light' || host === 'dark') return host;
58
+ return preferred;
59
+ }
@@ -254,6 +254,7 @@
254
254
  "frontmatter": {
255
255
  "engine": [
256
256
  "site",
257
+ "theme",
257
258
  "layout",
258
259
  "view",
259
260
  "frame",