@immediately-run/grove 0.2.0 → 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.0)
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.0",
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": {}
@@ -42,7 +48,7 @@
42
48
  },
43
49
  "dependencies": {
44
50
  "@immediately-run/mdx-plugins": "0.7.1",
45
- "@immediately-run/sdk": "^0.72.0",
51
+ "@immediately-run/sdk": "^0.80.0",
46
52
  "react": "^19.2.5",
47
53
  "react-dom": "^19.2.5"
48
54
  },
@@ -88,7 +94,7 @@
88
94
  "llms.txt"
89
95
  ],
90
96
  "peerDependencies": {
91
- "@immediately-run/sdk": "^0.72.0",
97
+ "@immediately-run/sdk": "^0.80.0",
92
98
  "react": "^19.2.5",
93
99
  "react-dom": "^19.2.5"
94
100
  }
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/GroveApp.css CHANGED
@@ -1677,6 +1677,17 @@ a.gdl-row {
1677
1677
  gap: 8px;
1678
1678
  align-items: center;
1679
1679
  }
1680
+ /* The weighted cloud's pixel range lives HERE and nowhere else (the component
1681
+ only sets --tag-weight): 11.5px (the plain .grove-tag size) to 22px. The rule
1682
+ hangs off the --weighted modifier because TagList shares the .grove-tagcloud
1683
+ wrapper and must stay unweighted. */
1684
+ .grove-tagcloud--weighted .grove-tag {
1685
+ font-size: calc(11.5px + var(--tag-weight, 0) * 10.5px);
1686
+ }
1687
+ .grove-tag__count {
1688
+ opacity: 0.5;
1689
+ margin-left: 4px;
1690
+ }
1680
1691
 
1681
1692
  /* ============ skeleton ============ */
