cursedbelt 2.7.0 → 2.8.0

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.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `bun run verify` — every check this repo has, run as a dependency GRAPH instead of a
4
+ * six-link `&&` chain.
5
+ *
6
+ * ## Why this stopped being a chain
7
+ *
8
+ * Measured 2026-09-15 on an idle machine, stage by stage, with the chain in place:
9
+ *
10
+ * paths 0.4s · typecheck 22.6s (three tsc projects) · demo:check 7.2s
11
+ * · build 13.2s · test 67.6s · e2e 32.2s → 142.0s end to end
12
+ *
13
+ * Five of those are single-threaded `tsc`/scan work that share nothing but the source tree,
14
+ * and they were queued behind one another on a fourteen-core machine. As a graph the same
15
+ * stages finish in roughly half the wall clock, and the saving grows with load: this repo's
16
+ * gate is two thirds of every gate second the generation has ever spent.
17
+ *
18
+ * 🔴 NOTHING HERE IS A SMALLER GATE. Every script the chain ran is still run, and
19
+ * `src/verifyGraph.spec.ts` fails if one is dropped or orphaned. This generation's third
20
+ * rule is that a repo's gate proves that repo — a gate that got faster by proving less is
21
+ * not faster, it is gone. There is deliberately no `verify:fast`: one gate, or the fast one
22
+ * becomes the one everybody runs and the slow one becomes the one nobody does.
23
+ *
24
+ * ## The edges, and why these are the only ones
25
+ *
26
+ * `build` regenerates `src/styles-static.css` and `src/styles-utilities.css` (its first two
27
+ * steps), and `stylesStaticMatches.spec.ts` and `stylesUtilitiesMatches.spec.ts` read those
28
+ * exact files and assert they are current. Run concurrently against a STALE stylesheet, one
29
+ * process writes the bytes the other is asserting on — a flake that appears only on the
30
+ * commit that changed a component, which is the worst possible day for it.
31
+ *
32
+ * So the generators are their own stage and everything that touches those bytes waits for
33
+ * it. Once `styles` has run, the same generators inside `build` find their output current
34
+ * and write nothing (they compare before writing) — which is what makes `build` safe to run
35
+ * beside `test` and `e2e` rather than in front of them.
36
+ *
37
+ * 🔴 `build` is a LEAF, not a prerequisite. No spec under `src` opens `dist/` — checked by
38
+ * `verifyGraph.spec.ts` rather than asserted here — and the demo resolves every
39
+ * `cursedbelt/…` specifier to SOURCE through the export map, so nothing in `test` or `e2e`
40
+ * can see the compiled output at all.
41
+ *
42
+ * ## What it prints
43
+ *
44
+ * Buffered per stage, never interleaved: six concurrent stages writing to one transcript is
45
+ * output nobody can read. The cost of buffering is silence, and silence is exactly what
46
+ * `autopilot/src/proc.ts` exists to prevent — so a heartbeat names what is still running and
47
+ * for how long. A reader can tell "thinking" from "dead" without knowing anything about the
48
+ * suite.
49
+ *
50
+ * 🔴 A failing stage's output is printed LAST, after the timing table. The runner keeps the
51
+ * final 4 KB of a gate as the evidence it files against the task (`worker.ts`), so whatever
52
+ * is at the tail is what a human reads first. The chain got this for free by stopping at the
53
+ * failure; a graph has to put it there on purpose.
54
+ */
55
+ import { cpus } from "node:os";
56
+ import { resolve } from "node:path";
57
+
58
+ export interface Stage {
59
+ /**
60
+ * The `package.json` script this stage runs. 🔴 A script name, never a command line —
61
+ * `package.json` stays the one place every command is spelled, so this file cannot drift
62
+ * from what `bun run <thing>` does.
63
+ */
64
+ script: string;
65
+ /** Stages that must be GREEN before this one may start. */
66
+ needs?: readonly string[];
67
+ /** Why the edge exists. An edge nobody can explain is an edge nobody can safely remove. */
68
+ why?: string;
69
+ }
70
+
71
+ /**
72
+ * The gate. 🔴 Order in this list means nothing — `needs` is the only thing that sequences
73
+ * anything, and the scheduler starts whatever is ready.
74
+ */
75
+ export const STAGES: readonly Stage[] = [
76
+ { script: "paths" },
77
+ { script: "typecheck:root" },
78
+ { script: "typecheck:server" },
79
+ { script: "typecheck:specs" },
80
+ { script: "demo:check" },
81
+ { script: "styles" },
82
+ {
83
+ script: "build",
84
+ needs: ["styles"],
85
+ why: "its first two steps are the same generators; two writers on one stylesheet is a race",
86
+ },
87
+ {
88
+ script: "test",
89
+ needs: ["styles"],
90
+ why: "stylesStaticMatches / stylesUtilitiesMatches read those two files and assert they are current",
91
+ },
92
+ {
93
+ script: "e2e",
94
+ needs: ["styles"],
95
+ why: "the demo bundle it drives is compiled from the same stylesheets",
96
+ },
97
+ ];
98
+
99
+ /** The repo root — this file's own directory, one level up. Never a literal. */
100
+ export const ROOT = resolve(import.meta.dir, "..");
101
+
102
+ /**
103
+ * How many stages may run at once.
104
+ *
105
+ * 🔴 A THIRD of the cores, not all of them, and the reason is that a stage is not a process.
106
+ * `e2e` alone spawns seven Playwright workers and `build` spawns two `tsc` processes, so a
107
+ * cap of six stages is already twenty-odd processes. This Mac reached twenty-one concurrent
108
+ * `bun test` processes once and the machine-wide PreToolUse warning exists because of it.
109
+ * Parallelism never reduces the total CPU work; over-subscribing only converts it into
110
+ * thrash, and browser specs degrade worse than anything else under contention.
111
+ *
112
+ * An explicit `CURSEDBELT_VERIFY_JOBS` wins, including `1`, which is how a bisect gets the
113
+ * old serial behaviour back without a second gate script existing.
114
+ */
115
+ export function jobLimit(env: NodeJS.ProcessEnv = process.env, cpuCount: number = cpus().length): number {
116
+ const asked = Number(env.CURSEDBELT_VERIFY_JOBS);
117
+ if (Number.isFinite(asked) && asked >= 1) return Math.floor(asked);
118
+ return Math.max(2, Math.min(6, Math.floor(cpuCount / 3)));
119
+ }
120
+
121
+ /**
122
+ * 🔴 Every stage reachable, and every `needs` naming a stage that exists.
123
+ *
124
+ * Both halves matter and they fail in opposite directions: a typo in `needs` would otherwise
125
+ * make a stage that can NEVER run (the scheduler would idle with work left, and "nothing
126
+ * failed" is not the same as "everything passed"), while a cycle would deadlock the same way.
127
+ * Checked before anything is spawned, so a broken graph costs a second rather than a gate.
128
+ */
129
+ export function graphFault(stages: readonly Stage[] = STAGES): string | null {
130
+ const names = new Set(stages.map((s) => s.script));
131
+ if (names.size !== stages.length) return "two stages name the same script";
132
+ for (const stage of stages) {
133
+ for (const need of stage.needs ?? []) {
134
+ if (!names.has(need)) return `\`${stage.script}\` needs \`${need}\`, which is not a stage`;
135
+ }
136
+ }
137
+ // Kahn's algorithm: anything still unresolved when nothing more can be resolved is a cycle.
138
+ const done = new Set<string>();
139
+ for (;;) {
140
+ const ready = stages.filter((s) => !done.has(s.script) && (s.needs ?? []).every((n) => done.has(n)));
141
+ if (ready.length === 0) break;
142
+ for (const s of ready) done.add(s.script);
143
+ }
144
+ const stuck = stages.filter((s) => !done.has(s.script)).map((s) => s.script);
145
+ return stuck.length > 0 ? `these stages can never run (a cycle in \`needs\`): ${stuck.join(", ")}` : null;
146
+ }
147
+
148
+ export interface StageResult {
149
+ script: string;
150
+ ok: boolean;
151
+ ms: number;
152
+ output: string;
153
+ /** Not started, because something it needed failed or the run had already gone red. */
154
+ skipped?: boolean;
155
+ }
156
+
157
+ function secs(ms: number): string {
158
+ return `${(ms / 1000).toFixed(1)}s`;
159
+ }
160
+
161
+ /** The table every run ends with, green or red. Pure, so the shape of it is testable. */
162
+ export function timingTable(results: readonly StageResult[], wallMs: number): string {
163
+ const width = Math.max(10, ...results.map((r) => r.script.length));
164
+ const cpu = results.reduce((a, r) => a + r.ms, 0);
165
+ const lines = [` ${"stage".padEnd(width)} elapsed result`];
166
+ for (const r of [...results].sort((a, b) => b.ms - a.ms)) {
167
+ const verdict = r.skipped ? "skipped" : r.ok ? "ok" : "FAILED";
168
+ lines.push(` ${r.script.padEnd(width)} ${secs(r.ms).padStart(9)} ${verdict}`);
169
+ }
170
+ lines.push(
171
+ "",
172
+ ` ${secs(wallMs)} of wall clock; ${secs(cpu)} of stage time. ` +
173
+ `${cpu > wallMs ? `${(cpu / wallMs).toFixed(1)}× overlap.` : "No overlap — one stage at a time."}`,
174
+ );
175
+ return lines.join("\n");
176
+ }
177
+
178
+ async function runStage(stage: Stage): Promise<StageResult> {
179
+ const started = Date.now();
180
+ const child = Bun.spawn([process.execPath, "run", stage.script], {
181
+ cwd: ROOT,
182
+ stdout: "pipe",
183
+ // Merged rather than kept apart: a compiler writes diagnostics to one and progress to
184
+ // the other, and a reader needs them interleaved the way the stage wrote them.
185
+ stderr: "pipe",
186
+ });
187
+ const [out, err, code] = await Promise.all([
188
+ new Response(child.stdout).text(),
189
+ new Response(child.stderr).text(),
190
+ child.exited,
191
+ ]);
192
+ return { script: stage.script, ok: code === 0, ms: Date.now() - started, output: `${out}${err}` };
193
+ }
194
+
195
+ /** Bytes of a failing stage's output that reach the tail. Enough for a stack, not a build log. */
196
+ const FAILURE_TAIL = 12_000;
197
+
198
+ export async function verify(stages: readonly Stage[] = STAGES): Promise<number> {
199
+ const fault = graphFault(stages);
200
+ if (fault) {
201
+ process.stdout.write(`\n🔴 the verify graph is broken: ${fault}\n`);
202
+ return 2;
203
+ }
204
+ const jobs = jobLimit();
205
+ const startedAt = Date.now();
206
+ process.stdout.write(`\n▸ verify — ${stages.length} stages, up to ${jobs} at once.\n\n`);
207
+
208
+ const results = new Map<string, StageResult>();
209
+ const running = new Map<string, number>();
210
+ let red = false;
211
+
212
+ // 🔴 Names what is STILL running, never a spinner. The question a reader has at minute
213
+ // four of a Playwright suite is "which stage, and how long has it been there" — a
214
+ // progress animation answers neither, and the transcript this lands in is a file.
215
+ const heartbeat = setInterval(() => {
216
+ if (running.size === 0) return;
217
+ const live = [...running.entries()]
218
+ .map(([script, at]) => `${script} ${secs(Date.now() - at)}`)
219
+ .sort()
220
+ .join(" · ");
221
+ process.stdout.write(` ⧗ still running: ${live}\n`);
222
+ }, 30_000);
223
+ // Never hold the process open on the timer alone.
224
+ heartbeat.unref?.();
225
+
226
+ const inFlight = new Set<Promise<void>>();
227
+ const start = (stage: Stage): void => {
228
+ running.set(stage.script, Date.now());
229
+ process.stdout.write(` ▶ ${stage.script}\n`);
230
+ const task = runStage(stage).then((result) => {
231
+ running.delete(stage.script);
232
+ results.set(result.script, result);
233
+ if (!result.ok) red = true;
234
+ process.stdout.write(` ${result.ok ? "✔" : "✘"} ${result.script} ${secs(result.ms)}\n`);
235
+ });
236
+ inFlight.add(task);
237
+ void task.finally(() => inFlight.delete(task));
238
+ };
239
+
240
+ for (;;) {
241
+ // 🔴 Once anything is red, nothing NEW starts — the chain would never have reached it —
242
+ // but whatever is already running is left to finish. Killing an in-flight stage buys
243
+ // back seconds that are already spent and throws away a second, independent failure
244
+ // that would otherwise be in the same report.
245
+ const ready = red
246
+ ? []
247
+ : stages.filter(
248
+ (s) =>
249
+ !results.has(s.script) &&
250
+ !running.has(s.script) &&
251
+ (s.needs ?? []).every((n) => results.get(n)?.ok === true),
252
+ );
253
+ while (ready.length > 0 && running.size < jobs) {
254
+ const next = ready.shift();
255
+ if (next) start(next);
256
+ }
257
+ if (inFlight.size === 0) break;
258
+ await Promise.race(inFlight);
259
+ }
260
+ clearInterval(heartbeat);
261
+
262
+ for (const stage of stages) {
263
+ if (!results.has(stage.script)) {
264
+ results.set(stage.script, { script: stage.script, ok: false, ms: 0, output: "", skipped: true });
265
+ }
266
+ }
267
+
268
+ const all = stages.map((s) => results.get(s.script) as StageResult);
269
+ const failed = all.filter((r) => !r.ok && !r.skipped);
270
+ const skipped = all.filter((r) => r.skipped);
271
+ const wall = Date.now() - startedAt;
272
+
273
+ process.stdout.write(`\n${timingTable(all, wall)}\n`);
274
+ if (skipped.length > 0) {
275
+ process.stdout.write(
276
+ `\n ⏭ not run, because the gate was already red: ${skipped.map((r) => r.script).join(", ")}\n`,
277
+ );
278
+ }
279
+ if (failed.length === 0) {
280
+ process.stdout.write(`\n✅ verify green in ${secs(wall)}.\n`);
281
+ return 0;
282
+ }
283
+ // 🔴 LAST. `worker.ts` files the final 4 KB of this as the evidence against the task.
284
+ for (const r of failed) {
285
+ process.stdout.write(`\n\n${"═".repeat(72)}\n🔴 FAILED: bun run ${r.script} (${secs(r.ms)})\n${"═".repeat(72)}\n`);
286
+ process.stdout.write(r.output.length > FAILURE_TAIL ? `…\n${r.output.slice(-FAILURE_TAIL)}` : r.output);
287
+ }
288
+ process.stdout.write(`\n\n🔴 verify FAILED in ${secs(wall)}: ${failed.map((r) => r.script).join(", ")}\n`);
289
+ return 1;
290
+ }
291
+
292
+ if (import.meta.main) process.exit(await verify());
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
+ accessMarkCopy,
3
4
  descendantIdsOf,
