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.
- package/dist/core/file-tree/fileTreeModel.d.ts +18 -0
- package/dist/core/file-tree/fileTreeModel.d.ts.map +1 -1
- package/dist/core/file-tree/fileTreeModel.js +24 -0
- package/dist/core/file-tree/fileTreeModel.js.map +1 -1
- package/dist/react/components/TagsInput.d.ts +8 -1
- package/dist/react/components/TagsInput.d.ts.map +1 -1
- package/dist/react/components/TagsInput.js +10 -4
- package/dist/react/components/TagsInput.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +14 -2
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +3 -9
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +1 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +1 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/package.json +7 -3
- package/scripts/verify.ts +292 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +23 -0
- package/src/core/file-tree/fileTreeModel.ts +33 -0
- package/src/demoFixture.spec.ts +11 -1
- package/src/react/components/TagsInput.spec.tsx +34 -0
- package/src/react/components/TagsInput.tsx +23 -1
- package/src/react/file-tree/FileTree.tsx +19 -11
- package/src/react/file-tree/index.ts +2 -0
- package/src/verifyGraph.spec.ts +181 -0
|
@@ -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
|
*
|
package/src/demoFixture.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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. */}
|