criteria-review 0.3.0 → 0.3.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/agents/README.md +18 -8
- package/agents/skills/claude-code/criteria-lookup/SKILL.md +10 -10
- package/bin/criteria-review.js +35 -0
- package/docs/help/01-getting-started.md +18 -12
- package/docs/standard/emit-contract.md +30 -3
- package/package.json +1 -1
- package/public/app.js +135 -24
- package/public/index.html +12 -6
- package/public/styles.css +69 -4
package/agents/README.md
CHANGED
|
@@ -15,14 +15,14 @@ The whole protocol is four ideas. Everything in `skills/` is these, spelled out
|
|
|
15
15
|
|
|
16
16
|
### 1. The queue is ordered, and the order is not yours to change
|
|
17
17
|
|
|
18
|
-
`criteria-review queue` returns what still needs a human decision, most important first: flagged
|
|
18
|
+
`npx criteria-review queue` returns what still needs a human decision, most important first: flagged
|
|
19
19
|
items, then grouped by document with the riskiest documents leading. The page uses the same
|
|
20
20
|
ordering from the same module. Reordering it in conversation leaves the architect looking at two
|
|
21
21
|
different "most important" and unable to tell which is right.
|
|
22
22
|
|
|
23
23
|
### 2. Show the scenario, do not summarise it
|
|
24
24
|
|
|
25
|
-
`criteria-review show <ID>` prints the title, status, persona, flags, intent, the Given/When/Then
|
|
25
|
+
`npx criteria-review show <ID>` prints the title, status, persona, flags, intent, the Given/When/Then
|
|
26
26
|
clauses, and every note. Present all of it, including **an intent line that says it is missing**.
|
|
27
27
|
A scenario whose intent was never sourced is the most dangerous kind, because a test citing it can
|
|
28
28
|
never disagree with the software, and omitting the line makes it look fine.
|
|
@@ -35,15 +35,15 @@ requirement.
|
|
|
35
35
|
Prose in reply to a presented scenario is a note, written verbatim:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
criteria-review note <ID> --message "<their words>"
|
|
38
|
+
npx criteria-review note <ID> --message "<their words>"
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Status moves are separate, named, and never taken unasked:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
criteria-review accept <ID> # what the software SHOULD do
|
|
45
|
-
criteria-review verify <ID> --commit <sha> # a person watched it happen
|
|
46
|
-
criteria-review reject <ID> # back to derived
|
|
44
|
+
npx criteria-review accept <ID> # what the software SHOULD do
|
|
45
|
+
npx criteria-review verify <ID> --commit <sha> # a person watched it happen
|
|
46
|
+
npx criteria-review reject <ID> # back to derived
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
`verify` resolves the commit from the project under review and reports which one it recorded, so a
|
|
@@ -77,14 +77,24 @@ not in a session that can end.
|
|
|
77
77
|
| `@looknow` | an agent, via `ask` or `flag` | the architect must look at this |
|
|
78
78
|
| `@review` | the architect writing a note | an agent must act on what they wrote |
|
|
79
79
|
|
|
80
|
-
An agent asks with `criteria-review ask <ID> --message "..."`, which puts the question beside the
|
|
80
|
+
An agent asks with `npx criteria-review ask <ID> --message "..."`, which puts the question beside the
|
|
81
81
|
scenario rather than in a transcript that scrolls away. It reads replies with
|
|
82
|
-
`criteria-review notes`, acts, then runs `criteria-review handled <ID>`, which clears the
|
|
82
|
+
`npx criteria-review notes`, acts, then runs `npx criteria-review handled <ID>`, which clears the
|
|
83
83
|
discussion and hands the item back for re-review.
|
|
84
84
|
|
|
85
85
|
**Never clear `@review` by hand.** It is the architect's request, and `handled` is the only thing
|
|
86
86
|
entitled to retire it.
|
|
87
87
|
|
|
88
|
+
### A note on invoking it
|
|
89
|
+
|
|
90
|
+
Commands here use `npx criteria-review`, which resolves a project's pinned copy first and works
|
|
91
|
+
when there is no package context at all - the situation an agent is usually in.
|
|
92
|
+
|
|
93
|
+
**A GATE must not use `npx`.** It fetches from the registry when the package is absent, which puts
|
|
94
|
+
a network download inside the check and lets a missing dependency read as a pass. A gate resolves
|
|
95
|
+
`node_modules/.bin/criteria-review`, falls back to `PATH`, and fails when neither exists. See
|
|
96
|
+
`docs/standard/emit-contract.md`.
|
|
97
|
+
|
|
88
98
|
### And one standing rule
|
|
89
99
|
|
|
90
100
|
**A session that edits an acceptance document regenerates before running tests.** Otherwise the
|
|
@@ -17,7 +17,7 @@ open page updates itself, because the server watches the criteria directories.
|
|
|
17
17
|
## Looking one thing up
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
criteria-review show <ID> [project]
|
|
20
|
+
npx criteria-review show <ID> [project]
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Present what it prints, in full, as prose the architect can read without decoding:
|
|
@@ -55,7 +55,7 @@ every time, and the whole point is that it does not.
|
|
|
55
55
|
When the architect answers with prose rather than a command, that prose is the note. Write it:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
criteria-review note <ID> [project] --message "<their words>"
|
|
58
|
+
npx criteria-review note <ID> [project] --message "<their words>"
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
**Use their words, not a summary.** The note is sourced intent, which is the one thing that cannot
|
|
@@ -119,7 +119,7 @@ If the finding is worth keeping - a real contradiction, or a clause nobody can t
|
|
|
119
119
|
record it. It is the agent's finding, so it goes in as the agent:
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
|
-
criteria-review ask <ID> [project] --message "DRIFT: <clause> is contradicted by <file:line>: <what the code does>" --as <who>
|
|
122
|
+
npx criteria-review ask <ID> [project] --message "DRIFT: <clause> is contradicted by <file:line>: <what the code does>" --as <who>
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
That raises `@looknow` rather than `@review`, which is correct: the finding is a question for the
|
|
@@ -138,7 +138,7 @@ up. Accepting it is correct even though the software fails it, because `accepted
|
|
|
138
138
|
intent, not about the implementation:
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
|
-
criteria-review accept <ID> --message "WRITE THE CODE TO MATCH: <what the software does instead, in their words>"
|
|
141
|
+
npx criteria-review accept <ID> --message "WRITE THE CODE TO MATCH: <what the software does instead, in their words>"
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
Then say plainly how the gap stays visible: a journey citing this scenario now goes **red**, which
|
|
@@ -151,7 +151,7 @@ inferred, or it describes an intention nobody holds. Send it back to `derived`,
|
|
|
151
151
|
what that status means - written up from the delivered software, describing what it appears to do:
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
|
-
criteria-review reject <ID> --message "WRITE THE TEST TO MATCH THE CODE: <what the software actually does, in their words>"
|
|
154
|
+
npx criteria-review reject <ID> --message "WRITE THE TEST TO MATCH THE CODE: <what the software actually does, in their words>"
|
|
155
155
|
```
|
|
156
156
|
|
|
157
157
|
Both raise `@review`, which hands the item to an agent. Report which one was recorded and what
|
|
@@ -173,7 +173,7 @@ mid-pass is how a review of seventy-five scenarios becomes an afternoon on the f
|
|
|
173
173
|
## Walking the queue
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
|
-
criteria-review queue --limit 10
|
|
176
|
+
npx criteria-review queue --limit 10
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
Ordered exactly as the page orders it: flagged items first, then grouped by document with the
|
|
@@ -215,11 +215,11 @@ If **you** need the architect's ruling on something and they are not in the conv
|
|
|
215
215
|
question belongs beside the scenario rather than in a transcript that scrolls away:
|
|
216
216
|
|
|
217
217
|
```bash
|
|
218
|
-
criteria-review ask <ID> [project] --message "the question" --as <who>
|
|
218
|
+
npx criteria-review ask <ID> [project] --message "the question" --as <who>
|
|
219
219
|
```
|
|
220
220
|
|
|
221
221
|
That raises `@looknow` and puts the question in their queue. Read what came back with
|
|
222
|
-
`criteria-review notes`, act, then `criteria-review handled <ID>` to hand the item back for
|
|
222
|
+
`npx criteria-review notes`, act, then `npx criteria-review handled <ID>` to hand the item back for
|
|
223
223
|
re-review.
|
|
224
224
|
|
|
225
225
|
## Ambiguity
|
|
@@ -230,7 +230,7 @@ exists to prevent.
|
|
|
230
230
|
|
|
231
231
|
## Related
|
|
232
232
|
|
|
233
|
-
- `criteria-review guide` and the standard in `docs/standard/` for what the statuses mean and how
|
|
233
|
+
- `npx criteria-review guide` and the standard in `docs/standard/` for what the statuses mean and how
|
|
234
234
|
criteria are written.
|
|
235
|
-
- The visual tool (`criteria-review here`) for a long pass beside the recordings, which is still
|
|
235
|
+
- The visual tool (`npx criteria-review here`) for a long pass beside the recordings, which is still
|
|
236
236
|
the better surface when there is video to watch.
|
package/bin/criteria-review.js
CHANGED
|
@@ -17,6 +17,7 @@ import { ejectStandard } from '../src/standard.js';
|
|
|
17
17
|
import { isUntracked, needsReview } from '../src/parse.js';
|
|
18
18
|
import { addNote, setStatus, ACTOR_ARCHITECT } from '../src/write.js';
|
|
19
19
|
import { orderQueue } from '../public/queue-order.js';
|
|
20
|
+
import { STANDARD_VERSION } from '../src/version.js';
|
|
20
21
|
import { buildModel, RENDERERS } from '../src/emit.js';
|
|
21
22
|
import { scanAll as scanAllRoots } from '../src/scan.js';
|
|
22
23
|
import {
|
|
@@ -200,6 +201,7 @@ function usage() {
|
|
|
200
201
|
criteria-review flag <ID> mark it LOOK NOW
|
|
201
202
|
criteria-review unflag <ID> clear LOOK NOW
|
|
202
203
|
criteria-review standard eject <dir> copy the standard into this project to own it
|
|
204
|
+
criteria-review version package and standard version (also --version)
|
|
203
205
|
criteria-review guide [skill] print the agent instruction set
|
|
204
206
|
criteria-review queue what needs a decision here, most important first
|
|
205
207
|
--limit <n> how many to list (default 10)
|
|
@@ -561,6 +563,19 @@ async function main() {
|
|
|
561
563
|
|
|
562
564
|
if (cmd === 'help' || args._.includes('--help') || args._.includes('-h')) return usage();
|
|
563
565
|
|
|
566
|
+
// A gate pins an exact version, so it must be able to report which version ran. Without
|
|
567
|
+
// this an unrecognised flag fell through to STARTING THE REVIEW SERVER and died on
|
|
568
|
+
// EADDRINUSE - a version query that boots a web server is the wrong answer twice over.
|
|
569
|
+
if (cmd === 'version' || args._.includes('--version') || args._.includes('-v')) {
|
|
570
|
+
const pkg = JSON.parse(
|
|
571
|
+
await readFile(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
|
|
572
|
+
);
|
|
573
|
+
// Both numbers, because they answer different questions: the package is what you
|
|
574
|
+
// pinned and downloaded, the standard is what your criteria conform to.
|
|
575
|
+
console.log(`criteria-review ${pkg.version} (standard ${STANDARD_VERSION})`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
564
579
|
if (cmd === 'add') {
|
|
565
580
|
const path = resolve(args._[1] ?? process.cwd());
|
|
566
581
|
const name = args._[2] ?? basename(path);
|
|
@@ -829,6 +844,26 @@ async function main() {
|
|
|
829
844
|
|
|
830
845
|
if (cmd === 'list') return cmdList(roots);
|
|
831
846
|
|
|
847
|
+
// Anything that looks like a command or a flag but was not recognised is an error, not
|
|
848
|
+
// an instruction to open the review UI. The old fallthrough turned a typo - or a flag a
|
|
849
|
+
// newer version understands - into a server start, which in a pipeline is a hang and on
|
|
850
|
+
// a desk is EADDRINUSE from the server already running.
|
|
851
|
+
// `serve` is how a backgrounded server is spawned (see cmdStart), and no args at all is
|
|
852
|
+
// the foreground UI. Everything else reaching here is unrecognised.
|
|
853
|
+
//
|
|
854
|
+
// NAMED EXPLICITLY rather than left to fall through, which is what made the first
|
|
855
|
+
// version of this guard reject the tool's own subprocess and break `start`, `here` and
|
|
856
|
+
// `restart` in a published release. A guard that does not know the commands it is
|
|
857
|
+
// guarding is a guess.
|
|
858
|
+
if (cmd !== undefined && cmd !== 'serve') {
|
|
859
|
+
throw new Error(`unknown command "${cmd}". Run: criteria-review help`);
|
|
860
|
+
}
|
|
861
|
+
if (args._.some((a) => a.startsWith('-'))) {
|
|
862
|
+
throw new Error(
|
|
863
|
+
`unknown option "${args._.find((a) => a.startsWith('-'))}". Run: criteria-review help`
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
832
867
|
const cfg = await loadConfig();
|
|
833
868
|
const mediaRoot = await resolveMediaRoot(cfg.mediaRoot);
|
|
834
869
|
// Re-read the config per request so `here` takes effect without a restart.
|
|
@@ -33,7 +33,7 @@ Everything else in the tool exists to make one of those three steps cheaper.
|
|
|
33
33
|
From inside any working tree:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
criteria-review here
|
|
36
|
+
npx criteria-review here
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
That registers the tree, starts the server if it is down, and opens the page on this project.
|
|
@@ -43,7 +43,7 @@ repository without checking anything first.
|
|
|
43
43
|
Give it a name when the directory name is not distinctive:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
criteria-review here . playwright-worktree
|
|
46
|
+
npx criteria-review here . playwright-worktree
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Several trees can be registered at once, **including two checkouts of the same repository**. A
|
|
@@ -65,9 +65,15 @@ The keyboard drives it. The bar along the bottom of the page is the whole cheat
|
|
|
65
65
|
| `n` | write a note on it |
|
|
66
66
|
| `f` | fullscreen the recording |
|
|
67
67
|
| `r` | reload from disk |
|
|
68
|
+
| `t` | switch between the flat queue and the grouped tree |
|
|
68
69
|
| `/` | jump to the filter box |
|
|
69
70
|
| `s` | switch between the queue and this reading tab |
|
|
70
71
|
|
|
72
|
+
Two ways to read the list, for two different questions. **Flat** is the queue: one ordered run,
|
|
73
|
+
riskiest first, which is what a review pass wants. **Tree** groups by document and feature, which
|
|
74
|
+
is what "where does this live, and what else is near it" wants. Groups collapse, and moving the
|
|
75
|
+
selection into a collapsed group opens it rather than selecting something you cannot see.
|
|
76
|
+
|
|
71
77
|
**Every action writes straight into the source markdown**, next to the scenario it is about.
|
|
72
78
|
There is no database. Your notes are sourced intent, which is the one thing that cannot be
|
|
73
79
|
reconstructed from the code later, so they belong in git beside what they annotate.
|
|
@@ -112,19 +118,19 @@ The loop closes like this, and each step hands the item to exactly one side:
|
|
|
112
118
|
|
|
113
119
|
1. A session asks a question beside the scenario it is about:
|
|
114
120
|
```bash
|
|
115
|
-
criteria-review ask CO-TYPES-006 myproject --message "Blank cell, or the word none?" --as claude
|
|
121
|
+
npx criteria-review ask CO-TYPES-006 myproject --message "Blank cell, or the word none?" --as claude
|
|
116
122
|
```
|
|
117
123
|
That writes the question as a note and raises `@looknow`.
|
|
118
124
|
2. You answer in the same pane by writing a note. That raises `@review` and clears `@looknow`.
|
|
119
|
-
3. The session reads it with `criteria-review notes`, acts, and runs
|
|
120
|
-
`criteria-review handled CO-TYPES-006`, which removes the discussion and raises `@looknow` so
|
|
125
|
+
3. The session reads it with `npx criteria-review notes`, acts, and runs
|
|
126
|
+
`npx criteria-review handled CO-TYPES-006`, which removes the discussion and raises `@looknow` so
|
|
121
127
|
the item comes back to you for re-review.
|
|
122
128
|
|
|
123
129
|
**Acting on a flagged scenario clears the flag.** Accepting it, marking it verified, sending it
|
|
124
130
|
back, writing a note - each one is you looking, so none of them leaves a badge behind demanding
|
|
125
131
|
attention you have already given.
|
|
126
132
|
|
|
127
|
-
There is also `criteria-review push`, which changes what an open page is showing and never
|
|
133
|
+
There is also `npx criteria-review push`, which changes what an open page is showing and never
|
|
128
134
|
touches disk. Use it when someone is at the screen; use a flag when the finding should wait for
|
|
129
135
|
the next review pass.
|
|
130
136
|
|
|
@@ -133,8 +139,8 @@ the next review pass.
|
|
|
133
139
|
The last step, and the one that stops the criteria and the tests drifting apart:
|
|
134
140
|
|
|
135
141
|
```bash
|
|
136
|
-
criteria-review generate . --out tests/e2e/support/scenarios.generated.ts
|
|
137
|
-
criteria-review generate . --out tests/e2e/support/scenarios.generated.ts --check
|
|
142
|
+
npx criteria-review generate . --out tests/e2e/support/scenarios.generated.ts
|
|
143
|
+
npx criteria-review generate . --out tests/e2e/support/scenarios.generated.ts --check
|
|
138
144
|
```
|
|
139
145
|
|
|
140
146
|
A journey's step names are supposed to be the scenario's own Given/When/Then wording. Typing
|
|
@@ -152,10 +158,10 @@ Full detail in **The emit contract**, and the step-by-step in **Adopting the sta
|
|
|
152
158
|
## Housekeeping
|
|
153
159
|
|
|
154
160
|
```bash
|
|
155
|
-
criteria-review status # exits 1 when down, 0 when up
|
|
156
|
-
criteria-review list # a summary in the terminal, touches nothing
|
|
157
|
-
criteria-review projects # what is registered, and where
|
|
158
|
-
criteria-review stop | restart
|
|
161
|
+
npx criteria-review status # exits 1 when down, 0 when up
|
|
162
|
+
npx criteria-review list # a summary in the terminal, touches nothing
|
|
163
|
+
npx criteria-review projects # what is registered, and where
|
|
164
|
+
npx criteria-review stop | restart
|
|
159
165
|
```
|
|
160
166
|
|
|
161
167
|
The server exits on its own after two hours with no page open, so there is nothing to remember to
|
|
@@ -11,13 +11,13 @@ bump rather than quietly. See `../decisions.md` D-003, D-004, D-005.
|
|
|
11
11
|
```bash
|
|
12
12
|
# Write the artefact. The consumer's package script is where this lives, so it travels
|
|
13
13
|
# with the repository and works on a machine that has never registered anything.
|
|
14
|
-
criteria-review generate . --out tests/e2e/support/scenarios.generated.ts
|
|
14
|
+
npx criteria-review generate . --out tests/e2e/support/scenarios.generated.ts
|
|
15
15
|
|
|
16
16
|
# Verify it matches the documents, change nothing, exit non-zero if it does not.
|
|
17
|
-
criteria-review generate . --out tests/e2e/support/scenarios.generated.ts --check
|
|
17
|
+
npx criteria-review generate . --out tests/e2e/support/scenarios.generated.ts --check
|
|
18
18
|
|
|
19
19
|
# The neutral artefact, for a consumer that is not TypeScript.
|
|
20
|
-
criteria-review generate . --out build/scenarios.json --format json
|
|
20
|
+
npx criteria-review generate . --out build/scenarios.json --format json
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
`--format` defaults from the output extension.
|
|
@@ -46,6 +46,33 @@ Three places, each doing a different job:
|
|
|
46
46
|
tool is needed to regenerate and to verify, never to compile. A build that could not run without
|
|
47
47
|
a locally-installed personal tool would be an unacceptable dependency for a project.
|
|
48
48
|
|
|
49
|
+
### Resolving the binary in a gate, which is NOT `npx`
|
|
50
|
+
|
|
51
|
+
A gate must resolve the project's own pinned copy, fall back to whatever is on `PATH`, and
|
|
52
|
+
**fail loudly when neither exists**:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
BIN="$(git rev-parse --show-toplevel)/<pkg-dir>/node_modules/.bin/criteria-review"
|
|
56
|
+
[ -x "$BIN" ] || BIN="$(command -v criteria-review || true)"
|
|
57
|
+
[ -n "$BIN" ] || { echo "criteria-review not installed; the criteria gate cannot run" >&2; exit 1; }
|
|
58
|
+
"$BIN" generate . --out ... --check
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Do not use `npx` here.** It fetches from the registry when the package is absent, which puts a
|
|
62
|
+
network download inside the gate and lets a missing devDependency read as a pass - the check
|
|
63
|
+
appears to run, against a version nobody pinned. `npx` is right for ad-hoc use and for an agent
|
|
64
|
+
that may have no package context at all; it is wrong for anything whose job is to fail.
|
|
65
|
+
|
|
66
|
+
The same reason rules out a bare `command -v criteria-review` guard: a project that pinned the
|
|
67
|
+
tool as a devDependency has no such binary on `PATH`, so the guard skips the gate on exactly the
|
|
68
|
+
machine that configured it correctly.
|
|
69
|
+
|
|
70
|
+
**Report the version the gate ran**, so a failure names it:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
"$BIN" --version # criteria-review 0.3.2 (standard 1.1.2)
|
|
74
|
+
```
|
|
75
|
+
|
|
49
76
|
## What is emitted
|
|
50
77
|
|
|
51
78
|
Only scenarios that can be cited. A scenario with no id or no status is excluded, because
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -35,6 +35,12 @@ const state = {
|
|
|
35
35
|
tab: 'review',
|
|
36
36
|
docs: [],
|
|
37
37
|
doc: null,
|
|
38
|
+
// Flat is the queue: one ordered list, riskiest first, which is what a review PASS
|
|
39
|
+
// wants. Tree is the map: which document holds what, and which feature inside it,
|
|
40
|
+
// which is what someone answering "where does this live" wants. Different questions,
|
|
41
|
+
// so both, rather than a compromise that serves neither.
|
|
42
|
+
tree: false,
|
|
43
|
+
collapsed: new Set(),
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -67,6 +73,8 @@ function saveViewState() {
|
|
|
67
73
|
selected: scenarioKey(state.view[state.index]),
|
|
68
74
|
tab: state.tab,
|
|
69
75
|
doc: state.doc,
|
|
76
|
+
tree: state.tree,
|
|
77
|
+
collapsed: [...state.collapsed],
|
|
70
78
|
})
|
|
71
79
|
);
|
|
72
80
|
} catch {
|
|
@@ -93,6 +101,8 @@ function restoreViewState() {
|
|
|
93
101
|
// document who refreshes should be back on it, not at the top of the queue.
|
|
94
102
|
state.doc = saved.doc ?? null;
|
|
95
103
|
state.savedTab = saved.tab === 'standard' ? 'standard' : 'review';
|
|
104
|
+
state.tree = saved.tree === true;
|
|
105
|
+
state.collapsed = new Set(Array.isArray(saved.collapsed) ? saved.collapsed : []);
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
/**
|
|
@@ -237,26 +247,9 @@ function renderCounts() {
|
|
|
237
247
|
.join('');
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// whether the next item continues the current feature or starts a new one.
|
|
244
|
-
let lastDoc = null;
|
|
245
|
-
$('#list').innerHTML = state.view
|
|
246
|
-
.map((s, i) => {
|
|
247
|
-
const doc = `${s.project} ${s.source}`;
|
|
248
|
-
let header = '';
|
|
249
|
-
if (doc !== lastDoc) {
|
|
250
|
-
lastDoc = doc;
|
|
251
|
-
const remaining = state.view.filter((x) => `${x.project} ${x.source}` === doc).length;
|
|
252
|
-
header = `<div class="doc-head">
|
|
253
|
-
<span class="doc-name">${escapeHtml(s.source.split('/').slice(-1)[0])}</span>
|
|
254
|
-
<span class="doc-meta">${escapeHtml(s.project)} · ${remaining}</span>
|
|
255
|
-
</div>`;
|
|
256
|
-
}
|
|
257
|
-
return (
|
|
258
|
-
header +
|
|
259
|
-
`<button class="row ${i === state.index ? 'active' : ''} ${
|
|
250
|
+
/** One scenario row. Shared by both views so they cannot drift in how a scenario reads. */
|
|
251
|
+
function scenarioRow(s, i, depth = 0) {
|
|
252
|
+
return `<button class="row depth-${depth} ${i === state.index ? 'active' : ''} ${
|
|
260
253
|
state.highlight.has(s.id) ? 'highlighted' : ''
|
|
261
254
|
}" data-i="${i}">
|
|
262
255
|
<span class="row-top">${flagged(s) ? '<span class="looknow">LOOK</span>' : ''}${
|
|
@@ -271,10 +264,90 @@ function renderList() {
|
|
|
271
264
|
}">!</span>`
|
|
272
265
|
}</span>
|
|
273
266
|
<span class="row-title">${escapeHtml(s.title)}</span>
|
|
274
|
-
</button
|
|
275
|
-
|
|
267
|
+
</button>`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The queue: one ordered list, with a header where the document changes. */
|
|
271
|
+
function renderFlat() {
|
|
272
|
+
// A header whenever the document changes, so the batch boundary is visible. Without
|
|
273
|
+
// it the grouping is real but invisible, and a reviewer cannot tell whether the next
|
|
274
|
+
// item continues the current feature or starts a new one.
|
|
275
|
+
let lastDoc = null;
|
|
276
|
+
return state.view
|
|
277
|
+
.map((s, i) => {
|
|
278
|
+
const doc = `${s.project} ${s.source}`;
|
|
279
|
+
let header = '';
|
|
280
|
+
if (doc !== lastDoc) {
|
|
281
|
+
lastDoc = doc;
|
|
282
|
+
const remaining = state.view.filter((x) => `${x.project} ${x.source}` === doc).length;
|
|
283
|
+
header = `<div class="doc-head">
|
|
284
|
+
<span class="doc-name">${escapeHtml(s.source.split('/').slice(-1)[0])}</span>
|
|
285
|
+
<span class="doc-meta">${escapeHtml(s.project)} · ${remaining}</span>
|
|
286
|
+
</div>`;
|
|
287
|
+
}
|
|
288
|
+
return header + scenarioRow(s, i);
|
|
276
289
|
})
|
|
277
290
|
.join('');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The map: project, document, feature, scenario.
|
|
295
|
+
*
|
|
296
|
+
* Groups keep the order of FIRST APPEARANCE in the queue rather than sorting
|
|
297
|
+
* alphabetically, so the riskiest document is still at the top and the two views agree
|
|
298
|
+
* about what matters most. A tree that reordered the queue would quietly disagree with
|
|
299
|
+
* every other surface about where to start.
|
|
300
|
+
*/
|
|
301
|
+
function renderTree() {
|
|
302
|
+
const tree = new Map();
|
|
303
|
+
state.view.forEach((s, i) => {
|
|
304
|
+
const docKey = `${s.project}|${s.source}`;
|
|
305
|
+
if (!tree.has(docKey)) tree.set(docKey, { scenario: s, features: new Map() });
|
|
306
|
+
const featureKey = s.feature ?? '';
|
|
307
|
+
const features = tree.get(docKey).features;
|
|
308
|
+
if (!features.has(featureKey)) features.set(featureKey, []);
|
|
309
|
+
features.get(featureKey).push([s, i]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const caret = (open) => `<span class="caret">${open ? '▾' : '▸'}</span>`;
|
|
313
|
+
const out = [];
|
|
314
|
+
|
|
315
|
+
for (const [docKey, { scenario, features }] of tree) {
|
|
316
|
+
const docOpen = !state.collapsed.has(docKey);
|
|
317
|
+
const count = [...features.values()].reduce((n, list) => n + list.length, 0);
|
|
318
|
+
out.push(
|
|
319
|
+
`<div class="doc-head tree-head" data-group="${escapeHtml(docKey)}">
|
|
320
|
+
${caret(docOpen)}
|
|
321
|
+
<span class="doc-name">${escapeHtml(scenario.source.split('/').slice(-1)[0])}</span>
|
|
322
|
+
<span class="doc-meta">${escapeHtml(scenario.project)} · ${count}</span>
|
|
323
|
+
</div>`
|
|
324
|
+
);
|
|
325
|
+
if (!docOpen) continue;
|
|
326
|
+
|
|
327
|
+
for (const [featureKey, list] of features) {
|
|
328
|
+
const featKey = `${docKey}|${featureKey}`;
|
|
329
|
+
const featOpen = !state.collapsed.has(featKey);
|
|
330
|
+
// A document with no Feature headings would otherwise grow a nameless level that
|
|
331
|
+
// costs a click and says nothing.
|
|
332
|
+
const named = featureKey !== '';
|
|
333
|
+
if (named) {
|
|
334
|
+
out.push(
|
|
335
|
+
`<div class="feature-head" data-group="${escapeHtml(featKey)}">
|
|
336
|
+
${caret(featOpen)}
|
|
337
|
+
<span>${escapeHtml(featureKey)}</span>
|
|
338
|
+
<span class="doc-meta">${list.length}</span>
|
|
339
|
+
</div>`
|
|
340
|
+
);
|
|
341
|
+
if (!featOpen) continue;
|
|
342
|
+
}
|
|
343
|
+
for (const [s, i] of list) out.push(scenarioRow(s, i, named ? 1 : 0));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out.join('');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderList() {
|
|
350
|
+
$('#list').innerHTML = state.tree ? renderTree() : renderFlat();
|
|
278
351
|
const active = $('#list .row.active');
|
|
279
352
|
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
280
353
|
}
|
|
@@ -467,9 +540,21 @@ function openNoteEditor(s) {
|
|
|
467
540
|
};
|
|
468
541
|
}
|
|
469
542
|
|
|
543
|
+
function revealSelected() {
|
|
544
|
+
if (!state.tree) return;
|
|
545
|
+
const s = state.view[state.index];
|
|
546
|
+
if (!s) return;
|
|
547
|
+
const docKey = `${s.project}|${s.source}`;
|
|
548
|
+
state.collapsed.delete(docKey);
|
|
549
|
+
state.collapsed.delete(`${docKey}|${s.feature ?? ''}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
470
552
|
function move(delta) {
|
|
471
553
|
if (!state.view.length) return;
|
|
472
554
|
state.index = Math.min(Math.max(state.index + delta, 0), state.view.length - 1);
|
|
555
|
+
// Before painting, not after: a selection inside a collapsed group would be real and
|
|
556
|
+
// invisible, and the reviewer would answer whatever they could see instead.
|
|
557
|
+
revealSelected();
|
|
473
558
|
renderList();
|
|
474
559
|
renderDetail();
|
|
475
560
|
saveViewState();
|
|
@@ -601,7 +686,11 @@ async function showTab(tab) {
|
|
|
601
686
|
$('#review-pane').hidden = tab !== 'review';
|
|
602
687
|
$('#standard-pane').hidden = tab !== 'standard';
|
|
603
688
|
for (const b of document.querySelectorAll('#tabs button')) {
|
|
604
|
-
|
|
689
|
+
const on = b.dataset.tab === tab;
|
|
690
|
+
b.classList.toggle('active', on);
|
|
691
|
+
// The class is what a sighted reader sees; this is what everything else reads. Setting
|
|
692
|
+
// one without the other makes the tab strip a lie to half its audience.
|
|
693
|
+
b.setAttribute('aria-selected', String(on));
|
|
605
694
|
}
|
|
606
695
|
// The queue's filters steer the queue and mean nothing while reading, so they go
|
|
607
696
|
// rather than sit there implying they apply to what is on screen. Same for the
|
|
@@ -636,6 +725,17 @@ function moveDoc(delta) {
|
|
|
636
725
|
openDoc(state.docs[next].name);
|
|
637
726
|
}
|
|
638
727
|
|
|
728
|
+
function setListMode(tree) {
|
|
729
|
+
state.tree = tree;
|
|
730
|
+
$('#view-mode').textContent = tree ? 'flat' : 'tree';
|
|
731
|
+
$('#view-mode').setAttribute('aria-pressed', String(tree));
|
|
732
|
+
revealSelected();
|
|
733
|
+
renderList();
|
|
734
|
+
saveViewState();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
$('#view-mode').addEventListener('click', () => setListMode(!state.tree));
|
|
738
|
+
|
|
639
739
|
$('#tabs').addEventListener('click', (e) => {
|
|
640
740
|
const b = e.target.closest('button[data-tab]');
|
|
641
741
|
if (b) showTab(b.dataset.tab);
|
|
@@ -673,7 +773,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
673
773
|
return;
|
|
674
774
|
}
|
|
675
775
|
|
|
676
|
-
if (e.key === '
|
|
776
|
+
if (e.key === 't') setListMode(!state.tree);
|
|
777
|
+
else if (e.key === 'j') move(1);
|
|
677
778
|
else if (e.key === 'k') move(-1);
|
|
678
779
|
else if (e.key === 'a') act('accepted');
|
|
679
780
|
else if (e.key === 'v') act('verified');
|
|
@@ -690,6 +791,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
690
791
|
});
|
|
691
792
|
|
|
692
793
|
$('#list').addEventListener('click', (e) => {
|
|
794
|
+
const group = e.target.closest('[data-group]');
|
|
795
|
+
if (group) {
|
|
796
|
+
const key = group.dataset.group;
|
|
797
|
+
if (state.collapsed.has(key)) state.collapsed.delete(key);
|
|
798
|
+
else state.collapsed.add(key);
|
|
799
|
+
renderList();
|
|
800
|
+
saveViewState();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
693
803
|
const row = e.target.closest('.row');
|
|
694
804
|
if (!row) return;
|
|
695
805
|
state.index = Number(row.dataset.i);
|
|
@@ -781,6 +891,7 @@ function listen() {
|
|
|
781
891
|
}
|
|
782
892
|
|
|
783
893
|
restoreViewState();
|
|
894
|
+
setListMode(state.tree);
|
|
784
895
|
load()
|
|
785
896
|
.then(listen)
|
|
786
897
|
.then(() => {
|
package/public/index.html
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
<body>
|
|
10
10
|
<header class="bar">
|
|
11
11
|
<h1>Criteria review</h1>
|
|
12
|
-
<nav class="tabs" id="tabs">
|
|
13
|
-
<button data-tab="review" class="active"
|
|
14
|
-
|
|
12
|
+
<nav class="tabs" id="tabs" role="tablist" aria-label="Views">
|
|
13
|
+
<button id="tab-review" data-tab="review" class="active"
|
|
14
|
+
role="tab" aria-selected="true" aria-controls="review-pane">Review</button>
|
|
15
|
+
<button id="tab-standard" data-tab="standard"
|
|
16
|
+
role="tab" aria-selected="false" aria-controls="standard-pane">Standard</button>
|
|
15
17
|
</nav>
|
|
16
18
|
<div class="counts" id="counts"></div>
|
|
17
19
|
<div class="spacer"></div>
|
|
@@ -35,12 +37,14 @@
|
|
|
35
37
|
<option value="none">no recording</option>
|
|
36
38
|
</select>
|
|
37
39
|
<input id="search" type="search" placeholder="filter text" />
|
|
40
|
+
<button id="view-mode" type="button" title="Flat queue or grouped tree (t)">tree</button>
|
|
38
41
|
</div>
|
|
39
42
|
</header>
|
|
40
43
|
|
|
41
44
|
<div id="banner" class="banner" hidden title="click to dismiss"></div>
|
|
42
45
|
|
|
43
|
-
<
|
|
46
|
+
<div class="panes">
|
|
47
|
+
<main id="review-pane" role="tabpanel" aria-labelledby="tab-review">
|
|
44
48
|
<aside id="list" class="list" aria-label="Scenario queue"></aside>
|
|
45
49
|
|
|
46
50
|
<section id="detail" class="detail">
|
|
@@ -53,12 +57,13 @@
|
|
|
53
57
|
deciding whether a scenario is written correctly should not have to leave the
|
|
54
58
|
page and go find a repository to read the rule.
|
|
55
59
|
-->
|
|
56
|
-
|
|
60
|
+
<main id="standard-pane" role="tabpanel" aria-labelledby="tab-standard" hidden>
|
|
57
61
|
<aside id="doc-list" class="list" aria-label="Standard documents"></aside>
|
|
58
62
|
<section id="doc" class="detail md">
|
|
59
63
|
<p class="empty">Select a document.</p>
|
|
60
64
|
</section>
|
|
61
|
-
|
|
65
|
+
</main>
|
|
66
|
+
</div>
|
|
62
67
|
|
|
63
68
|
<footer class="help">
|
|
64
69
|
<!--
|
|
@@ -73,6 +78,7 @@
|
|
|
73
78
|
<span><kbd>n</kbd> note</span>
|
|
74
79
|
<span><kbd>f</kbd> fullscreen</span>
|
|
75
80
|
<span><kbd>r</kbd> reload</span>
|
|
81
|
+
<span><kbd>t</kbd> tree / flat</span>
|
|
76
82
|
<span><kbd>/</kbd> search</span>
|
|
77
83
|
</span>
|
|
78
84
|
<span id="keys-standard" class="keys" hidden>
|
package/public/styles.css
CHANGED
|
@@ -25,15 +25,35 @@
|
|
|
25
25
|
|
|
26
26
|
* { box-sizing: border-box; }
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* Flex column, NOT a four-row grid template.
|
|
30
|
+
*
|
|
31
|
+
* The grid version assigned rows by POSITION, so it was only correct while every child
|
|
32
|
+
* was in flow. The banner is `hidden` most of the time, which shifted each remaining
|
|
33
|
+
* child up a row: the pane took the banner's `auto` row and collapsed to its content,
|
|
34
|
+
* and the footer took the `1fr` and grew to fill the window. Adding a second view made
|
|
35
|
+
* it worse again, five children for four rows.
|
|
36
|
+
*
|
|
37
|
+
* A flex column has no positional assumption to break: the bars size to content and the
|
|
38
|
+
* pane area takes the rest, whatever is hidden.
|
|
39
|
+
*/
|
|
28
40
|
body {
|
|
29
41
|
margin: 0;
|
|
30
42
|
height: 100vh;
|
|
31
|
-
display:
|
|
32
|
-
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
33
45
|
background: var(--bg);
|
|
34
46
|
color: var(--text);
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
body > header,
|
|
50
|
+
body > footer,
|
|
51
|
+
body > .banner { flex: 0 0 auto; }
|
|
52
|
+
|
|
53
|
+
/* min-height:0 is load bearing: without it a flex item refuses to shrink below its
|
|
54
|
+
content, and the scrolling panes push the footer off the bottom of the window. */
|
|
55
|
+
.panes { flex: 1 1 auto; min-height: 0; }
|
|
56
|
+
|
|
37
57
|
.bar {
|
|
38
58
|
display: flex;
|
|
39
59
|
align-items: center;
|
|
@@ -307,6 +327,41 @@ kbd {
|
|
|
307
327
|
.note-dialog button { background: var(--panel-2); border: 1px solid var(--line); color: var(--text); border-radius: 6px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
|
|
308
328
|
.note-dialog button.primary { border-color: var(--accent); }
|
|
309
329
|
|
|
330
|
+
/* ---- Tree view ---- */
|
|
331
|
+
|
|
332
|
+
.tree-head, .feature-head { cursor: pointer; user-select: none; }
|
|
333
|
+
.tree-head:hover, .feature-head:hover { background: var(--panel-2); }
|
|
334
|
+
.caret { display: inline-block; width: 12px; color: var(--muted); font-size: 10px; }
|
|
335
|
+
|
|
336
|
+
.feature-head {
|
|
337
|
+
display: flex;
|
|
338
|
+
align-items: center;
|
|
339
|
+
gap: 6px;
|
|
340
|
+
padding: 5px 12px 5px 18px;
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
color: var(--muted);
|
|
343
|
+
border-bottom: 1px solid var(--line);
|
|
344
|
+
}
|
|
345
|
+
.feature-head span:nth-child(2) { color: var(--text); }
|
|
346
|
+
.feature-head .doc-meta { margin-left: auto; }
|
|
347
|
+
|
|
348
|
+
/* Indent nests the row under its feature without moving the status pills, which are
|
|
349
|
+
what the eye scans down. */
|
|
350
|
+
.row.depth-1 { padding-left: 26px; }
|
|
351
|
+
|
|
352
|
+
#view-mode {
|
|
353
|
+
background: var(--panel);
|
|
354
|
+
border: 1px solid var(--line);
|
|
355
|
+
color: var(--muted);
|
|
356
|
+
font: inherit;
|
|
357
|
+
font-size: 12px;
|
|
358
|
+
padding: 3px 10px;
|
|
359
|
+
border-radius: 6px;
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
}
|
|
362
|
+
#view-mode:hover { color: var(--text); }
|
|
363
|
+
#view-mode[aria-pressed='true'] { color: var(--text); border-color: var(--accent); }
|
|
364
|
+
|
|
310
365
|
/* ---- Reading tab: the standard and the walkthrough, beside the queue ---- */
|
|
311
366
|
|
|
312
367
|
.tabs { display: flex; gap: 2px; }
|
|
@@ -327,13 +382,23 @@ kbd {
|
|
|
327
382
|
border-color: var(--line);
|
|
328
383
|
}
|
|
329
384
|
|
|
330
|
-
/*
|
|
385
|
+
/* Both panes occupy ONE row of the body grid, so adding a second view cannot change the
|
|
386
|
+
page's row template. Before this they were siblings of header and footer, and the body's
|
|
387
|
+
four-row template had five children to place. */
|
|
388
|
+
.panes { min-height: 0; display: grid; }
|
|
389
|
+
.panes > main { grid-area: 1 / 1; min-height: 0; }
|
|
390
|
+
|
|
331
391
|
#standard-pane { display: grid; grid-template-columns: 320px 1fr; min-height: 0; }
|
|
332
392
|
|
|
333
393
|
/* `display` set on an element or class BEATS the hidden attribute's UA rule, so a
|
|
334
394
|
pane given a display here stays on screen while claiming to be hidden. Both panes
|
|
335
395
|
then render stacked. Needs the same specificity to win, not !important. */
|
|
336
|
-
|
|
396
|
+
/* ID + attribute, because `#standard-pane { display: grid }` is an ID selector and beats a
|
|
397
|
+
plain `main[hidden]`. That mismatch shipped: the pane reported hidden === true while
|
|
398
|
+
computing display:grid, and rendered under the other one. */
|
|
399
|
+
#review-pane[hidden], #standard-pane[hidden], .filters[hidden], .counts[hidden] {
|
|
400
|
+
display: none;
|
|
401
|
+
}
|
|
337
402
|
|
|
338
403
|
/* The two key-hint rows swap; the hidden one must actually go. */
|
|
339
404
|
.keys { display: contents; }
|