criteria-review 0.3.2 → 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.
@@ -848,9 +848,20 @@ async function main() {
848
848
  // an instruction to open the review UI. The old fallthrough turned a typo - or a flag a
849
849
  // newer version understands - into a server start, which in a pipeline is a hang and on
850
850
  // a desk is EADDRINUSE from the server already running.
851
- if (cmd !== undefined || args._.some((a) => a.startsWith('-'))) {
852
- const what = cmd ?? args._.find((a) => a.startsWith('-'));
853
- throw new Error(`unknown command "${what}". Run: criteria-review help`);
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
+ );
854
865
  }
855
866
 
856
867
  const cfg = await loadConfig();
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "criteria-review",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "description": "Review acceptance criteria across projects, and generate the scenario clauses your tests cite.",
6
6
  "license": "MIT",
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
- function renderList() {
241
- // A header whenever the document changes, so the batch boundary is visible.
242
- // Without it the grouping is real but invisible, and a reviewer cannot tell
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
- b.classList.toggle('active', b.dataset.tab === tab);
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 === 'j') move(1);
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">Review</button>
14
- <button data-tab="standard">Standard</button>
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
- <main id="review-pane">
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
- <main id="standard-pane" hidden>
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
- </main>
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: grid;
32
- grid-template-rows: auto auto 1fr auto;
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
- /* The two panes share the queue's grid, so switching does not move the furniture. */
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
- main[hidden], .filters[hidden], .counts[hidden] { display: none; }
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; }