1682
1693
  @keyframes gshimmer {
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
@@ -373,8 +371,8 @@ export default function GroveWiki({
373
371
  return (
374
372
  // R3-277b: declare the enclosing bundle for the platform's link-space consumers
375
373
  // (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
376
- // R3-482: the SDK pin (^0.72.0, #75) reads `bundleRoot` new-then-old, so the new
377
- // spelling is stated alone — the deprecated `corpusRoot` stays in the type for
374
+ // R3-482: the SDK pin (since ^0.72.0, #75) reads `bundleRoot` new-then-old, so the
375
+ // new spelling is stated alone — the deprecated `corpusRoot` stays in the type for
378
376
  // older consumers (mdx-plugins' forever-compat) but no longer here.
379
377
  <LinkSpaceContext.Provider
380
378
  value={{
@@ -387,12 +385,10 @@ export default function GroveWiki({
387
385
  // chroot), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot
388
386
  // name a federated mount materialised beside it — the app-level mount
389
387
  // point is not a corpus path — and the fork's `$fs:` stays
390
- // mount-absolute. THIS field carries the same flag for the SDK's generic
391
- // component consumers (WikiLink/MDXComponents): the pinned SDK 0.72.0
392
- // does not forward it, so it is decided-but-not-consumed until a
393
- // forwarding release is published and pinned — the booked follow-up,
394
- // stated here rather than blurred (§8.3's dated note:
395
- // built-but-not-in-force is a materially different state from done).
388
+ // mount-absolute. This field carries the same flag for the SDK's generic
389
+ // component consumers (WikiLink/MDXComponents) — consumed since the
390
+ // 0.79.0 pin (R3-783): those components forward it to the shared
391
+ // resolver, so the clamp is load-bearing on the generic path too.
396
392
  bundleChrooted: isDispatched(),
397
393
  }}
398
394
  >
@@ -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>
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ // TagCloud's render contract: chips carry a 0..1 `--tag-weight` and NO inline
3
+ // font-size (the pixel range is GroveApp.css's), the wrapper carries the
4
+ // --weighted modifier, and `limit` keeps the N most-used tags (a bad limit is
5
+ // ignored with one warning). Metadata is supplied through TinkerableContext, the
6
+ // way Sidebar.test.tsx supplies its map. The rendered pixel size itself is
7
+ // jsdom-untestable (no calc()/custom-property resolution) — that leg is R3-946.
8
+ import { describe, expect, it, vi, afterEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot, type Root } from 'react-dom/client';
11
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
12
+
13
+ const { default: TagCloud } = await import('./TagCloud');
14
+
15
+ // One tag on 191 entries (the real docs corpus's `bug` scale) and one on a
16
+ // single entry.
17
+ const FILES = {
18
+ ...Object.fromEntries(
19
+ Array.from({ length: 191 }, (_, i) => [`/app/content/e${i}.mdx`, { title: `E${i}`, tags: ['bug'] }]),
20
+ ),
21
+ '/app/content/single.mdx': { title: 'Single.', tags: ['doc'] },
22
+ };
23
+
24
+ const NAV = {
25
+ outerHref: 'https://example.immediately.run/app/x',
26
+ navigationState: {
27
+ sandboxPath: '/app/x',
28
+ provider: 'github',
29
+ namespace: 'immediately-run',
30
+ repository: 'docs',
31
+ ref: 'main',
32
+ hash: '',
33
+ search: '',
34
+ },
35
+ routingSpec: {} as never,
36
+ filesMetadata: FILES,
37
+ };
38
+
39
+ let mounted: { root: Root; container: HTMLElement } | null = null;
40
+
41
+ async function mountCloud(limit?: number): Promise<HTMLElement> {
42
+ const container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ const root = createRoot(container);
45
+ await act(async () => {
46
+ root.render(
47
+ <TinkerableContext.Provider value={NAV as never}>
48
+ <TagCloud limit={limit} />
49
+ </TinkerableContext.Provider>,
50
+ );
51
+ });
52
+ mounted = { root, container };
53
+ return container;
54
+ }
55
+
56
+ afterEach(() => {
57
+ if (mounted) {
58
+ act(() => mounted!.root.unmount());
59
+ mounted!.container.remove();
60
+ mounted = null;
61
+ }
62
+ });
63
+
64
+ const chips = (container: HTMLElement): HTMLElement[] =>
65
+ [...container.querySelectorAll('.grove-tag')] as HTMLElement[];
66
+
67
+ describe('TagCloud', () => {
68
+ it('weights every chip between 0 and 1 with no inline font-size, on a --weighted wrapper', async () => {
69
+ const container = await mountCloud();
70
+ expect(container.querySelector('.grove-tagcloud')!.classList.contains('grove-tagcloud--weighted')).toBe(true);
71
+ const all = chips(container);
72
+ expect(all.length).toBe(2);
73
+ const weights = all.map((chip) => Number.parseFloat(chip.style.getPropertyValue('--tag-weight')));
74
+ for (const w of weights) {
75
+ expect(Number.isFinite(w)).toBe(true);
76
+ expect(w).toBeGreaterThanOrEqual(0);
77
+ expect(w).toBeLessThanOrEqual(1);
78
+ }
79
+ // Chips read alphabetically: `bug` (191 entries, the maximum) then `doc`
80
+ // (one entry, the minimum).
81
+ expect(weights).toEqual([1, 0]);
82
+ for (const chip of all) expect(chip.style.fontSize).toBe('');
83
+ });
84
+
85
+ it('limit={1} renders only the most-used tag, still scaled against the corpus', async () => {
86
+ const container = await mountCloud(1);
87
+ const all = chips(container);
88
+ expect(all.length).toBe(1);
89
+ expect(all[0]!.textContent).toContain('bug');
90
+ // Corpus-relative (not subset-relative): the corpus maximum keeps weight 1
91
+ // under the limit — subset scaling would give it min === max → 0.
92
+ expect(all[0]!.style.getPropertyValue('--tag-weight')).toBe('1');
93
+ });
94
+
95
+ it('limit={0} is treated as absent and warns once', async () => {
96
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
97
+ const container = await mountCloud(0);
98
+ expect(chips(container).length).toBe(2);
99
+ expect(warn.mock.calls.length).toBe(1);
100
+ expect(String(warn.mock.calls[0]![0])).toContain('0');
101
+ warn.mockRestore();
102
+ });
103
+ });
@@ -1,46 +1,64 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useCallback } from 'react';
2
+ import { useCallback, useEffect } from 'react';
3
+ import type { CSSProperties } from 'react';
3
4
  import { useMetadataQuery } from '@immediately-run/sdk';
4
5
  import { contentDir } from '../lib/content';
5
6
  import { queryPaths } from '../lib/wiki';
7
+ import { countTags, tagWeight, topTags, type TagCount } from '../lib/tagCloud';
6
8
 
7
9
  // Import-free engine component: every tag across the site, sized by frequency.
8
10
  // Chrome tags (`ui/*`) are excluded — they drive layout, not classification.
9
- export default function TagCloud() {
11
+ //
12
+ // Sizing: each chip carries its weight as `--tag-weight` and the CSS owns the
13
+ // pixels (11.5–22px) — the old `11 + count * 2 + 'px'` inline style had no
14
+ // ceiling and let a frequent tag fill the viewport. The wrapper carries the
15
+ // `--weighted` modifier so the rule never touches TagList's plain chips, which
16
+ // share the `.grove-tagcloud` wrapper class.
17
+
18
+ // A bad `limit` warns once per distinct value per session — an authored page
19
+ // never goes blank over a prop.
20
+ const warnedLimits = new Set<string>();
21
+
22
+ export default function TagCloud({ limit }: { limit?: number }) {
23
+ const limitOk = limit === undefined || (Number.isInteger(limit) && limit > 0);
24
+ useEffect(() => {
25
+ if (limitOk) return;
26
+ const key = String(limit);
27
+ if (warnedLimits.has(key)) return;
28
+ warnedLimits.add(key);
29
+ console.warn(`TagCloud: limit must be a positive integer, got ${key} — showing every tag`);
30
+ }, [limit, limitOk]);
31
+
10
32
  const queryFn = useCallback((filesMetadata: Record<string, any>) => {
11
- const counts: Record<string, number> = {};
12
- Object.entries(filesMetadata).forEach(([p, m]) => {
13
- if (!p.startsWith(contentDir())) return;
14
- if (m && Array.isArray(m.tags)) {
15
- m.tags.forEach((t: string) => {
16
- if (t.startsWith('ui/')) return;
17
- counts[t] = (counts[t] || 0) + 1;
18
- });
19
- }
20
- });
21
- // Encode counts into the string[] the query contract returns.
22
- return Object.keys(counts)
23
- .sort()
24
- .map((t) => `${t}:${counts[t]}`);
33
+ // The query contract is string-valued, so the pairs ride as `tag:count`
34
+ // (the encoding is unchanged).
35
+ return countTags(filesMetadata, contentDir()).map(({ tag, count }) => `${tag}:${count}`);
25
36
  }, []);
26
37
 
27
38
  const result = useMetadataQuery(queryFn);
28
39
  const entries: string[] = queryPaths(result);
40
+ const pairs: TagCount[] = entries.map((e) => {
41
+ const [tag, count] = e.split(':');
42
+ return { tag: tag!, count: Number(count) };
43
+ });
44
+ const shown = topTags(pairs, limitOk ? limit : undefined);
45
+ // The scale is relative to the CORPUS (every tag), not the shown subset —
46
+ // under a limit the corpus's most-used tag must still read as the largest.
47
+ const counts = pairs.map((p) => p.count);
48
+ const min = Math.min(...counts);
49
+ const max = Math.max(...counts);
29
50
 
30
51
  return (
31
- <div className="grove-tagcloud">
32
- {entries.map((e) => {
33
- const [tag, count] = e.split(':');
34
- return (
35
- <span
36
- key={tag}
37
- className="grove-tag"
38
- style={{ fontSize: 11 + Number(count) * 2 + 'px', padding: '4px 12px' }}
39
- >
40
- #{tag} <span style={{ opacity: 0.5, marginLeft: 4 }}>{count}</span>
41
- </span>
42
- );
43
- })}
52
+ <div className="grove-tagcloud grove-tagcloud--weighted">
53
+ {shown.map(({ tag, count }) => (
54
+ <span
55
+ key={tag}
56
+ className="grove-tag"
57
+ style={{ '--tag-weight': String(tagWeight(count, min, max)) } as CSSProperties}
58
+ >
59
+ #{tag} <span className="grove-tag__count">{count}</span>
60
+ </span>
61
+ ))}
44
62
  </div>
45
63
  );
46
64
  }
@@ -0,0 +1,148 @@
1
+ // @vitest-environment jsdom
2
+ // R3-877 — the workbench delivery of the edit affordance for a read-only opener
3
+ // delegation: `requestEdit({ bundleFile })`, with the refusal contract pinned:
4
+ // `read-only` hides the affordance until the next mount announcement, `cancelled`
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";
9
+
10
+ import type { EditAffordance } from "./useEditAffordance";
11
+
12
+ const requestEditMock = vi.fn<(t?: unknown) => Promise<void>>();
13
+ const invokeTaskMock = vi.fn<(a: unknown) => Promise<void>>();
14
+ const useMountsMock = vi.fn<() => unknown[]>();
15
+
16
+ vi.mock("@immediately-run/sdk", () => ({
17
+ requestEdit: (a?: unknown) => requestEditMock(a),
18
+ invokeTask: (a: unknown) => invokeTaskMock(a),
19
+ capFile: (ref: unknown, opts: unknown) => ({
20
+ ...(ref as object),
21
+ ...(opts as object),
22
+ }),
23
+ useMounts: () => useMountsMock(),
24
+ }));
25
+
26
+ // The corpus identity comes from the real contentRoot module, driven through its
27
+ // own producer (R3-877 round 1, R2) — never a hand-typed shape.
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
+ };
47
+
48
+ let latest: EditAffordance | null = null;
49
+ function Probe() {
50
+ const affordance = useEditAffordance();
51
+ useEffect(() => {
52
+ latest = affordance;
53
+ });
54
+ return null;
55
+ }
56
+
57
+ let container: HTMLDivElement;
58
+ let root: Root;
59
+
60
+ beforeEach(() => {
61
+ setContentRoot("/task/t1/dir", { mountId: "/task/t1/dir" });
62
+ requestEditMock.mockReset().mockResolvedValue(undefined);
63
+ invokeTaskMock.mockReset().mockResolvedValue(undefined);
64
+ useMountsMock.mockReset().mockReturnValue([RO_MOUNT]);
65
+ latest = null;
66
+ container = document.createElement("div");
67
+ document.body.appendChild(container);
68
+ root = createRoot(container);
69
+ act(() => root.render(<Probe />));
70
+ });
71
+
72
+ const rerender = () => act(() => root.render(<Probe />));
73
+
74
+ afterEach(() => {
75
+ resetContentRoot();
76
+ act(() => root.unmount());
77
+ container.remove();
78
+ });
79
+ const open = async (key = `${getContentRoot()}plot/the-rail.mdx`) => {
80
+ await act(async () => {
81
+ latest!.openEditor(key);
82
+ await Promise.resolve();
83
+ });
84
+ rerender();
85
+ };
86
+
87
+ describe("useEditAffordance — the workbench delivery (R3-877)", () => {
88
+ it("an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })", async () => {
89
+ expect(latest!.writable).toBe(true);
90
+ await open();
91
+ expect(requestEditMock).toHaveBeenCalledWith({
92
+ bundleFile: "/plot/the-rail.mdx",
93
+ });
94
+ expect(invokeTaskMock).not.toHaveBeenCalled();
95
+ expect(latest!.refused).toBe(false);
96
+ });
97
+
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", () => {
114
+ useMountsMock.mockReturnValue([{ ...RO_MOUNT, readerCanEdit: false }]);
115
+ rerender();
116
+ expect(latest!.writable).toBe(false);
117
+ });
118
+
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
+ );
123
+ await open();
124
+ expect(latest!.refused).toBe(false); // not a render-in-place refusal
125
+ expect(latest!.writable).toBe(false); // hidden…
126
+ useMountsMock.mockReturnValue([{ ...RO_MOUNT }]); // …until the next announcement…
127
+ rerender();
128
+ expect(latest!.writable).toBe(true); // …unlatches it.
129
+ });
130
+
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
+ );
135
+ await open();
136
+ expect(latest!.refused).toBe(false);
137
+ expect(latest!.writable).toBe(true);
138
+ });
139
+
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
+ );
144
+ await open();
145
+ expect(latest!.refused).toBe(true);
146
+ expect(invokeTaskMock).not.toHaveBeenCalled();
147
+ });
148
+ });
@@ -7,14 +7,25 @@
7
7
  // was withheld entirely and the wiki became read-only for the one packaging where the