4
5
  type FileTreeDrop,
5
6
  type FileTreeNode,
@@ -460,3 +461,25 @@ describe('describeDrop', () => {
460
461
  expect(describeDrop(TREE, { kind: 'into', parentId: 'gone' })).toBe('Into this folder');
461
462
  });
462
463
  });
464
+
465
+ describe('accessMarkCopy', () => {
466
+ test('defaults speak collections — hiddenness, per state', () => {
467
+ expect(accessMarkCopy('locked')).toBe('Hidden — unlock to see what is in here');
468
+ expect(accessMarkCopy('private')).toContain('hidden from a locked page');
469
+ });
470
+
471
+ test('🔴 a consumer with its own privacy vocabulary overrides the words', () => {
472
+ // The vault's protected folders wear the same padlock meaning "needs the master
473
+ // password" — a claim of hiddenness would be FALSE there (rows visible, secrets
474
+ // sealed), which is why the words are an argument and not a constant.
475
+ const copy = { private: 'Protected — everything in here needs the master password' };
476
+ expect(accessMarkCopy('private', copy)).toBe(copy.private);
477
+ // An override for one state leaves the other's default standing.
478
+ expect(accessMarkCopy('locked', copy)).toBe('Hidden — unlock to see what is in here');
479
+ });
480
+
481
+ test('normal (and absent) access has nothing to say', () => {
482
+ expect(accessMarkCopy('normal')).toBe('');
483
+ expect(accessMarkCopy(undefined)).toBe('');
484
+ });
485
+ });
@@ -464,6 +464,39 @@ export function describeDrop(nodes: readonly FileTreeNode[], drop: FileTreeDrop
464
464
  return sibling ? `${where} ${sibling.name}` : `${where} this row`;
465
465
  }
