@immediately-run/grove 0.2.2 → 0.2.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.
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.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.2.3)
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/grove",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -17,6 +17,12 @@
17
17
  "version": "^1"
18
18
  }
19
19
  ],
20
+ "opens": [
21
+ {
22
+ "app": "self",
23
+ "version": "^1"
24
+ }
25
+ ],
20
26
  "requests": {
21
27
  "task:invoke": {},
22
28
  "llm:chat": {}
package/src/App.tsx CHANGED
@@ -107,7 +107,7 @@ export default function App() {
107
107
  // hackability). §2's "single-map merge, never provider nesting" was written against
108
108
  // patching a provider in AFTER a partial render; nesting it INSIDE the gate above is
109
109
  // that same merge, composed before anything paints.
110
- const wiki = <GroveWiki readOnly={boot.readOnly} rejectedComponents={contentComponents.rejected} />;
110
+ const wiki = <GroveWiki rejectedComponents={contentComponents.rejected} />;
111
111
  const withComponents =
112
112
  contentComponents.status === 'ready' && Object.keys(contentComponents.components).length > 0 ? (
113
113
  <MDXProvider components={contentComponents.components}>{wiki}</MDXProvider>
package/src/GroveWiki.tsx CHANGED
@@ -80,10 +80,8 @@ function bundleHref(bundlePath: string): string {
80
80
  * (R3-169: under dispatch the root is a runtime value, not `/app/content/`).
81
81
  */
82
82
  export default function GroveWiki({
83
- readOnly = false,
84
83
  rejectedComponents = [],
85
84
  }: {
86
- readOnly?: boolean;
87
85
  /** Corpus component declarations that could not be loaded (R3-174). Shown, not
88
86
  * swallowed: a `<RoadmapBoard/>` that silently never appears is indistinguishable from
89
87
  * one nobody wrote, and the author has no other channel to learn which it was. */
@@ -166,7 +164,7 @@ export default function GroveWiki({
166
164
  // `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
167
165
  // mount change so a live role downgrade hides the affordance instead of producing
168
166
  // `EROFS` on click.
169
- const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(readOnly);
167
+ const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance();
170
168
 
171
169
  const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
172
170
  // The site brand is a wiki-wide constant, so read it from the home entry's
@@ -126,7 +126,7 @@ export default function EntryBody({ entryKey }: { entryKey: string }) {
126
126
  * from the live mount set (the hook only runs on this path — no double
127
127
  * subscription on the stock page). */
128
128
  function StandaloneHeader({ entryKey, mins }: { entryKey: string; mins: number }) {
129
- const local = useEditAffordance(false);
129
+ const local = useEditAffordance();
130
130
  return (
131
131
  <EntryHeader
132
132
  entryKey={entryKey}
@@ -288,7 +288,7 @@ describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel do
288
288
  const { useEditAffordance } = await import('../hooks/useEditAffordance');
289
289
  const refusedStates: boolean[] = [];
290
290
  function Probe() {
291
- const aff = useEditAffordance(false);
291
+ const aff = useEditAffordance();
292
292
  refusedStates.push(aff.refused);
293
293
  return (
294
294
  <div>
@@ -3,36 +3,51 @@
3
3
  // delegation: `requestEdit({ bundleFile })`, with the refusal contract pinned:
4
4
  // `read-only` hides the affordance until the next mount announcement, `cancelled`
5
5
  // stays silent, `forbidden` (and anything else) sets `refused`.
6
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
- import { act, useEffect } from 'react';
8
- import { createRoot, type Root } from 'react-dom/client';
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7
+ import { act, useEffect } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
9
 
10
- import type { EditAffordance } from './useEditAffordance';
10
+ import type { EditAffordance } from "./useEditAffordance";
11
11
 
12
12
  const requestEditMock = vi.fn<(t?: unknown) => Promise<void>>();
13
13
  const invokeTaskMock = vi.fn<(a: unknown) => Promise<void>>();
14
14
  const useMountsMock = vi.fn<() => unknown[]>();
15
15
 
16
- vi.mock('@immediately-run/sdk', () => ({
16
+ vi.mock("@immediately-run/sdk", () => ({
17
17
  requestEdit: (a?: unknown) => requestEditMock(a),
18
18
  invokeTask: (a: unknown) => invokeTaskMock(a),
19
- capFile: (ref: unknown, opts: unknown) => ({ ...(ref as object), ...(opts as object) }),
19
+ capFile: (ref: unknown, opts: unknown) => ({
20
+ ...(ref as object),
21
+ ...(opts as object),
22
+ }),
20
23
  useMounts: () => useMountsMock(),
21
24
  }));
22
25
 
23
26
  // The corpus identity comes from the real contentRoot module, driven through its
24
27
  // own producer (R3-877 round 1, R2) — never a hand-typed shape.
25
- import { getContentRoot, resetContentRoot, setContentRoot } from '../lib/contentRoot';
26
-
27
- import { useEditAffordance } from './useEditAffordance';
28
-
29
- (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
30
-
31
- const RO_MOUNT = { type: 'task-delegation', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'ro', readerCanEdit: true };
28
+ import {
29
+ getContentRoot,
30
+ resetContentRoot,
31
+ setContentRoot,
32
+ } from "../lib/contentRoot";
33
+
34
+ import { useEditAffordance } from "./useEditAffordance";
35
+
36
+ (
37
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
38
+ ).IS_REACT_ACT_ENVIRONMENT = true;
39
+
40
+ const RO_MOUNT = {
41
+ type: "task-delegation",
42
+ path: "/task/t1/dir",
43
+ id: "/task/t1/dir",
44
+ mode: "ro",
45
+ readerCanEdit: true,
46
+ };
32
47
 
33
48
  let latest: EditAffordance | null = null;
34
49
  function Probe() {
35
- const affordance = useEditAffordance(false);
50
+ const affordance = useEditAffordance();
36
51
  useEffect(() => {
37
52
  latest = affordance;
38
53
  });
@@ -43,12 +58,12 @@ let container: HTMLDivElement;
43
58
  let root: Root;
44
59
 
45
60
  beforeEach(() => {
46
- setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
61
+ setContentRoot("/task/t1/dir", { mountId: "/task/t1/dir" });
47
62
  requestEditMock.mockReset().mockResolvedValue(undefined);
48
63
  invokeTaskMock.mockReset().mockResolvedValue(undefined);
49
64
  useMountsMock.mockReset().mockReturnValue([RO_MOUNT]);
50
65
  latest = null;
51
- container = document.createElement('div');
66
+ container = document.createElement("div");
52
67
  document.body.appendChild(container);
53
68
  root = createRoot(container);
54
69
  act(() => root.render(<Probe />));
@@ -69,23 +84,42 @@ const open = async (key = `${getContentRoot()}plot/the-rail.mdx`) => {
69
84
  rerender();
70
85
  };
71
86
 
72
- describe('useEditAffordance — the workbench delivery (R3-877)', () => {
73
- it('an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })', async () => {
87
+ describe("useEditAffordance — the workbench delivery (R3-877)", () => {
88
+ it("an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })", async () => {
74
89
  expect(latest!.writable).toBe(true);
75
90
  await open();
76
- expect(requestEditMock).toHaveBeenCalledWith({ bundleFile: '/plot/the-rail.mdx' });
91
+ expect(requestEditMock).toHaveBeenCalledWith({
92
+ bundleFile: "/plot/the-rail.mdx",
93
+ });
77
94
  expect(invokeTaskMock).not.toHaveBeenCalled();
78
95
  expect(latest!.refused).toBe(false);
79
96
  });
80
97
 
81
- it('an ro delegation with `readerCanEdit: false` does not offer the control', () => {
98
+ it("the ro corpus latch (the boot path of every opener delegation) does not hide the offer — R3-878 regression", async () => {
99
+ // The bug the venue leg caught: the boot latches the corpus's `ro` mode as the
100
+ // contentRoot readOnly flag, and the pre-fix hook gated `writable` on it — so an
101
+ // app-declared opener's chroot (always `ro`, APP_CUSTOMIZATION §5a) never showed
102
+ // the pencil, although the reader's authority may well edit the source.
103
+ resetContentRoot();
104
+ setContentRoot("/task/t1/dir", { readOnly: true, mountId: "/task/t1/dir" });
105
+ rerender();
106
+ expect(latest!.writable).toBe(true);
107
+ await open();
108
+ expect(requestEditMock).toHaveBeenCalledWith({
109
+ bundleFile: "/plot/the-rail.mdx",
110
+ });
111
+ });
112
+
113
+ it("an ro delegation with `readerCanEdit: false` does not offer the control", () => {
82
114
  useMountsMock.mockReturnValue([{ ...RO_MOUNT, readerCanEdit: false }]);
83
115
  rerender();
84
116
  expect(latest!.writable).toBe(false);
85
117
  });
86
118
 
87
- it('a `read-only` refusal hides the affordance until the next mount announcement', async () => {
88
- requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'read-only' }));
119
+ it("a `read-only` refusal hides the affordance until the next mount announcement", async () => {
120
+ requestEditMock.mockRejectedValue(
121
+ Object.assign(new Error("no"), { code: "read-only" }),
122
+ );
89
123
  await open();
90
124
  expect(latest!.refused).toBe(false); // not a render-in-place refusal
91
125
  expect(latest!.writable).toBe(false); // hidden…
@@ -94,15 +128,19 @@ describe('useEditAffordance — the workbench delivery (R3-877)', () => {
94
128
  expect(latest!.writable).toBe(true); // …unlatches it.
95
129
  });
96
130
 
97
- it('a `cancelled` rejection stays silent (the reader closed the editor)', async () => {
98
- requestEditMock.mockRejectedValue(Object.assign(new Error('closed'), { code: 'cancelled' }));
131
+ it("a `cancelled` rejection stays silent (the reader closed the editor)", async () => {
132
+ requestEditMock.mockRejectedValue(
133
+ Object.assign(new Error("closed"), { code: "cancelled" }),
134
+ );
99
135
  await open();
100
136
  expect(latest!.refused).toBe(false);
101
137
  expect(latest!.writable).toBe(true);
102
138
  });
103
139
 
104
- it('a `forbidden` refusal sets `refused` (rendered as text in place) and never retries via edit-file', async () => {
105
- requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'forbidden' }));
140
+ it("a `forbidden` refusal sets `refused` (rendered as text in place) and never retries via edit-file", async () => {
141
+ requestEditMock.mockRejectedValue(
142
+ Object.assign(new Error("no"), { code: "forbidden" }),
143
+ );
106
144
  await open();
107
145
  expect(latest!.refused).toBe(true);
108
146
  expect(invokeTaskMock).not.toHaveBeenCalled();
@@ -13,10 +13,19 @@
13
13
  // than producing `EROFS` on click — and a `readerCanEdit: false` hint or a `read-only`
14
14
  // refusal hides the offer — and it hands back one `openEditor(entryKey)` the chrome
15
15
  // calls without knowing which packaging it is in.
16
- import { useCallback, useMemo, useState } from 'react';
17
- import { capFile, invokeTask, requestEdit, useMounts } from '@immediately-run/sdk';
18
- import { getContentRoot, getCorpusMountId, isDispatched } from '../lib/contentRoot';
19
- import { corpusWritable, editTarget } from '../lib/editTarget';
16
+ import { useCallback, useMemo, useState } from "react";
17
+ import {
18
+ capFile,
19
+ invokeTask,
20
+ requestEdit,
21
+ useMounts,
22
+ } from "@immediately-run/sdk";
23
+ import {
24
+ getContentRoot,
25
+ getCorpusMountId,
26
+ isDispatched,
27
+ } from "../lib/contentRoot";
28
+ import { corpusWritable, editTarget } from "../lib/editTarget";
20
29
 
21
30
  export interface EditAffordance {
22
31
  /** Whether to render an edit affordance at all — the MOUNT's answer, live. */
@@ -41,7 +50,7 @@ export interface EditAffordance {
41
50
  editHint: string;
42
51
  }
43
52
 
44
- export function useEditAffordance(readOnly: boolean): EditAffordance {
53
+ export function useEditAffordance(): EditAffordance {
45
54
  const mounts = useMounts();
46
55
  const [busy, setBusy] = useState(false);
47
56
  const [refused, setRefused] = useState(false);
@@ -54,7 +63,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
54
63
  const corpus = useMemo(() => {
55
64
  const mountId = getCorpusMountId();
56
65
  const mount = mounts?.find((m) => (m.id ?? m.path) === mountId);
57
- return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId, mountMode: mount?.mode ?? null };
66
+ return {
67
+ dispatched: isDispatched(),
68
+ contentRoot: getContentRoot(),
69
+ mountId,
70
+ mountMode: mount?.mode ?? null,
71
+ };
58
72
  }, [mounts]);
59
73
 
60
74
  // R3-877: a `read-only` refusal (the reader cannot edit the source) hides the
@@ -69,12 +83,18 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
69
83
  setReaderReadOnly(false);
70
84
  }
71
85
 
72
- const writable = !readOnly && !readerReadOnly && corpusWritable(mounts, corpus);
86
+ // No `readOnly` veto here (there was one until R3-878's live leg): the boot-time
87
+ // read-only latch is the corpus delegation's `ro` mode, so gating on it hid the
88
+ // affordance in exactly the one case the workbench delivery exists for — an
89
+ // app-declared opener's chroot is always `ro` (APP_CUSTOMIZATION §5a). The live
90
+ // mount list already answers writability (`corpusWritable`), re-read on every
91
+ // announcement, and a `read-only` refusal latches the offer off above.
92
+ const writable = !readerReadOnly && corpusWritable(mounts, corpus);
73
93
 
74
94
  // A refusal surfaces where the affordance was offered (3.3.1, R3-608);
75
95
  // `cancelled` — the reader closing the editor — stays silent by contract.
76
96
  const refusedUnlessCancelled = (e: unknown): undefined => {
77
- if ((e as { code?: string } | null)?.code !== 'cancelled') setRefused(true);
97
+ if ((e as { code?: string } | null)?.code !== "cancelled") setRefused(true);
78
98
  return undefined;
79
99
  };
80
100
 
@@ -85,10 +105,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
85
105
  setBusy(true);
86
106
  setRefused(false);
87
107
  const done = () => setBusy(false);
88
- if (target.via === 'self') {
108
+ if (target.via === "self") {
89
109
  // The fork: the present→edit transition on our own source. Self-scoped by
90
110
  // contract, which is exactly right when the corpus IS our repo.
91
- requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
111
+ requestEdit({ path: target.path })
112
+ .catch(refusedUnlessCancelled)
113
+ .finally(done);
92
114
  return;
93
115
  }
94
116
  // Dispatch, read-only delegation: ask the workbench to open the entry's source
@@ -97,12 +119,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
97
119
  // which this click is. `cancelled` stays silent; `read-only` hides the
98
120
  // affordance until the next mount announcement; anything else renders in place
99
121
  // as text (the `refused` flag) — and is never retried through `edit-file`.
100
- if (target.via === 'workbench') {
122
+ if (target.via === "workbench") {
101
123
  requestEdit({ bundleFile: target.relPath })
102
124
  .catch((e: unknown) => {
103
125
  const code = (e as { code?: string } | null)?.code;
104
- if (code === 'cancelled') return;
105
- if (code === 'read-only') {
126
+ if (code === "cancelled") return;
127
+ if (code === "read-only") {
106
128
  setReaderReadOnly(true);
107
129
  return;
108
130
  }
@@ -116,8 +138,11 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
116
138
  // already hold the directory, and `edit-file` is one hop further along a chain
117
139
  // §5.7.1 bounds at depth 4. The host resolves the cap against our grants, so
118
140
  // this can only ever narrow.
119
- invokeTask('edit-file', {
120
- file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
141
+ invokeTask("edit-file", {
142
+ file: capFile(
143
+ { mountId: target.mountId, relPath: target.relPath },
144
+ { mode: "rw" },
145
+ ),
121
146
  })
122
147
  .catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
123
148
  .finally(done);
@@ -126,8 +151,8 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
126
151
  );
127
152
 
128
153
  const editHint = corpus.dispatched
129
- ? 'Edits save to the mounted content, and can be proposed back to its repository as a PR.'
130
- : 'Edit this entry';
154
+ ? "Edits save to the mounted content, and can be proposed back to its repository as a PR."
155
+ : "Edit this entry";
131
156
 
132
157
  return { writable, busy, refused, openEditor, editHint };
133
158
  }