8
8
  // content is most obviously somebody's to edit.
9
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
10
+ // The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the live
11
+ // mount list so a role downgrade reroutes the delivery on the next render (rw → the
12
+ // `edit-file` overlay; ro → the workbench under the reader's authority, R3-877) rather
13
+ // than producing `EROFS` on click — and a `readerCanEdit: false` hint or a `read-only`
14
+ // refusal hides the offer — and it hands back one `openEditor(entryKey)` the chrome
13
15
  // 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';
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";
18
29
 
19
30
  export interface EditAffordance {
20
31
  /** Whether to render an edit affordance at all — the MOUNT's answer, live. */
@@ -39,26 +50,51 @@ export interface EditAffordance {
39
50
  editHint: string;
40
51
  }
41
52
 
42
- export function useEditAffordance(readOnly: boolean): EditAffordance {
53
+ export function useEditAffordance(): EditAffordance {
43
54
  const mounts = useMounts();
44
55
  const [busy, setBusy] = useState(false);
45
56
  const [refused, setRefused] = useState(false);
46
57
 
47
58
  // Read the corpus identity through the mount list's identity, so the memo re-runs when
48
59
  // the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
49
- // the MODE is not, and that is the half this hook exists to keep current.
50
- const corpus = useMemo(
51
- () => ({ dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() }),
52
- // eslint-disable-next-line react-hooks/exhaustive-deps
53
- [mounts],
54
- );
60
+ // the MODE is not, and that is the half this hook exists to keep current. R3-877: the
61
+ // delegation's live MODE now also routes the delivery (rw → the edit-file overlay,
62
+ // ro → the workbench under the reader's authority).
63
+ const corpus = useMemo(() => {
64
+ const mountId = getCorpusMountId();
65
+ const mount = mounts?.find((m) => (m.id ?? m.path) === mountId);
66
+ return {
67
+ dispatched: isDispatched(),
68
+ contentRoot: getContentRoot(),
69
+ mountId,
70
+ mountMode: mount?.mode ?? null,
71
+ };
72
+ }, [mounts]);
73
+
74
+ // R3-877: a `read-only` refusal (the reader cannot edit the source) hides the
75
+ // affordance until the next mount announcement — the hint's re-announcement is
76
+ // exactly what unlatches it. Reset on every mount-list change — the
77
+ // render-adjusted-state pattern (not an effect, which would paint one frame
78
+ // stale), the React-sanctioned form.
79
+ const [readerReadOnly, setReaderReadOnly] = useState(false);
80
+ const [resetFor, setResetFor] = useState(mounts);
81
+ if (resetFor !== mounts) {
82
+ setResetFor(mounts);
83
+ setReaderReadOnly(false);
84
+ }
55
85
 
56
- const writable = !readOnly && 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);
57
93
 
58
94
  // A refusal surfaces where the affordance was offered (3.3.1, R3-608);
59
95
  // `cancelled` — the reader closing the editor — stays silent by contract.
60
96
  const refusedUnlessCancelled = (e: unknown): undefined => {
61
- if ((e as { code?: string } | null)?.code !== 'cancelled') setRefused(true);
97
+ if ((e as { code?: string } | null)?.code !== "cancelled") setRefused(true);
62
98
  return undefined;
63
99
  };
64
100
 
@@ -69,18 +105,44 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
69
105
  setBusy(true);
70
106
  setRefused(false);
71
107
  const done = () => setBusy(false);
72
- if (target.via === 'self') {
108
+ if (target.via === "self") {
73
109
  // The fork: the present→edit transition on our own source. Self-scoped by
74
110
  // contract, which is exactly right when the corpus IS our repo.
75
- requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
111
+ requestEdit({ path: target.path })
112
+ .catch(refusedUnlessCancelled)
113
+ .finally(done);
114
+ return;
115
+ }
116
+ // Dispatch, read-only delegation: ask the workbench to open the entry's source
117
+ // under the reader's authority (R3-876 / APP_CUSTOMIZATION §5a). Our chroot is
118
+ // never upgraded and nothing is minted for us; the host needs a real gesture,
119
+ // which this click is. `cancelled` stays silent; `read-only` hides the
120
+ // affordance until the next mount announcement; anything else renders in place
121
+ // as text (the `refused` flag) — and is never retried through `edit-file`.
122
+ if (target.via === "workbench") {
123
+ requestEdit({ bundleFile: target.relPath })
124
+ .catch((e: unknown) => {
125
+ const code = (e as { code?: string } | null)?.code;
126
+ if (code === "cancelled") return;
127
+ if (code === "read-only") {
128
+ setReaderReadOnly(true);
129
+ return;
130
+ }
131
+ setRefused(true);
132
+ })
133
+ .finally(done);
76
134
  return;
77
135
  }
78
- // Dispatch: attenuate the corpus delegation down to this one file and hand it to
79
- // the platform editor. Nothing new is minted — we already hold the directory, and
80
- // `edit-file` is one hop further along a chain §5.7.1 bounds at depth 4. The host
81
- // resolves the cap against OUR grants, so this can only ever narrow.
82
- invokeTask('edit-file', {
83
- file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
136
+ // Dispatch, writable delegation: attenuate the corpus delegation down to this
137
+ // one file and hand it to the platform editor. Nothing new is minted — we
138
+ // already hold the directory, and `edit-file` is one hop further along a chain
139
+ // §5.7.1 bounds at depth 4. The host resolves the cap against our grants, so
140
+ // this can only ever narrow.
141
+ invokeTask("edit-file", {
142
+ file: capFile(
143
+ { mountId: target.mountId, relPath: target.relPath },
144
+ { mode: "rw" },
145
+ ),
84
146
  })
85
147
  .catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
86
148
  .finally(done);
@@ -89,8 +151,8 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
89
151
  );
90
152
 
91
153
  const editHint = corpus.dispatched
92
- ? 'Edits save to the mounted content, and can be proposed back to its repository as a PR.'
93
- : '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";
94
156
 
95
157
  return { writable, busy, refused, openEditor, editHint };
96
158
  }
@@ -4,21 +4,29 @@
4
4
  // viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
5
5
  // offer one at all must be the corpus mount's CURRENT mode rather than a property of the
6
6
  // packaging or a flag latched at boot.
7
- import { describe, expect, it } from 'vitest';
7
+ import { describe, expect, it, afterEach } from 'vitest';
8
8
  import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
9
9
  import type { CorpusIdentity } from './editTarget';
10
10
  import type { SandboxMount } from '@immediately-run/sdk/mounts';
11
-
12
- const fork: CorpusIdentity = {
13
- dispatched: false,
14
- contentRoot: '/app/content/',
15
- mountId: null,
11
+ import { getContentRoot, getCorpusMountId, isDispatched, resetContentRoot, setContentRoot } from './contentRoot';
12
+
13
+ // The corpus identities come from the real producer (R3-877 round 1, R2): the
14
+ // trailing-slash normalization every `slice` in editTarget depends on lives in
15
+ // `setContentRoot` — a hand-typed literal would keep passing while it broke.
16
+ const forkFor = (): CorpusIdentity => {
17
+ resetContentRoot();
18
+ return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
16
19
  };
17
- const dispatched: CorpusIdentity = {
18
- dispatched: true,
19
- contentRoot: '/task/t1/dir/',
20
- mountId: '/task/t1/dir',
20
+ const dispatchedFor = (): CorpusIdentity => {
21
+ setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
22
+ return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
21
23
  };
24
+ afterEach(() => resetContentRoot());
25
+
26
+ const fork: CorpusIdentity = forkFor();
27
+ const dispatched: CorpusIdentity = dispatchedFor();
28
+ // The dispatched entry keys, built off the producer's root — never a hand-typed prefix.
29
+ const IN = (rel: string) => `${dispatched.contentRoot}${rel}`;
22
30
 
23
31
  const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
24
32
  ({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
@@ -32,7 +40,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
32
40
  });
33
41
 
34
42
  it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
35
- expect(editTarget('/task/t1/dir/plot/the-rail.mdx', dispatched)).toEqual({
43
+ expect(editTarget(IN('plot/the-rail.mdx'), dispatched)).toEqual({
36
44
  via: 'delegate',
37
45
  mountId: '/task/t1/dir',
38
46
  relPath: 'plot/the-rail.mdx',
@@ -40,7 +48,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
40
48
  });
41
49
 
42
50
  it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
43
- const t = editTarget('/task/t1/dir/home.mdx', dispatched);
51
+ const t = editTarget(IN('home.mdx'), dispatched);
44
52
  expect(t).toMatchObject({ relPath: 'home.mdx' });
45
53
  // The fork's `content/` segment must NOT leak into a corpus-relative path: the
46
54
  // delegated chroot is minted AT the content directory.
@@ -52,11 +60,11 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
52
60
  });
53
61
 
54
62
  it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
55
- expect(editTarget('/task/t1/dir/home.mdx', { ...dispatched, mountId: null })).toBeNull();
63
+ expect(editTarget(IN('home.mdx'), { ...dispatched, mountId: null })).toBeNull();
56
64
  });
57
65
 
58
66
  it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
59
- expect(editTarget('/task/t1/dir/', dispatched)).toBeNull();
67
+ expect(editTarget(IN(''), dispatched)).toBeNull();
60
68
  });
61
69
 
62
70
  it('never throws on a junk key', () => {
@@ -81,13 +89,19 @@ describe('corpusWritable — the mount decides, live', () => {
81
89
  expect(corpusWritable([mount()], dispatched)).toBe(true);
82
90
  });
83
91
 
84
- it('a ro corpus is not writable, so the affordance is hidden rather than EROFS-ing', () => {
85
- expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
92
+ it('a ro corpus with no hint is still offerable (R3-877: the workbench class) — never EROFS, a refusal tells', () => {
93
+ // Pre-R3-877 this was `false` — an ro mount hid the affordance outright. The
94
+ // workbench class edits under the reader's authority, so our ro mount is the
95
+ // normal case, not a refusal. An explicit `false` hint still hides it (below).
96
+ expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
86
97
  });
87
98
 
88
- it('follows a LIVE downgrade: the same mount re-announced ro flips the answer', () => {
99
+ it('follows a live downgrade: re-announced ro flips the delivery, and a false hint flips the offer', () => {
89
100
  expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
90
- expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
101
+ // ro with no hint: still offerable, now via the workbench (reader's authority).
102
+ expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
103
+ // ro with the host saying the reader cannot edit: hidden.
104
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
91
105
  });
92
106
 
93
107
  it('a corpus mount that has vanished is not writable', () => {
@@ -106,3 +120,49 @@ describe('corpusWritable — the mount decides, live', () => {
106
120
  expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
107
121
  });
108
122
  });
123
+
124
+ // R3-877 — the third outcome: an ro delegation edits via the workbench, under the
125
+ // reader's authority (`requestEdit({ bundleFile })`, R3-876). Order of preference:
126
+ // self → delegate (rw) → workbench (ro).
127
+ describe('editTarget — the workbench class for a read-only delegation (R3-877)', () => {
128
+ // The corpus identity's contentRoot mirrors the real getContentRoot() shape: the
129
+ // delegated chroot root, trailing slash.
130
+ const roCorpus: CorpusIdentity = { ...dispatched, mountMode: 'ro' };
131
+
132
+ it('an `ro` dispatched corpus yields workbench with the leading-slash bundle-relative path', () => {
133
+ expect(editTarget(IN('plot/the-rail.mdx'), roCorpus)).toEqual({
134
+ via: 'workbench',
135
+ relPath: '/plot/the-rail.mdx',
136
+ });
137
+ });
138
+
139
+ it('an `rw` delegation still yields delegate (the edit-file overlay is unchanged)', () => {
140
+ expect(editTarget(IN('plot/the-rail.mdx'), { ...dispatched, mountMode: 'rw' })).toEqual({
141
+ via: 'delegate',
142
+ mountId: '/task/t1/dir',
143
+ relPath: 'plot/the-rail.mdx',
144
+ });
145
+ });
146
+
147
+ it('an UNKNOWN mode (an older host announces none) keeps the pre-R3-877 delegate behavior', () => {
148
+ expect(editTarget(IN('home.mdx'), dispatched)).toEqual({
149
+ via: 'delegate',
150
+ mountId: '/task/t1/dir',
151
+ relPath: 'home.mdx',
152
+ });
153
+ });
154
+
155
+ it('the corpus root itself is still nothing to edit, workbench included', () => {
156
+ expect(editTarget(IN(''), roCorpus)).toBeNull();
157
+ });
158
+ });
159
+
160
+ describe('corpusWritable — the ro delegation is offerable on the hint (R3-877)', () => {
161
+ it('ro + readerCanEdit true → offered', () => {
162
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: true })], dispatched)).toBe(true);
163
+ });
164
+
165
+ it('ro + readerCanEdit false → not offered (never show a control that refuses)', () => {
166
+ expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
167
+ });
168
+ });
@@ -20,8 +20,11 @@
20
20
  //
21
21
  // **The mount decides.** Writability is a property of the delegation's current mode, not
22
22
  // of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
23
- // than the boot-time flag: a role downgrade re-announces the mount `ro`, and the
24
- // affordance must disappear rather than surface `EROFS` when clicked.
23
+ // than the boot-time flag. Since R3-877 a role downgrade no longer hides the affordance —
24
+ // it reroutes the delivery: `rw` hands the file to the `edit-file` overlay, `ro` asks the
25
+ // workbench under the reader's authority (`requestEdit({ bundleFile })`). The offer hides
26
+ // only when the host's `readerCanEdit` hint says the reader cannot edit, or a `read-only`
27
+ // refusal proved it.
25
28
  //
26
29
  // Pure — no SDK, no React — so all of the above is testable without a host.
27
30
 
@@ -31,8 +34,14 @@ import type { SandboxMount } from '@immediately-run/sdk/mounts';
31
34
  export type EditTarget =
32
35
  /** The fork: our own repo, via the self-scoped present→edit transition. */
33
36
  | { via: 'self'; path: string }
34
- /** Dispatch: one file of the delegated corpus, handed to the platform editor. */
35
- | { via: 'delegate'; mountId: string; relPath: string };
37
+ /** Dispatch, writable delegation: one file of the corpus, handed to the platform
38
+ * editor as a narrowed `edit-file` delegation (the overlay). */
39
+ | { via: 'delegate'; mountId: string; relPath: string }
40
+ /** Dispatch, read-only delegation (an app-declared opener's chroot): ask the
41
+ * workbench to open the entry's source in the main-pane editor under the
42
+ * reader's authority — `requestEdit({ bundleFile })` (R3-876 / APP_CUSTOMIZATION
43
+ * §5a). The path is bundle-relative with a leading slash. */
44
+ | { via: 'workbench'; relPath: string };
36
45
 
37
46
  export interface CorpusIdentity {
38
47
  /** Whether the corpus is a mount rather than this app's own repo. */
@@ -41,6 +50,11 @@ export interface CorpusIdentity {
41
50
  contentRoot: string;
42
51
  /** The corpus mount id, when dispatched (`getCorpusMountId()`). */
43
52
  mountId: string | null;
53
+ /** The corpus delegation's current mode, read off the live mount list (R3-877):
54
+ * `ro` routes the edit to the workbench under the reader's authority; `rw` keeps
55
+ * the `edit-file` overlay. Absent/unknown keeps the pre-R3-877 behavior
56
+ * (`delegate` — an `rw`-assuming host that announces no mode). */
57
+ mountMode?: 'ro' | 'rw' | null;
44
58
  }
45
59
 
46
60
  /** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
@@ -62,17 +76,25 @@ export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget
62
76
  if (!corpus.mountId) return null;
63
77
  if (!entryKey.startsWith(corpus.contentRoot)) return null;
64
78
  const relPath = entryKey.slice(corpus.contentRoot.length);
65
- return relPath ? { via: 'delegate', mountId: corpus.mountId, relPath } : null;
79
+ if (!relPath) return null;
80
+ // An `ro` delegation (the opener's chroot — the only mode an app-declared opener
81
+ // ever holds) goes to the workbench: the reader's authority, not ours — the mount
82
+ // is never upgraded and nothing is minted for us. Leading-slash, the host's
83
+ // `bundleFile` grammar.
84
+ if (corpus.mountMode === 'ro') return { via: 'workbench', relPath: `/${relPath}` };
85
+ return { via: 'delegate', mountId: corpus.mountId, relPath };
66
86
  }
67
87
 
68
88
  /**
69
89
  * May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
70
90
  *
71
91
  * A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
72
- * mount — and asks the LIVE mount list, not the boot-time flag, so a live `rw → ro`
73
- * downgrade (a role change the host re-announces on the same mount id) hides the
74
- * affordance on the next render. That is the whole difference between "hidden because you
75
- * may not" and "shown, then `EROFS` when you try".
92
+ * mount — and asks the live mount list, not the boot-time flag, so a live `rw → ro`
93
+ * downgrade (a role change the host re-announces on the same mount id) reroutes the
94
+ * delivery on the next render (to the workbench class) and an explicit
95
+ * `readerCanEdit: false` hides the offer — rather than surfacing `EROFS` on click.
96
+ * That is the whole difference between "hidden because you may not" and "shown, then
97
+ * `EROFS` when you try".
76
98
  *
77
99
  * A corpus mount that has vanished from the list answers `false`: no mount, no write.
78
100
  */
@@ -89,5 +111,12 @@ export function corpusWritable(
89
111
  // `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
90
112
  // mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
91
113
  // it, so the two never disagree about the same mount.
92
- return !!mount && mount.mode !== 'ro';
114
+ if (!mount) return false;
115
+ if (mount.mode !== 'ro') return true;
116
+ // R3-877 (APP_CUSTOMIZATION §5a.5): an `ro` delegation can still offer the
117
+ // workbench edit — the reader's authority — gated on the host's advisory
118
+ // `readerCanEdit` hint: offer when it is true, OR when the host sent no hint
119
+ // (absent = unknown — the refusal would tell, and `read-only` hides it after).
120
+ // Never offer on an explicit `false`.
121
+ return mount.readerCanEdit !== false;
93
122
  }
@@ -0,0 +1,115 @@
1
+ // countTags / tagWeight / topTags — the pure half of TagCloud. The counting case
2
+ // runs over the repo's REAL content tree, parsed with the real frontmatter parser,
3
+ // so a drifted fixture cannot agree with the code by accident (ways_of_working §4).
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { parseFrontmatter } from './frontmatter';
8
+ import { countTags, tagWeight, topTags, type TagCount } from './tagCloud';
9
+
10
+ const CONTENT_ROOT = '/app/content/';
11
+
12
+ /** Every .mdx under the repo's content/ as a metadata map keyed the way the
13
+ * store keys it (absolute module path under the app root). */
14
+ const realCorpusMetadata = (): Record<string, { tags?: unknown }> => {
15
+ const dir = join(process.cwd(), 'content');
16
+ const walk = (d: string): string[] =>
17
+ readdirSync(d, { withFileTypes: true }).flatMap((e) =>
18
+ e.isDirectory() ? walk(join(d, e.name)) : e.name.endsWith('.mdx') ? [join(d, e.name)] : [],
19
+ );
20
+ const files: Record<string, { tags?: unknown }> = {};
21
+ for (const abs of walk(dir)) {
22
+ const { data } = parseFrontmatter(readFileSync(abs, 'utf8'));
23
+ files[CONTENT_ROOT + abs.slice(dir.length + 1)] = { tags: (data as { tags?: unknown }).tags };
24
+ }
25
+ return files;
26
+ };
27
+
28
+ /** The same count, computed independently in the test from the same parsed map. */
29
+ const expectCounts = (files: Record<string, { tags?: unknown }>): TagCount[] => {
30
+ const counts = new Map<string, number>();
31
+ for (const [p, m] of Object.entries(files)) {
32
+ if (!p.startsWith(CONTENT_ROOT)) continue;
33
+ for (const t of Array.isArray(m.tags) ? (m.tags as string[]) : []) {
34
+ if (t.startsWith('ui/')) continue;
35
+ counts.set(t, (counts.get(t) ?? 0) + 1);
36
+ }
37
+ }
38
+ return [...counts.keys()].sort().map((tag) => ({ tag, count: counts.get(tag)! }));
39
+ };
40
+
41
+ describe('countTags', () => {
42
+ it('over the real corpus, equals the independent count and carries no ui/ tag', () => {
43
+ const files = realCorpusMetadata();
44
+ expect(Object.keys(files).length).toBeGreaterThan(0);
45
+ const result = countTags(files, CONTENT_ROOT);
46
+ expect(result).toEqual(expectCounts(files));
47
+ expect(result.some(({ tag }) => tag.startsWith('ui/'))).toBe(false);
48
+ });
49
+
50
+ it('ignores entries outside the content root and non-array tags', () => {
51
+ const result = countTags(
52
+ {
53
+ '/app/content/a.mdx': { tags: ['x'] },
54
+ '/other/b.mdx': { tags: ['x'] },
55
+ '/app/content/c.mdx': { tags: 'x' },
56
+ '/app/content/d.mdx': null,
57
+ },
58
+ CONTENT_ROOT,
59
+ );
60
+ expect(result).toEqual([{ tag: 'x', count: 1 }]);
61
+ });
62
+ });
63
+
64
+ describe('tagWeight', () => {
65
+ it('gives the minimum 0 and the maximum 1', () => {
66
+ expect(tagWeight(1, 1, 191)).toBe(0);
67
+ expect(tagWeight(191, 1, 191)).toBe(1);
68
+ });
69
+
70
+ it('is strictly increasing over the real corpus spread (1, 15, 191)', () => {
71
+ const w = [1, 15, 191].map((c) => tagWeight(c, 1, 191));
72
+ expect(w[0]!).toBeLessThan(w[1]!);
73
+ expect(w[1]!).toBeLessThan(w[2]!);
74
+ // Logarithmic: a tag 15x the minimum sits well under the arithmetic midpoint.
75
+ expect(w[1]!).toBeLessThan(0.6);
76
+ });
77
+
78
+ it('clamps a count beyond the range', () => {
79
+ expect(tagWeight(10_000, 1, 191)).toBe(1);
80
+ });
81
+
82
+ it('returns 0 when there is no spread (one tag, or all equal)', () => {
83
+ expect(tagWeight(7, 7, 7)).toBe(0);
84
+ });
85
+
86
+ it('returns 0 for a count below 1 or a non-finite count', () => {
87
+ expect(tagWeight(0, 1, 10)).toBe(0);
88
+ expect(tagWeight(Number.NaN, 1, 10)).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe('topTags', () => {
93
+ const five: TagCount[] = [
94
+ { tag: 'alpha', count: 3 },
95
+ { tag: 'beta', count: 9 },
96
+ { tag: 'gamma', count: 12 },
97
+ { tag: 'delta', count: 12 },
98
+ { tag: 'epsilon', count: 1 },
99
+ ];
100
+
101
+ it('keeps the N highest counts and returns them in tag order', () => {
102
+ expect(topTags(five, 2).map((t) => t.tag)).toEqual(['delta', 'gamma']);
103
+ });
104
+
105
+ it('breaks a tie at the cut by tag name', () => {
106
+ // beta(9) and a tie between gamma/delta(12): limit 2 takes delta+gamma; a
107
+ // tie AT the cut (limit 3 leaves beta out) — add a second 9 to force it.
108
+ const withTie: TagCount[] = [...five.slice(0, 2), { tag: 'zed', count: 9 }, ...five.slice(2)];
109
+ expect(topTags(withTie, 3).map((t) => t.tag)).toEqual(['beta', 'delta', 'gamma']);
110
+ });
111
+
112
+ it('returns the input when no limit is given', () => {
113
+ expect(topTags(five)).toBe(five);
114
+ });
115
+ });
@@ -0,0 +1,58 @@
1
+ // TagCloud's counting and scaling, extracted from the component so the cases are
2
+ // unit-testable (the component is a thin wiring shell). The chip's pixel range is
3
+ // NOT here — it lives in GroveApp.css, and the component only hands each chip a
4
+ // 0..1 weight via `--tag-weight`.
5
+
6
+ /** One tag and how many content entries carry it. */
7
+ export interface TagCount {
8
+ tag: string;
9
+ count: number;
10
+ }
11
+
12
+ interface TaggedMeta {
13
+ tags?: unknown;
14
+ }
15
+
16
+ /** Every tag across the corpus with its count, sorted by tag. Only entries under
17
+ * the content root count; `ui/` tags are chrome (they drive layout, not
18
+ * classification) and are skipped. */
19
+ export function countTags(filesMetadata: Record<string, TaggedMeta | null>, contentRoot: string): TagCount[] {
20
+ const counts: Record<string, number> = {};
21
+ Object.entries(filesMetadata).forEach(([p, m]) => {
22
+ if (!p.startsWith(contentRoot)) return;
23
+ if (m && Array.isArray(m.tags)) {
24
+ (m.tags as string[]).forEach((t) => {
25
+ if (t.startsWith('ui/')) return;
26
+ counts[t] = (counts[t] || 0) + 1;
27
+ });
28
+ }
29
+ });
30
+ return Object.keys(counts)
31
+ .sort()
32
+ .map((tag) => ({ tag, count: counts[tag]! }));
33
+ }
34
+
35
+ /** A tag's weight in the closed range 0..1: logarithmic and relative to THIS
36
+ * corpus's own minimum and maximum, so the scale holds at 10 entries and at
37
+ * 10,000 — the least-used tag renders 0, the most-used 1, and a tag used ten
38
+ * times as often is visibly but not ten times larger. No spread (`max === min`)
39
+ * returns 0, so a uniform corpus renders plain chips; a count below 1 or a
40
+ * non-finite count returns 0. */
41
+ export function tagWeight(count: number, min: number, max: number): number {
42
+ if (!Number.isFinite(count) || count < 1) return 0;
43
+ if (max <= min) return 0;
44
+ const w = (Math.log(count) - Math.log(min)) / (Math.log(max) - Math.log(min));
45
+ return Math.min(1, Math.max(0, w));
46
+ }
47
+
48
+ /** The `limit` most-used tags, ties at the cut broken by tag name, re-sorted by
49
+ * tag for display (the cloud reads alphabetically). An absent limit returns the
50
+ * input unchanged — validating a bad limit is the component's job (it warns). */
51
+ export function topTags(entries: TagCount[], limit?: number): TagCount[] {
52
+ if (limit === undefined) return entries;
53
+ return entries
54
+ .slice()
55
+ .sort((a, b) => b.count - a.count || (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
56
+ .slice(0, limit)
57
+ .sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
58
+ }