466
466
 
467
+ /** The consumer's own words for the 🔒 access mark, per state. See {@link accessMarkCopy}. */
468
+ export interface AccessMarkCopy {
469
+ private?: string;
470
+ locked?: string;
471
+ }
472
+
473
+ /**
474
+ * What the access mark's tooltip SAYS for a given access state.
475
+ *
476
+ * 🔴 The defaults speak collections' vocabulary — "hidden from a locked page" — because
477
+ * that is the app the mark was built for. The mark itself is generic and the words are
478
+ * not: the vault's protected folders wear the same padlock meaning "everything in here
479
+ * needs the master password", and a claim of hiddenness would be false there (the rows
480
+ * are visible; their SECRETS are sealed). So the words are an argument with a default,
481
+ * resolved here rather than inline, where they can be tested without simulating a hover.
482
+ *
483
+ * Empty string for `normal` — a row with no mark has nothing to say.
484
+ */
485
+ export function accessMarkCopy(
486
+ access: FileTreeAccess | undefined,
487
+ copy?: AccessMarkCopy,
488
+ ): string {
489
+ if (access === 'locked') return copy?.locked ?? 'Hidden — unlock to see what is in here';
490
+ if (access === 'private') {
491
+ // Deliberately "this or what is in it": a consumer sets `private` both for a row that
492
+ // is itself private and for a public folder holding private children (collections does
493
+ // exactly that), and a wording that claimed only the first would be false on half the
494
+ // rows it marks.
495
+ return copy?.private ?? 'Private — this, or something in it, is hidden from a locked page';
496
+ }
497
+ return '';
498
+ }
499
+
467
500
  /**
468
501
  * The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
469
502
  *
@@ -12,6 +12,7 @@ import {
12
12
  } from '../demo/devServer';
13
13
  import { DEMO_TSCONFIG, derivePaths, readDemoTsconfig, renderPathsBlock } from '../demo/generatePaths';
14
14
  import pkg from '../package.json';
15
+ import { STAGES } from '../scripts/verify';
15
16
 
16
17
  /**
17
18
  * 🔴 The e2e suite must never be able to sit green while it drives nothing.
@@ -101,7 +102,16 @@ describe('the e2e fixture exists', () => {
101
102
  // Everything above proves the fixture resolves. Only this proves anybody looks:
102
103
  // without it, `e2e` drops back out of the gate and this whole file becomes a
103
104
  // comment about a suite nothing runs.
104
- expect(pkg.scripts.verify, '`verify` must end in the e2e suite').toInclude('bun run e2e');
105
+ //
106
+ // 🔴 `verify` stopped being an `&&` chain on 2026-09-15 and became a dependency
107
+ // graph (`scripts/verify.ts`), so "the script mentions e2e" is no longer where the
108
+ // answer lives. Asking the graph is strictly stronger than asking the string was:
109
+ // `verifyGraph.spec.ts` additionally pins that every OTHER stage of the old chain
110
+ // is still in it, which the `toInclude` never checked for any of them.
111
+ expect(
112
+ STAGES.map((s) => s.script),
113
+ '`verify` must still run the e2e suite',
114
+ ).toContain('e2e');
105
115
  expect(pkg.scripts.e2e).toInclude('e2e/playwright.config.ts');
106
116
  });
107
117
  });
@@ -71,3 +71,37 @@ describe('TagsInput', () => {
71
71
  expect(json(container)).toEqual(['late']);
72
72
  });
73
73
  });
74
+
75
+ describe('suggestions', () => {
76
+ test('offers a datalist of suggestions, minus what is already committed', () => {
77
+ const { container } = render(
78
+ <TagsInput value={['work']} onChange={() => undefined} suggestions={['work', 'bank']} />,
79
+ );
80
+ const input = container.querySelector('input') as HTMLInputElement;
81
+ const list = container.querySelector('datalist') as HTMLDataListElement;
82
+ expect(list).not.toBeNull();
83
+ expect(input.getAttribute('list')).toBe(list.id);
84
+ const offered = [...list.querySelectorAll('option')].map((o) => o.getAttribute('value'));
85
+ expect(offered).toEqual(['bank']);
86
+ });
87
+
88
+ test('🔴 suppression is case-insensitive — "Bank" committed hides the suggestion "bank"', () => {
89
+ // mergeTags dedupes case-SENSITIVELY, so offering "bank" back beside a committed
90
+ // "Bank" would commit a second, differently-spelled chip — the exact drift a
91
+ // suggestion list exists to prevent.
92
+ const { container } = render(
93
+ <TagsInput value={['Bank']} onChange={() => undefined} suggestions={['bank', 'tax']} />,
94
+ );
95
+ const offered = [...container.querySelectorAll('datalist option')].map((o) =>
96
+ o.getAttribute('value'),
97
+ );
98
+ expect(offered).toEqual(['tax']);
99
+ });
100
+
101
+ test('no suggestions → no datalist and no dangling list attribute', () => {
102
+ const { container } = render(<TagsInput value={[]} onChange={() => undefined} />);
103
+ expect(container.querySelector('datalist')).toBeNull();
104
+ const input = container.querySelector('input') as HTMLInputElement;
105
+ expect(input.getAttribute('list')).toBeNull();
106
+ });
107
+ });
@@ -1,5 +1,5 @@
1
1
  import { X } from 'lucide-react';
2
- import { type KeyboardEvent, useState } from 'react';
2
+ import { type KeyboardEvent, useId, useState } from 'react';
3
3
  import { cn } from '../lib/cn';
4
4
  import { CONTROL_MIN_HEIGHT_CLASS, type ControlSize } from '../lib/field';
5
5
  import { IconButton } from './IconButton';
@@ -14,6 +14,13 @@ export interface TagsInputProps {
14
14
  disabled?: boolean;
15
15
  /** Cap the number of tags; the input hides once reached. */
16
16
  maxTags?: number;
17
+ /**
18
+ * Tags to offer as you type — the vocabulary already in use, so a re-tag matches
19
+ * the first spelling instead of minting "Bank"/"bank"/"banking" three ways. A
20
+ * native `<datalist>` (the browser owns the dropdown and its accessibility), and
21
+ * tags already committed on this value are not re-offered. Omit for none.
22
+ */
23
+ suggestions?: readonly string[];
17
24
  id?: string;
18
25
  name?: string;
19
26
  'aria-invalid'?: true | undefined;
@@ -54,6 +61,7 @@ export function TagsInput({
54
61
  maxTags,
55
62
  id,
56
63
  name,
64
+ suggestions,
57
65
  'aria-invalid': ariaInvalid,
58
66
  'aria-label': ariaLabel,
59
67
  'aria-describedby': ariaDescribedBy,
@@ -61,7 +69,13 @@ export function TagsInput({
61
69
  size = 'md',
62
70
  }: TagsInputProps) {
63
71
  const [draft, setDraft] = useState('');
72
+ const listId = useId();
64
73
  const atMax = maxTags != null && value.length >= maxTags;
74
+ // Case-insensitive: a committed "Bank" must suppress the suggestion "bank" —
75
+ // offering it back commits nothing (mergeTags dedupes case-sensitively, so it
76
+ // WOULD land as a second chip) and reads as the field suggesting a duplicate.
77
+ const committed = new Set(value.map((tag) => tag.toLowerCase()));
78
+ const offered = (suggestions ?? []).filter((tag) => !committed.has(tag.toLowerCase()));
65
79
 
66
80
  const commit = (raw: string) => {
67
81
  const next = mergeTags(value, [raw]);
@@ -121,6 +135,7 @@ export function TagsInput({
121
135
  aria-label={ariaLabel}
122
136
  aria-describedby={ariaDescribedBy}
123
137
  autoComplete='off'
138
+ list={offered.length > 0 ? listId : undefined}
124
139
  onChange={(e) => {
125
140
  const v = e.target.value;
126
141
  if (v.includes(',')) {
@@ -141,6 +156,13 @@ export function TagsInput({
141
156
  className='min-w-24 flex-1 bg-transparent py-0.5 text-base text-field-fg outline-none placeholder:text-field-placeholder sm:text-sm'
142
157
  />
143
158
  ) : null}
159
+ {offered.length > 0 ? (
160
+ <datalist id={listId}>
161
+ {offered.map((tag) => (
162
+ <option key={tag} value={tag} />
163
+ ))}
164
+ </datalist>
165
+ ) : null}
144
166
  </div>
145
167
  );
146
168
  }
@@ -68,6 +68,8 @@ import type {
68
68
  } from 'react';
69
69
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
70
70
  import {
71
+ type AccessMarkCopy,
72
+ accessMarkCopy,
71
73
  describeDrop,
72
74
  descendantIdsOf,
73
75
  type DropOffer,
@@ -225,6 +227,19 @@ export interface FileTreeProps {
225
227
  /** Whether an in-flight external drag is one this tree wants. Default: any drag. */
226
228
  acceptsExternalDrag?: (event: ReactDragEvent) => boolean;
227
229
 
230
+ /**
231
+ * What the 🔒 access mark SAYS, per access state — hover text on the mark a row
232
+ * with `access: 'private' | 'locked'` draws.
233
+ *
234
+ * 🔴 The defaults speak collections' vocabulary ("hidden from a locked page"),
235
+ * because that is the app the mark was built for — but the mark itself is generic
236
+ * and the words are not. The vault's protected folders wear the same padlock
237
+ * meaning "everything in here needs the master password", and a tooltip claiming
238
+ * they are hidden would be false: the rows are visible, their SECRETS are sealed.
239
+ * An app whose privacy model has its own name passes its own words.
240
+ */
241
+ accessCopy?: AccessMarkCopy;
242
+
228
243
  /** Narrow the tree to matching rows, keeping the folders on the way to each. */
229
244
  filter?: string;
230
245
  /** What to show when there are no rows — differs between "nothing here" and "no match". */
@@ -331,6 +346,7 @@ export function FileTree({
331
346
  renderAccessory,
332
347
  onDropExternal,
333
348
  acceptsExternalDrag,
349
+ accessCopy,
334
350
  filter,
335
351
  empty = 'Nothing here yet.',
336
352
  ariaLabel = 'Files',
@@ -1255,18 +1271,10 @@ export function FileTree({
1255
1271
  is the twisty's, and a folder losing its arrow would lose the control that
1256
1272
  opens it) and never moves with how many actions a consumer wired up.
1257
1273
  */}
1274
+ {/* The mark's words are the consumer's when it has its own privacy
1275
+ vocabulary — see accessMarkCopy for why the defaults are collections'. */}
1258
1276
  {node.access === 'private' || node.access === 'locked' ? (
1259
- <Tooltip
1260
- content={
1261
- node.access === 'locked'
1262
- ? 'Hidden — unlock to see what is in here'
1263
- : // Deliberately "this or what is in it": a consumer sets `private` both
1264
- // for a row that is itself private and for a public folder holding
1265
- // private children (collections does exactly that), and a wording that
1266
- // claimed only the first would be false on half the rows it marks.
1267
- 'Private — this, or something in it, is hidden from a locked page'
1268
- }
1269
- >
1277
+ <Tooltip content={accessMarkCopy(node.access, accessCopy)}>
1270
1278
  {/* Not `aria-hidden`: this is the one thing on the row a screen reader
1271
1279
  cannot work out from anything else, and the name alone would announce a
1272
1280
  private folder identically to a public one. */}
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // Its own subpath so an app with a flat list never pulls the gesture in.
15
15
  export {
16
+ type AccessMarkCopy,
17
+ accessMarkCopy,
16
18
  descendantIdsOf,
17
19
  type DropOffer,
18
20
  type FileTreeAccess,