sign-off-axi 0.4.4 → 0.4.6

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/cli.js CHANGED
@@ -10,8 +10,9 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
10
10
  import { parseArgs } from 'node:util';
11
11
  import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
- import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
13
+ import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
14
14
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
+ import { faults } from './evidence-text.js';
15
16
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
16
17
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
17
18
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -206,7 +207,7 @@ An empty items list removes that filer's items.
206
207
  --type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
207
208
  an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
208
209
  The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
209
- --type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link.
210
+ --type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
210
211
  The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
211
212
  --type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
212
213
  and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --chapter m:ss=<title> (in order) are buttons that jump the video there. Answers work as for a decision.
@@ -484,7 +485,7 @@ function filerOf(values, session = false) {
484
485
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
485
486
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
486
487
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
487
- const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
488
+ const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
488
489
  /** The board's decisions with their latest answer; a missing board file is an empty board.
489
490
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
490
491
  function board(file, say = warn) {
@@ -515,7 +516,7 @@ function ready(file, on, root) {
515
516
  }
516
517
  function list(args) {
517
518
  const { file, values, on } = parse('list', args, TIERS);
518
- const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
519
+ const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
519
520
  if (values.json)
520
521
  return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
521
522
  const help = [
@@ -525,7 +526,7 @@ function list(args) {
525
526
  ];
526
527
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
527
528
  if (values.full) {
528
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? (d.dismissed ? 'dismissed' : 'waiting'), title: d.title, about: d.about ?? '', detail: d.detail ?? '',
529
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
529
530
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
530
531
  }
531
532
  return toon({
@@ -604,7 +605,9 @@ async function wait(args) {
604
605
  const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
605
606
  // With a filer, only its own items count, and an answer it has not taken yet is new however old it is.
606
607
  const mine = (rows) => (filer === undefined ? rows : rows.filter((d) => d.filer === filer));
607
- const before = new Set(board(file).flatMap((d) => (d.answer ? [d.key] : [])));
608
+ // A design with only screen notes still waits: it counts as unanswered here until its design verdict lands.
609
+ const open = (rows) => rows.map((d) => (awaitsVerdict(d.type, d.answer) ? { ...d, answer: undefined } : d));
610
+ const before = new Map(open(board(file)).flatMap((d) => (d.answer ? [[d.key, JSON.stringify(d.answer)]] : [])));
608
611
  // Rounds seen before waiting starts stay quiet; only a newly asked round wakes the loop.
609
612
  const seenRounds = new Map();
610
613
  for (const r of readRounds(file))
@@ -618,8 +621,8 @@ async function wait(args) {
618
621
  }
619
622
  catch { }
620
623
  for (let first = true;; first = false) {
621
- const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
622
- const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : !before.has(d.key))));
624
+ const rows = mine(open(board(file, first ? warn : () => { }))), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
625
+ const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : before.get(d.key) !== JSON.stringify(d.answer))));
623
626
  const counts = new Map(), asked = [];
624
627
  for (const r of readRounds(file, first ? warn : () => { })) {
625
628
  counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
@@ -974,7 +977,7 @@ function replace(file, named, on, from, filer) {
974
977
  // A key whose answer outlived it may not come back: the board and the answers are read here, under the lock the feed writes with.
975
978
  const saved = readAnswers(file);
976
979
  for (const i of fed.items)
977
- if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
980
+ if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key) && !awaitsVerdict(i.kind, saved[i.key])) {
978
981
  throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
979
982
  }
980
983
  const current = board(file);
@@ -982,7 +985,7 @@ function replace(file, named, on, from, filer) {
982
985
  const d = incoming.find((x) => x.key === had.key);
983
986
  if (filer && d && had.filer && had.filer !== filer)
984
987
  throw fail(`key ${d.key} on ${tilde(file)} was posted by another filer`, 'CONFLICT', 'Feed it under a new key');
985
- if (had.answer && d && question(d) !== question(had)) {
988
+ if (had.answer && !awaitsVerdict(had.type, had.answer) && d && question(d) !== question(had)) {
986
989
  throw fail(`key ${d.key} was answered and now holds a different question on ${tilde(file)}`, 'CONFLICT', 'Post the new question under a new key');
987
990
  }
988
991
  }
@@ -1019,6 +1022,11 @@ function replace(file, named, on, from, filer) {
1019
1022
  }
1020
1023
  /** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key.
1021
1024
  * `message` is a checked contract evidence message (post --kind evidence): its key, if any, takes only the same message again, and its run is stored as `evidence`. */
1025
+ const UNFIT = {
1026
+ text: (path) => `--media ${path} is mostly text: a picture of a log, receipt or summary is not evidence`,
1027
+ 'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
1028
+ placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
1029
+ };
1022
1030
  function postMedia(file, named, on, values, type, filer, message) {
1023
1031
  fits(values.title);
1024
1032
  const explainer = type === 'explainer';
@@ -1058,6 +1066,10 @@ function postMedia(file, named, on, values, type, filer, message) {
1058
1066
  throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
1059
1067
  return path;
1060
1068
  });
1069
+ // A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
1070
+ const [unfit] = explainer ? [] : sources.flatMap((src) => /\.(png|jpe?g|webp)$/i.test(src) ? faults(src).map((f) => ({ src, f })) : []);
1071
+ if (unfit)
1072
+ throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
1061
1073
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1062
1074
  return locked(`${file}.lock`, () => {
1063
1075
  const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
@@ -1072,7 +1084,8 @@ function postMedia(file, named, on, values, type, filer, message) {
1072
1084
  }
1073
1085
  return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
1074
1086
  }
1075
- if (message && own && Object.hasOwn(readAnswers(file), own))
1087
+ const answers = readAnswers(file);
1088
+ if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
1076
1089
  throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1077
1090
  if (own && current.some((d) => d.key === own))
1078
1091
  throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
@@ -1354,7 +1367,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1354
1367
  return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
1355
1368
  };
1356
1369
  // An answer outlives its item: a new question under that key would inherit it.
1357
- if (!had && Object.hasOwn(saved, d.key))
1370
+ if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
1358
1371
  throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
1359
1372
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1360
1373
  const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
@@ -1367,7 +1380,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1367
1380
  fits(d.title, d.key);
1368
1381
  items.push({ filed: now, ...raw[i] });
1369
1382
  }
1370
- return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
1383
+ return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
1371
1384
  });
1372
1385
  if (raised.some((r) => r.status === 'raised')) {
1373
1386
  const before = waitingKeys(file);
@@ -2078,6 +2091,22 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
2078
2091
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2079
2092
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2080
2093
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
2094
+ /** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
2095
+ * stored hash, so each picture is measured once. */
2096
+ const unfitMedia = new Map();
2097
+ const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => {
2098
+ if (!unfitMedia.has(m.sha256)) {
2099
+ let found;
2100
+ try {
2101
+ found = faults(under(dirname(file), m.path)).some((f) => f !== 'text');
2102
+ }
2103
+ catch {
2104
+ return false;
2105
+ }
2106
+ unfitMedia.set(m.sha256, found);
2107
+ }
2108
+ return unfitMedia.get(m.sha256);
2109
+ });
2081
2110
  const state = () => {
2082
2111
  reap();
2083
2112
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
@@ -2088,7 +2117,7 @@ const state = () => {
2088
2117
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
2089
2118
  // Who filed an item stays in the file: the page only learns that its filer took the answer.
2090
2119
  // A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
2091
- decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
2120
+ decisions: loadDecisions(file).filter((d) => !fakeEvidence(d)).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
2092
2121
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2093
2122
  readonly: readOnly(),
2094
2123
  answers_only: answersOnly(),
@@ -2359,7 +2388,7 @@ async function route(req) {
2359
2388
  appendRound(file, key, note.trim(), comments);
2360
2389
  return [200, state()];
2361
2390
  }
2362
- const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
2391
+ const { key, answer, note = '', notes, decisions, verdict, pick, requested } = JSON.parse(body) ?? {};
2363
2392
  if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
2364
2393
  return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
2365
2394
  const d = loadDecisions(file).find((x) => x.key === key);
@@ -2381,7 +2410,9 @@ async function route(req) {
2381
2410
  }
2382
2411
  if (d.kind === 'design') {
2383
2412
  try {
2384
- answered(appendDesignAnswer(file, d, verdict, notes, pick), d);
2413
+ const before = readAnswers(file)[d.key]?.answer, a = appendDesignAnswer(file, d, verdict, notes, pick, requested);
2414
+ if (a && !awaitsVerdict(d.type, a) && (verdict !== undefined || a.answer !== before))
2415
+ answered(a, d);
2385
2416
  }
2386
2417
  catch (e) {
2387
2418
  if (e instanceof InputError)
@@ -0,0 +1,182 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { inflateSync } from 'node:zlib';
4
+ /** A picture of text (a log, receipt or summary) is a few colours: paper, ink and their anti-aliased blends, with no large flat block of a
5
+ * third colour. A real capture of the product has colour, images and icons off that line, or a grey panel covering a good part of the
6
+ * picture. Measured on the calibration set in the PR: text renders, lossy ones too, have at most 0.01% of pixels off the line and a flat
7
+ * third colour of 1.7% at most; real captures have 1.5% or more off the line and a flat third colour of 8.9% or more. */
8
+ const OFF_LINE_MAX = 0.005;
9
+ /** The largest flat area in a third colour (any colour but the paper or the ink), as a share of the picture. */
10
+ const SOLID_MAX = 0.04;
11
+ /** A saturated colour is at least this far between its brightest and dimmest channel; a colour band must cover this share of the picture.
12
+ * A colour-bar card has at least five such bands, together most of the picture; the real captures measured have one at most. */
13
+ const SATURATED = 96;
14
+ const BAND_MIN = 0.05;
15
+ const BARS_MIN = 5;
16
+ const BARS_SHARE_MIN = 0.6;
17
+ /** A placeholder is one flat colour over this share of the picture; the real captures measured top out at 75%, the text renders are text. */
18
+ const PLACEHOLDER_MIN = 0.9;
19
+ /** A picture with no ink at all (a flat colour) is not text, so it passes. */
20
+ const INK_MIN = 0.005;
21
+ /** How close (0-255 RGB distance) a pixel must be to the paper or to the paper-ink line to count as text. */
22
+ const NEAR = 24;
23
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
24
+ /** Samples per pixel for each PNG colour type: grey, RGB, palette, grey with alpha, RGB with alpha. */
25
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
26
+ /** Adam7 interlace passes: x start, y start, x step, y step. A non-interlaced PNG is one pass over every pixel. */
27
+ const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
28
+ const WHOLE = [[0, 0, 1, 1]];
29
+ /** The share of pixels that are ink (a colour off the paper), of pixels off the paper-ink line, and of the largest flat area in any other colour,
30
+ * or undefined when the file cannot be decoded. */
31
+ export function palette(file) {
32
+ const rgb = decode(file);
33
+ if (!rgb)
34
+ return undefined;
35
+ const total = rgb.length / 3, bins = new Uint32Array(32768);
36
+ const centre = (b) => [(b >> 10) * 8 + 4, ((b >> 5) & 31) * 8 + 4, (b & 31) * 8 + 4];
37
+ for (let i = 0; i < rgb.length; i += 3)
38
+ bins[(rgb[i] >> 3) << 10 | (rgb[i + 1] >> 3) << 5 | (rgb[i + 2] >> 3)]++;
39
+ const paperBin = bins.indexOf(Math.max(...bins)), paper = centre(paperBin);
40
+ let inkBin = -1;
41
+ for (let b = 0; b < bins.length; b++) {
42
+ if (bins[b] && dist(centre(b), paper) > 128 && (inkBin < 0 || bins[b] > bins[inkBin]))
43
+ inkBin = b;
44
+ }
45
+ let solid = 0;
46
+ bins.forEach((count, b) => {
47
+ if (b !== paperBin && b !== inkBin && count / total > solid)
48
+ solid = count / total;
49
+ });
50
+ // Saturated flat colours: a colour-bar test card has several, each a band of the picture.
51
+ let bars = 0, barShare = 0;
52
+ bins.forEach((count, b) => {
53
+ const c = centre(b), share = count / total;
54
+ if (Math.max(...c) - Math.min(...c) >= SATURATED && share >= BAND_MIN)
55
+ bars++, (barShare += share);
56
+ });
57
+ const top = bins[paperBin] / total;
58
+ if (inkBin < 0)
59
+ return { ink: 0, off: 0, solid: 0, top, bars, barShare };
60
+ const [pr, pg, pb] = paper, [ar, ag, ab] = centre(inkBin).map((c, i) => c - paper[i]), len2 = ar * ar + ag * ag + ab * ab;
61
+ let ink = 0, off = 0;
62
+ for (let i = 0; i < rgb.length; i += 3) {
63
+ const dr = rgb[i] - pr, dg = rgb[i + 1] - pg, db = rgb[i + 2] - pb;
64
+ if (dr * dr + dg * dg + db * db <= NEAR * NEAR)
65
+ continue;
66
+ const t = Math.min(1, Math.max(0, (dr * ar + dg * ag + db * ab) / len2));
67
+ const er = dr - t * ar, eg = dg - t * ag, eb = db - t * ab;
68
+ if (er * er + eg * eg + eb * eb <= NEAR * NEAR)
69
+ ink++;
70
+ else
71
+ off++;
72
+ }
73
+ return { ink: ink / total, off: off / total, solid, top, bars, barShare };
74
+ }
75
+ /** Why a still is not a picture of the product: text (a log, receipt or summary), colour bars (a test card) or a placeholder (a flat card
76
+ * standing in for a picture). A file that cannot be decoded has no faults: it is not refused. */
77
+ export function faults(file) {
78
+ const share = palette(file);
79
+ if (!share)
80
+ return [];
81
+ const text = share.ink >= INK_MIN && share.off <= OFF_LINE_MAX && share.solid <= SOLID_MAX;
82
+ return [
83
+ ...(text ? ['text'] : []),
84
+ ...(share.bars >= BARS_MIN && share.barShare >= BARS_SHARE_MIN ? ['colour bars'] : []),
85
+ ...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
86
+ ];
87
+ }
88
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
89
+ /** The picture as packed 8-bit RGB. PNG is decoded here; JPEG, WebP and any PNG this decoder refuses go through ImageMagick, when it is installed. */
90
+ function decode(file) {
91
+ const bytes = readFileSync(file);
92
+ if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
93
+ const own = pngRgb(bytes);
94
+ if (own)
95
+ return own;
96
+ }
97
+ const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000 });
98
+ return out.status === 0 && out.stdout.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out.stdout) : undefined;
99
+ }
100
+ /** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
101
+ function pngRgb(buf) {
102
+ try {
103
+ let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
104
+ const idat = [];
105
+ for (let at = 8; at + 8 <= buf.length;) {
106
+ const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
107
+ if (name === 'IHDR')
108
+ [width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
109
+ else if (name === 'PLTE')
110
+ palette = data;
111
+ else if (name === 'IDAT')
112
+ idat.push(data);
113
+ else if (name === 'IEND')
114
+ break;
115
+ at += 12 + len;
116
+ }
117
+ const channels = CHANNELS[type];
118
+ if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
119
+ return undefined;
120
+ const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
121
+ const rgb = Buffer.alloc(width * height * 3);
122
+ const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
123
+ let at = 0;
124
+ for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
125
+ const pw = Math.ceil((width - x0) / dx), ph = Math.ceil((height - y0) / dy);
126
+ if (pw <= 0 || ph <= 0)
127
+ continue;
128
+ const rowBytes = Math.ceil(pw * bits / 8), px = Buffer.alloc(rowBytes * ph);
129
+ for (let y = 0; y < ph; y++, at += rowBytes + 1) {
130
+ const filter = raw[at];
131
+ for (let i = 0; i < rowBytes; i++) {
132
+ const a = i >= bpp ? px[y * rowBytes + i - bpp] : 0, b = y ? px[(y - 1) * rowBytes + i] : 0;
133
+ let predict = 0;
134
+ if (filter === 1)
135
+ predict = a;
136
+ else if (filter === 2)
137
+ predict = b;
138
+ else if (filter === 3)
139
+ predict = (a + b) >> 1;
140
+ else if (filter === 4) {
141
+ const c = i >= bpp && y ? px[(y - 1) * rowBytes + i - bpp] : 0, p = a + b - c;
142
+ const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
143
+ predict = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
144
+ }
145
+ px[y * rowBytes + i] = (raw[at + 1 + i] + predict) & 0xff;
146
+ }
147
+ }
148
+ // The sample at index `n` of a row: a whole byte, a packed value of 1, 2 or 4 bits, or the high byte of 16 bits.
149
+ const sample = (y, n) => {
150
+ if (depth === 16)
151
+ return px[y * rowBytes + n * 2];
152
+ if (depth === 8)
153
+ return px[y * rowBytes + n];
154
+ const shift = 8 - depth - ((n * depth) & 7);
155
+ return (px[y * rowBytes + ((n * depth) >> 3)] >> shift) & ((1 << depth) - 1);
156
+ };
157
+ for (let y = 0; y < ph; y++) {
158
+ for (let x = 0; x < pw; x++) {
159
+ const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * 3;
160
+ if (type === 3) {
161
+ const k = sample(y, n) * 3;
162
+ rgb[o] = palette[k] ?? 0;
163
+ rgb[o + 1] = palette[k + 1] ?? 0;
164
+ rgb[o + 2] = palette[k + 2] ?? 0;
165
+ }
166
+ else if (channels <= 2) {
167
+ rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
168
+ }
169
+ else {
170
+ rgb[o] = to8(sample(y, n));
171
+ rgb[o + 1] = to8(sample(y, n + 1));
172
+ rgb[o + 2] = to8(sample(y, n + 2));
173
+ }
174
+ }
175
+ }
176
+ }
177
+ return rgb;
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
package/dist/store.js CHANGED
@@ -280,27 +280,41 @@ function saveAnswer(decisionsPath, d, option, note, comments = [], by) {
280
280
  appendLine(answersPath(decisionsPath), answer);
281
281
  return answer;
282
282
  }
283
- /** Saves a design answer with screen-specific notes and an optional listed direction pick. */
284
- export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick) {
283
+ /** A design answer that holds only screen notes while the design has no design-wide verdict: it still waits for one. */
284
+ export const SCREENS_ONLY = 'screens';
285
+ /** True while an answer leaves the item waiting: none yet, or only screen notes. */
286
+ export const awaitsVerdict = (type, a) => !a || (type === 'design' && a.answer === SCREENS_ONLY);
287
+ const DESIGN_LABEL = { approve: 'Approved', change: 'Changes requested', [SCREENS_ONLY]: 'Screens reviewed' };
288
+ /** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a change on a screen of an approved design sets it to change, and with none yet it waits as screens-only. */
289
+ export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, requested) {
285
290
  const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
286
- if (verdict !== 'approve' && verdict !== 'change')
287
- throw bad('verdict must be approve or change');
291
+ if (verdict !== undefined && verdict !== 'approve' && verdict !== 'change')
292
+ throw bad('verdict must be approve or change, or omitted to save a screen');
288
293
  if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
289
294
  throw bad('notes must be a list, with at least one note on change');
295
+ if (verdict === undefined && !notes.some(n => n?.journey !== undefined))
296
+ throw bad('a screen save names its screen');
297
+ if (requested === true && (verdict !== undefined || !notes.some(n => n?.journey !== undefined && (n.verdict ?? 'change') === 'change')))
298
+ throw bad('a requested change is a screen save with a change note on a screen');
290
299
  const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
291
300
  for (const n of notes) {
292
301
  const hasJourney = n?.journey !== undefined, hasScreen = n?.screen !== undefined;
293
- if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) || !isText(n.note) || !n.note.trim() || [...n.note].length > NOTE_MAX) {
294
- throw bad('each note needs both a known journey and screen, or neither for a design-level note, with text of 500 characters or fewer');
302
+ if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) ||
303
+ (n.verdict !== undefined && !['approve', 'change'].includes(n.verdict)) ||
304
+ (n.note !== undefined && (!isText(n.note) || [...n.note].length > NOTE_MAX)) || ((n.verdict ?? 'change') === 'change' && !n.note?.trim())) {
305
+ throw bad('each note needs both a known journey and screen, or neither for a design-level note; verdict must be approve or change, with text required on change (500 characters or fewer)');
295
306
  }
296
307
  }
297
308
  if (new Set(notes.map(n => n.journey === undefined ? '' : `${n.journey}/${n.screen}`)).size !== notes.length)
298
309
  throw bad('notes must name each screen once, or contain one design-level note');
299
310
  const options = d.options ?? [];
300
- if (options.length ? !options.some(o => o.value === pick) : pick !== undefined)
301
- throw bad('pick must be one listed option, or omitted when no options were offered');
302
- const answer = { key: d.key, answer: verdict, label: verdict === 'approve' ? 'Approved' : 'Changes requested', close: d.close ?? 'done', at: now(), notes, ...(pick !== undefined && { pick: pick }) };
311
+ if (pick !== undefined ? !options.some(o => o.value === pick) : verdict !== undefined && options.length > 0)
312
+ throw bad('pick must be one listed option, and a design verdict needs one when options were offered');
303
313
  const current = readAnswers(decisionsPath)[d.key];
314
+ const decided = current?.answer === 'approve' || current?.answer === 'change' ? current.answer : undefined;
315
+ const kept = (verdict ?? (decided === 'approve' && requested === true ? 'change' : decided ?? SCREENS_ONLY));
316
+ const keptPick = (pick ?? (options.some(o => o.value === current?.pick) ? current?.pick : undefined));
317
+ const answer = { key: d.key, answer: kept, label: DESIGN_LABEL[kept], close: d.close ?? 'done', at: now(), notes, ...(keptPick !== undefined && { pick: keptPick }) };
304
318
  if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
305
319
  return;
306
320
  appendLine(answersPath(decisionsPath), answer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -67,34 +67,25 @@
67
67
  .i-waiting .vis .icon svg { width: 2.25rem; height: 2.25rem; }
68
68
  .i-reports .mark { width: 0.375rem; height: 1.25rem; border-radius: 2px; background: var(--area); }
69
69
  .pair, .areas { display: grid; gap: 1rem; margin-top: 1rem; }
70
- /* What needs you first and the ask in Triage: its pictures side by side and its answers stacked at one width. */
71
- .needs .row, .focus .row { padding: 0; border: 0; background: none; box-shadow: none; grid-template-columns: minmax(0, 1fr); }
72
- .needs .row .choices, .focus .row .choices { display: grid; grid-template-columns: minmax(0, 1fr); }
70
+ /* Needs you first keeps the existing answer controls at a readable width. */
71
+ .needs .row { padding: 0; border: 0; background: none; box-shadow: none; grid-template-columns: minmax(0, 1fr); }
72
+ .needs .row .choices { display: grid; grid-template-columns: minmax(0, 1fr); }
73
73
  .needs .row .choices, .needs .rung { max-width: 28rem; }
74
- .needs .media, .focus .media { flex-wrap: nowrap; }
75
- .needs .media > *, .focus .media > * { flex: 1 1 0; min-width: 0; }
76
- .needs .mocks, .focus .mocks { grid-auto-columns: minmax(0, 1fr); max-width: 16rem; margin: 0; }
77
- .focus .mocks { max-width: 24rem; }
78
- .focus .shot { aspect-ratio: 3 / 4; }
74
+ .needs .media { flex-wrap: nowrap; }
75
+ .needs .media > * { flex: 1 1 0; min-width: 0; }
76
+ .needs .mocks { grid-auto-columns: minmax(0, 1fr); max-width: 16rem; margin: 0; }
79
77
  .needs .shot { aspect-ratio: 1; }
80
- /* Triage: the open asks one at a time, the question big and the answers big enough for a thumb; at desktop the queue sits beside it. */
81
- .fhead { margin: 0; color: var(--muted); font-size: 0.875rem; font-weight: 650; }
82
- .pbar { height: 4px; margin: 0.5rem 0 1rem; border-radius: 4px; background: var(--line); overflow: hidden; }
83
- .pbar i { display: block; height: 100%; background: var(--c-waiting); }
84
- .focus .since { margin: -0.375rem 0 1rem; font-weight: 600; } .needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
85
- .focus .row h3 { font-size: 1.75rem; line-height: 1.2; letter-spacing: -0.01em; }
86
- .focus .choices button, .needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
87
- @media (min-width: 60rem) { .focus .choices button, .needs .choices .rec { min-height: 2.75rem; } }
78
+ .needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
88
79
  /* On Summary the recommended answer is the large button, and the others are pills under it. */
89
80
  .needs .choices button { font-weight: 600; } .needs .choices button:not(.rec) { justify-self: start; border-radius: 999px; }
90
81
  .needs .choices button[aria-pressed="true"] { padding-right: 2.75rem; }
91
- .quiet { display: flex; gap: 0.25rem; margin-top: 0.5rem; }
92
- .quiet button { border: 0; background: none; color: var(--muted); font-weight: 600; }
93
- .upnext { display: grid; margin-top: 1rem; }
94
- .upnext .meta { margin: 0; font-weight: 700; }
95
- .hint { text-align: center; }
96
- .queue, .keys { display: none; }
97
- .caught { text-align: center; }
82
+ .needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
83
+ @media (min-width: 60rem) { .needs .choices .rec { min-height: 2.75rem; } }
84
+ @media (max-width: 59.999rem) {
85
+ body:has(.decide) { padding-bottom: calc(9rem + env(safe-area-inset-bottom)); }
86
+ .top:has(+ .decide) .back { display: none; }
87
+ }
88
+
98
89
  /* Reports: a card per report, its steps as numbered nodes on a line with only the marks that matter under each; Open lists them in place. */
99
90
  .reports { display: grid; gap: 0.75rem; }
100
91
  .report { display: grid; gap: 0.75rem; scroll-margin-top: 4.5rem; }
@@ -244,8 +235,72 @@
244
235
  .readonly .rung iframe { height: 80vh; }
245
236
  .row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.875rem; padding: 1rem; scroll-margin-top: 4.5rem;
246
237
  background: var(--surface); border-radius: 0.875rem; }
247
- /* Waiting cards carry the accent stripe; answered ones sit flat and quiet. */
248
- .in-waiting .row { border-left: 3px solid var(--accent); padding-left: calc(1rem - 3px); }
238
+ /* Waiting and Decide use separate board cards; the R1 answer renderer remains the source of answer writes. */
239
+ .waiting-lede { margin: 0; color: var(--muted); }
240
+ .plan-hero { display: grid; overflow: hidden; margin: 0 0 2rem; border-radius: 1.125rem; background: var(--surface); }
241
+ .plan-preview { width: 100%; height: 12rem; min-width: 0; overflow: hidden; background: var(--sunk); }
242
+ .plan-preview .rung { height: 100%; }
243
+ .plan-preview .rung .frame, .plan-preview .rung iframe { height: 100%; min-height: 0; border: 0; border-radius: 0; }
244
+ .plan-preview .rung > .meta { display: none; }
245
+ .plan-copy { display: grid; align-content: center; gap: .5rem; padding: 1.25rem; }
246
+ .plan-copy .eyebrow, .ask-card .kind { margin: 0; color: var(--warn); font-size: .875rem; font-weight: 600; }
247
+ .plan-copy h2 { margin: 0; font-size: 1.75rem; }
248
+ .plan-copy .about { margin: 0; color: var(--muted); -webkit-line-clamp: 1; }
249
+ .plan-meta { display: flex; flex-wrap: wrap; gap: .35rem 1rem; color: var(--muted); font-size: .875rem; }
250
+ .plan-meta span { white-space: nowrap; }
251
+ .open-link.plan-open { display: inline-flex; width: fit-content; min-height: 2.75rem; padding: 0 1.25rem; font-size: 1rem; }
252
+ .ask-group { list-style: none; margin: .5rem 0 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
253
+ .ask-group > li { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 4.125rem; padding: .5rem .5rem .5rem 0; }
254
+ .ask-group > li + li { border-top: .5px solid var(--line); }
255
+ .ask-card { display: flex; flex: 1; min-width: 0; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
256
+ .ask-card .icon { width: 2rem; height: 2rem; border-radius: .5rem; background: var(--action); }
257
+ .ask-card .icon svg { width: 1.125rem; height: 1.125rem; stroke: white; }
258
+ .ask-card .copy { display: grid; flex: 1; min-width: 0; gap: .125rem; }
259
+ .ask-card .title { font-size: 1.0625rem; font-weight: 550; line-height: 1.3; }
260
+ .ask-card .meta { margin: 0; color: var(--muted); font-size: .9375rem; }
261
+ .ask-group .x { margin: 0; }
262
+ .decide { max-width: 44rem; margin: 0 auto; padding-bottom: 1rem; }
263
+ .decide-preview { margin: .5rem 0 .75rem; padding: 1rem; min-height: 8rem; display: grid; place-items: center; overflow: hidden; border-radius: 1.125rem; background: var(--surface); }
264
+ .decide-preview > img { display: block; max-width: 100%; max-height: 15rem; object-fit: contain; border-radius: .75rem; }
265
+ .decide-preview .rung { width: 100%; }
266
+ .decide-preview .rung .frame, .decide-preview .rung iframe { max-height: 15rem; }
267
+ .decide-preview .mocks { --tall: min(32svh, 16rem); }
268
+ .decide-preview .shot { aspect-ratio: 236 / 273; }
269
+ .decide-preview .mock figcaption .label { flex: 1 1 auto; min-width: 0; font-size: .75rem; }
270
+ .decide-preview .mock figcaption .badge { padding: .1rem .25rem; font-size: .6875rem; white-space: nowrap; }
271
+ @media (max-width: 59.999rem) {
272
+ .top:has(+ .decide) { padding: .25rem 0 0; }
273
+ .decide-preview { margin: .25rem 0 .5rem; padding: .75rem; }
274
+ .decide-preview .mocks { grid-auto-columns: min(85%, 8rem); --tall: min(20svh, 10rem); }
275
+ .decide-preview .shot { aspect-ratio: 1; }
276
+ .decide .lead h2 { margin: .25rem 0; }
277
+ .decide .pick-label { margin: .5rem 0; }
278
+ .decide .silent-line { margin: .5rem 0; }
279
+ .decide .pick-option { min-height: 3.125rem; padding: .5rem 1rem .5rem 0; }
280
+ body:has(.decide) #toast { bottom: calc(9.5rem + env(safe-area-inset-bottom)); }
281
+ }
282
+ .decide h2 { margin: .5rem 0 .5rem; font-size: 1.75rem; line-height: 1.15; letter-spacing: -.01em; }
283
+ .decide .why { margin: 0; color: var(--muted); font-size: 1.0625rem; font-weight: 400; }
284
+ .pick-label { margin: 1rem 0 .5rem; color: var(--muted); font-size: .875rem; letter-spacing: .02em; }
285
+ .pick-list { list-style: none; margin: 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
286
+ .pick-list li + li { border-top: .5px solid var(--line); }
287
+ .pick-option { display: flex; width: 100%; min-height: 2.75rem; align-items: center; gap: .75rem; padding: .5rem 1rem .5rem 0; border: 0; border-radius: 0; background: transparent; text-align: left; }
288
+ .pick-option .words { display: grid; flex: 1; min-width: 0; gap: .125rem; }
289
+ .pick-option .label-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem; }
290
+ .pick-option .label { font-size: 1.25rem; line-height: 1.3; }
291
+ .pick-option .reason { color: var(--muted); font-size: 1rem; line-height: 1.35; }
292
+ .pick-option .recommend { color: var(--accent); font-size: .9375rem; }
293
+ .pick-option .check { width: 1.5rem; color: var(--accent); font-size: 1.5rem; visibility: hidden; }
294
+ .pick-option[aria-pressed="true"] { color: var(--text); background: transparent; }
295
+ .pick-option[aria-pressed="true"]::before { content: none; }
296
+ .pick-option[aria-pressed="true"] .check { visibility: visible; }
297
+ .silent-line { margin: 1.25rem 0; color: var(--muted); }
298
+ .decide-bar { position: fixed; z-index: 1; left: 0; right: 0; bottom: calc(4.125rem + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: .5rem;
299
+ padding: .75rem max(1.25rem, env(safe-area-inset-right)) .75rem max(1.25rem, env(safe-area-inset-left)); background: var(--bg); }
300
+ .decide-bar .answer { flex: 1; background: var(--action); color: var(--on-accent); }
301
+ .decide-bar .later { display: inline-flex; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0 0.75rem; color: var(--accent); background: transparent; }
302
+ .decide-bar .skip { color: var(--muted); background: transparent; }
303
+ .ask-group + .gone-asks { margin-top: 1rem; }
249
304
  .in-answered .row { background: transparent; box-shadow: none; }
250
305
  .in-answered .row h3 { color: var(--muted); font-weight: 500; }
251
306
  /* A sign-in: one big Open for its link, off once the link expired. */
@@ -359,6 +414,27 @@
359
414
  .design-review .foot > * { flex: 1; }
360
415
  .design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
361
416
  .design-review > a { display: block; max-width: 56rem; margin: 1rem auto; color: var(--accent); }
417
+ .design-viewer { position: fixed; inset: 0; width: 100%; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: var(--bg); color: var(--text); }
418
+ .design-viewer[open] { display: flex; flex-direction: column; }
419
+ .design-viewer header { display: flex; gap: 1rem; align-items: center; padding: 0.75rem max(1rem, env(safe-area-inset-right)) 0.75rem max(1rem, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); }
420
+ .design-viewer header > div { flex: 1; min-width: 0; max-height: 22dvh; overflow: auto; }
421
+ .design-viewer h2, .design-viewer p { margin: 0; }
422
+ .design-viewer h2 { font-size: 1.25rem; line-height: 1.2; }
423
+ .design-viewer .meta { font-size: 1rem; }
424
+ .design-viewer-panel button { padding: 0 0.75rem; font-size: 1rem; white-space: nowrap; }
425
+ .design-viewer button, .design-viewer select { min-height: 2.75rem; }
426
+ .design-viewer-body { display: grid; grid-template-rows: minmax(0, 1fr) auto; flex: 1; min-height: 0; }
427
+ .design-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--sunk); touch-action: pan-y; }
428
+ .design-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
429
+ .design-stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--surface); }
430
+ .design-viewer-panel { overflow: auto; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)); background: var(--surface); }
431
+ .design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
432
+ .design-viewer-panel .foot > * { flex: 1; }
433
+ .design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
434
+ .design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
435
+ .design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
436
+ @media (min-width: 60rem) { .design-viewer header h2 { font-size: 2.125rem; } .design-viewer-body { grid-template: minmax(0, 1fr) / minmax(0, 1fr) 22.5rem; } .design-viewer-panel { padding: 1.25rem; } }
437
+ @media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }
362
438
  .needs .foot .open-link { min-height: 2.75rem; padding: 0 1rem; background: var(--action); color: var(--on-accent); text-align: center; }
363
439
  /* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
364
440
  .gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
@@ -416,14 +492,18 @@
416
492
  button:focus-visible, summary:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
417
493
  .shot:focus-visible { outline-offset: -2px; }
418
494
  .empty { margin: 0; padding: 1.5rem 1rem; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: 0.875rem; }
495
+ @media (max-width: 44.999rem) {
496
+ .plan-preview { display: none; }
497
+ .plan-copy .about { display: block; -webkit-line-clamp: unset; -webkit-box-orient: initial; overflow: visible; }
498
+ }
419
499
  @media (min-width: 45rem) {
420
500
  main { padding: 0 2rem; }
421
501
  .row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
422
- .in-waiting .row { padding-left: calc(1.25rem - 3px); }
423
502
  .row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
424
503
  /* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
425
504
  .mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
426
505
  .rung iframe { height: min(32rem, 75vh); }
506
+ .plan-hero { grid-template-columns: 46% minmax(0, 1fr); min-height: 20rem; }
427
507
  /* A picture card stacks like a design card: the picture, then its buttons at their own width. */
428
508
  .row:has(> .rung) { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
429
509
  .row:has(> .rung) .choices { justify-content: start; }
@@ -456,12 +536,11 @@
456
536
  .tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
457
537
  .pair { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
458
538
  .areas { grid-template-columns: 1fr 1fr; align-items: start; }
459
- .tri { display: grid; grid-template-columns: minmax(16rem, 2fr) minmax(0, 3fr); gap: 2rem; align-items: start; }
460
- .queue { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
461
- .queue button { display: grid; width: 100%; padding: 0.75rem 1rem; border-color: transparent; background: none; text-align: left; }
462
- .queue button[aria-current] { border-color: var(--line); background: var(--surface); }
463
- .queue .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
464
- .hint, .upnext { display: none; }
539
+ .plan-preview { height: 20rem; }
540
+ .plan-preview .rung .frame, .plan-preview .rung iframe { min-height: 0; }
541
+ .plan-copy { padding: 2rem; }
542
+ .plan-copy h2, .decide h2 { font-size: 2.5rem; line-height: 1.1; }
543
+ .decide-bar { position: static; margin: 1rem 0 0; padding: .75rem 0; background: transparent; }
465
544
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
466
545
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
467
546
  .rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
@@ -469,7 +548,6 @@
469
548
  .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
470
549
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
471
550
  .list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
472
- .keys { display: block; }
473
551
  }
474
552
  </style>
475
553
  </head>
@@ -511,20 +589,31 @@
511
589
 
512
590
  const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
513
591
 
514
- function save(d, o, buttons, err) {
515
- buttons.forEach((b) => (b.disabled = true));
592
+ function lock(...btns) {
593
+ const controls = [...btns, ...(btns[0].closest('.decide, .row')?.querySelectorAll('button') ?? [])].filter((b) => !b.disabled);
594
+ controls.forEach((b) => (b.disabled = true));
595
+ return () => controls.forEach((b) => (b.disabled = false));
596
+ }
597
+
598
+ async function save(d, o, buttons, err, after) {
599
+ const unlock = lock(...buttons);
516
600
  // An answers-only board takes no comments, and words sent as the answer are the answer itself (a reply item's also go as a comment).
517
601
  const typed = state.answers_only || (o.value === 'text' && !d.reply) ? '' : (drafts.get(d.key) ?? '').trim();
518
602
  const note = drafts.get(d.key) ?? noteOf(d), before = answerOf(d.key);
519
603
  const drop = clearIfSaved(d.key, drafts.get(d.key));
520
- call('/api/answers', {
521
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
522
- }, () => { forget(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
523
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
524
- }, drop)).catch((e) => {
604
+ try {
605
+ await call('/api/answers', {
606
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
607
+ }, () => { forget(d.key); if (!typed) drop(); });
608
+ } catch (e) {
525
609
  shown(err).textContent = `Couldn't save: ${e.message}`;
526
- buttons.forEach((b) => (b.disabled = false));
527
- });
610
+ return unlock();
611
+ }
612
+ offerUndo(d, before);
613
+ after?.();
614
+ if (typed) call('/api/messages', {
615
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
616
+ }, drop).catch(() => say('Could not save your comment', 8000));
528
617
  }
529
618
 
530
619
  const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
@@ -550,11 +639,12 @@
550
639
  undoBtn.setAttribute('aria-label', `Undo, ${headline(d)}`);
551
640
  say(`Saved: ${now.answer === 'text' ? 'your reply' : now.label}`, 5000, () => {
552
641
  undoBtn.disabled = true;
553
- cur = d.key; // Triage shows the ask again.
554
642
  call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' },
555
643
  body: JSON.stringify({ key: d.key, answer: before?.answer ?? '', note: before?.note ?? '' }) })
556
- .then(() => say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000),
557
- (e) => say(`Couldn't undo: ${e.message}`, 8000));
644
+ .then(() => {
645
+ if (!before) location.hash = askHref(d);
646
+ say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000);
647
+ }, (e) => say(`Couldn't undo: ${e.message}`, 8000));
558
648
  });
559
649
  // The tap redrew its card and took the focus with it: put it on Undo so the keyboard can reach it in time.
560
650
  if (document.activeElement === document.body || !document.activeElement) undoBtn.focus();
@@ -565,11 +655,11 @@
565
655
 
566
656
  /** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
567
657
  function send(btn, err, body, draft, text) {
568
- btn.disabled = true;
658
+ const unlock = lock(btn);
569
659
  call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
570
660
  clearIfSaved(draft, drafts.get(draft))).catch((e) => {
571
661
  shown(err).textContent = `Couldn't send: ${e.message}`;
572
- btn.disabled = false;
662
+ unlock();
573
663
  });
574
664
  }
575
665
 
@@ -680,17 +770,20 @@
680
770
  ro ? '' : other(d, saved, err), err, tech(d));
681
771
  }
682
772
 
773
+ /** A count with its unit in the form it deserves: 1 section, 2 sections. */
774
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
683
775
  /** A plan: its one-line answer, how many sections and decisions it asks about, and one Open plan for its page over the board. */
684
776
  function plan(d, lead) {
685
- const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
686
777
  return el('article', { class: 'row plan', id: `item-${d.key}` }, el('div', {}, lead,
687
- el('p', { class: 'meta' }, `${count(d.sections.length, 'section')} · ${count(d.decisions.length, 'decision')}`)),
778
+ el('p', { class: 'meta' }, `${plural(d.sections.length, 'section')} · ${plural(d.decisions.length, 'decision')}`)),
688
779
  el('a', { class: 'open-link', href: `#${area(d)}/${d.key}`, 'aria-label': `Open plan, ${d.title}` }, 'Open plan'));
689
780
  }
781
+ /** A span of minutes: whole minutes under the hour, hours and the rest over it. */
782
+ const mins = (m) => (m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`);
690
783
  /** The time left on a sign-in link, in whole minutes. */
691
784
  const expiry = (at) => {
692
785
  const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000);
693
- return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`}`;
786
+ return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${mins(m)}`;
694
787
  };
695
788
  /** A sign-in: the service, what it unlocks, who waits, the time left and one Open for its link. Its poster closes it with signin done. */
696
789
  function signin(d, lead, saved, err) {
@@ -751,24 +844,24 @@
751
844
  /** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
752
845
  * Sent on its own the words are the answer (`text`); typed before a button is picked they go with that answer as its note.
753
846
  * On a normal board they can also ask for another round (not on an evidence update) or go as a comment. */
754
- function other(d, saved, err) {
847
+ function other(d, saved, err, after) {
755
848
  const box = el('textarea', { id: `note-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' });
756
849
  box.value = drafts.get(d.key) ?? (saved?.answer === 'text' ? '' : noteOf(d));
757
850
  box.oninput = () => { drafts.set(d.key, box.value); keep(); };
758
851
  // An option valued text would be the same answer, so that item takes words only as a note.
759
852
  const words = !d.options.some((o) => o.value === 'text'), talk = !state.answers_only, round = talk && d.type !== 'evidence';
760
853
  const sendBtn = el('button', { 'aria-label': `Send reply, ${headline(d)}` }, 'Send reply');
761
- sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err) : (shown(err).textContent = 'Write the reply first.'));
854
+ sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err, after) : (shown(err).textContent = 'Write the reply first.'));
762
855
  const roundBtn = el('button', { class: 'quick', 'aria-label': `Another round, ${headline(d)}` }, 'Another round');
763
856
  roundBtn.onclick = () => {
764
- roundBtn.disabled = true;
857
+ const unlock = lock(roundBtn);
765
858
  const note = (drafts.get(d.key) ?? noteOf(d)).trim();
766
859
  const drop = clearIfSaved(d.key, drafts.get(d.key));
767
860
  call('/api/rounds', {
768
861
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
769
862
  }, () => { forget(d.key); drop(); }).catch((e) => {
770
863
  shown(err).textContent = `Couldn't ask: ${e.message}`;
771
- roundBtn.disabled = false;
864
+ unlock();
772
865
  });
773
866
  };
774
867
  const commentBtn = el('button', { 'aria-label': `Send as a comment, ${headline(d)}` }, 'Send as a comment');
@@ -955,7 +1048,8 @@
955
1048
  you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
956
1049
  // The drawings are fixed strings from this page, never text from the board file.
957
1050
  const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
958
- const isAnswered = (d) => Object.hasOwn(state.answers, d.key);
1051
+ // A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
1052
+ const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
959
1053
  /** Each item lives in one area: answered items, then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks.
960
1054
  * A plan has no options but waits for its send-back. */
961
1055
  const area = (d) => {
@@ -967,6 +1061,13 @@
967
1061
  /** A page to open: an .html view, framed in a sandbox. */
968
1062
  const reads = (d) => /\.html$/i.test(d.view ?? '');
969
1063
  const hhmm = (at) => new Date(at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
1064
+ /** Where an item opens: its own area, and inside Waiting on you a plan or a design has its own screen; every other ask opens on Decide. */
1065
+ const askHref = (d) => {
1066
+ const a = area(d);
1067
+ if (a !== 'waiting') return `#${a === 'notes' ? 'reports' : a}`;
1068
+ return d.type === 'plan' ? `#waiting/${d.key}` : d.kind === 'design' ? `#designs/${d.key}` : `#decide/${d.key}`;
1069
+ };
1070
+ const left = (at) => { const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000); return m <= 0 ? 'deadline passed' : `${mins(m)} left`; };
970
1071
  const isToday = (at) => new Date(at).toDateString() === new Date().toDateString();
971
1072
  const day = (at) => new Date(at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
972
1073
  const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
@@ -1023,7 +1124,7 @@
1023
1124
  /** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
1024
1125
  function section(a, items) {
1025
1126
  const SHOWN = 4, line = (d) => {
1026
- const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : `#${a}` }, ...compact(d, a));
1127
+ const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : a === 'waiting' ? askHref(d) : `#${a}` }, ...compact(d, a));
1027
1128
  link.onclick = () => (jump = d.key);
1028
1129
  return el('li', {}, link, xBtn(d));
1029
1130
  };
@@ -1131,7 +1232,7 @@
1131
1232
  }
1132
1233
  main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
1133
1234
  if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
1134
- el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length} sections to approve or change, and ${d.decisions.length} decisions to make. Nothing is built until you send back.`));
1235
+ el('p', { class: 'plan-summary' }, icon('plan'), `${plural(d.sections.length, 'section')} to approve or change, and ${plural(d.decisions.length, 'decision')} to make. Nothing is built until you send back.`));
1135
1236
  main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note, an approval can carry a comment, and a decision takes an option or your own words.'));
1136
1237
  d.sections.forEach((s, i) => {
1137
1238
  const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
@@ -1206,6 +1307,11 @@
1206
1307
  return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
1207
1308
  }
1208
1309
 
1310
+ let designSession, boardStale = false;
1311
+ addEventListener('message', (e) => {
1312
+ const viewer = document.querySelector('.design-viewer[open]');
1313
+ if (viewer && e.source === viewer.querySelector('iframe')?.contentWindow && e.data?.['sign-off'] === 'viewer-nav') viewer.dispatchEvent(new KeyboardEvent('keydown', { key: e.data.key }));
1314
+ });
1209
1315
  function designReview(d) {
1210
1316
  const saved = state.answers[d.key], screens = (d.journeys ?? []).flatMap((journey) => journey.screens.map((screen) => ({ journey, screen }))), savedNote = saved?.notes?.[0];
1211
1317
  const screenKey = (target) => target && `${target.journey.id}/${target.screen.id}`, screenFor = (key) => screens.find((target) => screenKey(target) === key);
@@ -1214,11 +1320,19 @@
1214
1320
  for (const option of d.options ?? []) pick.append(el('option', { value: option.value }, option.label));
1215
1321
  pick.value = saved?.pick ?? '';
1216
1322
  const screenSelect = screens.length > 1 ? el('select', { 'aria-label': 'Screen for note' }, ...screens.map((target) => el('option', { value: screenKey(target) }, `${target.journey.title} — ${target.screen.title}`))) : '';
1217
- const selectedKey = savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]);
1323
+ if (designSession?.key !== d.key) designSession = { key: d.key, open: false, selected: '', drafts: new Map() };
1324
+ const session = designSession;
1325
+ const selectedKey = session.selected || (savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]));
1218
1326
  if (screenSelect) screenSelect.value = screenFor(selectedKey) ? selectedKey : screenKey(screens[0]);
1219
1327
  const target = () => screenSelect ? screenFor(screenSelect.value) : screens[0];
1220
- note.value = savedNote?.note ?? '';
1221
- const previewPath = () => (target() && Object.values(target().screen.views)[0]) || d.preview;
1328
+ const currentNote = () => state.answers[d.key]?.notes?.find((n) => n.journey === target()?.journey.id && n.screen === target()?.screen.id);
1329
+ const screenLine = () => (currentNote() ? (currentNote().verdict === 'approve' ? 'Screen approved' : 'Changes requested') : 'Not reviewed yet');
1330
+ note.value = session.drafts.get(screenKey(target())) ?? currentNote()?.note ?? '';
1331
+ note.oninput = () => session.drafts.set(screenKey(target()), note.value);
1332
+ const previewPath = () => {
1333
+ const views = target()?.screen.views, form = innerWidth < 960 ? 'phone' : 'computer', theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1334
+ return views?.[`${form}_${theme}`] || views?.[`${form}_${theme === 'dark' ? 'light' : 'dark'}`] || (views && Object.values(views)[0]) || d.preview;
1335
+ };
1222
1336
  const previewElement = (path) => /\.html?$/i.test(path) ? el('iframe', { class: 'design-review-preview', src: src(designUrl(d, path)), title: `Design preview: ${d.title}`, sandbox: 'allow-scripts' }) : el('img', { class: 'design-review-preview', src: src(designUrl(d, path)), alt: `Design preview: ${d.title}` });
1223
1337
  const preview = previewPath() ? el('div', { class: 'design-preview-window' }, previewElement(previewPath())) : '';
1224
1338
  const fullPreview = previewPath() ? el('a', { class: 'design-preview-full', href: src(designUrl(d, previewPath())), target: '_blank', rel: 'noopener noreferrer' }, 'Open full preview') : '';
@@ -1226,23 +1340,91 @@
1226
1340
  const path = previewPath();
1227
1341
  if (preview) preview.replaceChildren(previewElement(path));
1228
1342
  if (fullPreview) fullPreview.href = src(designUrl(d, path));
1343
+ session.selected = screenKey(target());
1344
+ note.value = session.drafts.get(session.selected) ?? currentNote()?.note ?? '';
1229
1345
  };
1230
1346
  const status = el('p', { class: 'meta', role: 'status' }, saved ? saved.label : d.status === 'draft' ? 'Draft' : 'In review');
1231
1347
  const err = el('p', { class: 'err', role: 'alert' });
1348
+ let saving = false;
1232
1349
  const save = (verdict) => {
1233
- if (d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
1234
- const selected = target(), text = note.value.trim();
1235
- return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, verdict,
1236
- notes: text ? [{ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), note: text }] : [], ...(pick.value && { pick: pick.value }) }) }, render);
1350
+ const screen = session.open;
1351
+ if (!screen && d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
1352
+ // A screen save (the viewer) sends no design-wide verdict; a note carries the verdict of the button that saved it, on a screen or at design level.
1353
+ const selected = target(), text = note.value.trim(), writes = (selected && screen) || text, live = new Set(screens.map(screenKey));
1354
+ const notes = (state.answers[d.key]?.notes ?? []).filter((n) => (!n.journey || live.has(`${n.journey}/${n.screen}`)) && (n.journey !== selected?.journey.id || n.screen !== selected?.screen.id));
1355
+ if (writes) notes.push({ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), verdict, note: text });
1356
+ saving = true; approve.disabled = change.disabled = true;
1357
+ return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
1358
+ notes, ...(pick.value && { pick: pick.value }) }) }, () => session.drafts.delete(screenKey(selected)))
1359
+ .finally(() => { saving = false; approve.disabled = change.disabled = state.readonly || !!state.answers_only; if (session.open) screenStatus.textContent = screenLine(); });
1237
1360
  };
1238
1361
  const approve = el('button', { class: 'open-link' }, 'Approve'), change = el('button', {}, 'Change');
1239
1362
  approve.disabled = change.disabled = state.readonly || !!state.answers_only;
1240
1363
  approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
1241
1364
  change.onclick = () => { if (!note.value.trim()) { err.textContent = 'Add a note describing what should change.'; note.focus(); return; } save('change').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; }); };
1242
- return el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
1243
- screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
1244
- el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err,
1365
+ const controls = el('div', {}, d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
1366
+ el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err);
1367
+ const open = el('button', { class: 'open-link', 'aria-haspopup': 'dialog', 'data-design': d.key }, 'Review full screen');
1368
+ const root = el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
1369
+ screens.length ? open : '', screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', controls,
1245
1370
  d.link ? el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer' }, 'Open original design') : '');
1371
+ if (!screens.length) return root;
1372
+ const heading = el('h2', { id: 'design-screen-title', dir: 'auto' }), count = el('p', { class: 'meta', 'aria-live': 'polite' });
1373
+ const stage = el('div', { class: 'design-stage' }), screenStatus = el('p', { class: 'meta', role: 'status' });
1374
+ const previous = el('button', { 'aria-label': 'Previous screen' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next screen' }, 'Next ›');
1375
+ const close = el('button', { 'aria-label': 'Close viewer' }, 'Close');
1376
+ const viewer = el('dialog', { class: 'design-viewer', 'aria-labelledby': heading.id }, el('header', {}, el('div', {}, count, heading), close),
1377
+ el('div', { class: 'design-viewer-body' }, stage, el('section', { class: 'design-viewer-panel', 'aria-label': 'Screen review' },
1378
+ el('div', { class: 'foot' }, previous, next), screenStatus)));
1379
+ const panel = viewer.querySelector('.design-viewer-panel');
1380
+ const draw = () => {
1381
+ const i = screens.indexOf(target());
1382
+ heading.textContent = `${target().journey.title} — ${target().screen.title}`;
1383
+ count.textContent = `${i + 1} of ${screens.length}`;
1384
+ previous.disabled = i === 0; next.disabled = i === screens.length - 1;
1385
+ stage.replaceChildren(previewElement(previewPath())); stage.scrollTop = 0;
1386
+ screenStatus.textContent = screenLine();
1387
+ approve.textContent = 'Approve screen'; change.textContent = 'Request change'; err.textContent = '';
1388
+ };
1389
+ const step = (delta) => {
1390
+ const i = screens.indexOf(target()) + delta;
1391
+ if (i < 0 || i >= screens.length || saving) return;
1392
+ session.drafts.set(screenKey(target()), note.value);
1393
+ if (screenSelect) { screenSelect.value = screenKey(screens[i]); screenSelect.onchange(); }
1394
+ draw();
1395
+ };
1396
+ previous.onclick = () => step(-1); next.onclick = () => step(1);
1397
+ const key = (e) => {
1398
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); viewer.close(); }
1399
+ else if (!e.target.closest?.('textarea, input, select') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight'].includes(e.key)) {
1400
+ e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1);
1401
+ }
1402
+ };
1403
+ viewer.onkeydown = key;
1404
+ let touch;
1405
+ stage.addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
1406
+ stage.addEventListener('touchend', (e) => {
1407
+ const end = e.changedTouches[0];
1408
+ if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) step(end.clientX < touch.clientX ? 1 : -1);
1409
+ touch = null;
1410
+ });
1411
+ const controlsNext = controls.nextSibling;
1412
+ open.onclick = () => {
1413
+ session.open = true; panel.append(controls); root.append(viewer); draw(); viewer.showModal();
1414
+ };
1415
+ close.onclick = () => viewer.close();
1416
+ viewer.onclose = () => {
1417
+ session.open = false;
1418
+ if (boardStale) {
1419
+ boardStale = false;
1420
+ render();
1421
+ document.querySelector(`[data-design="${CSS.escape(d.key)}"]`)?.focus();
1422
+ return;
1423
+ }
1424
+ root.insertBefore(controls, controlsNext); approve.textContent = 'Approve'; change.textContent = 'Change';
1425
+ open.focus();
1426
+ };
1427
+ return root;
1246
1428
  }
1247
1429
 
1248
1430
  /** The report a #reports/<key> link opens (or the plan a #waiting/<key> link opens), over the whole board: a bar with the way back and its title, then its page in a sandboxed frame.
@@ -1324,47 +1506,95 @@
1324
1506
  sel.onchange = () => { waitSort = sel.value; try { localStorage.setItem(SORT_KEY, waitSort); } catch {} reshow('.sort select'); };
1325
1507
  return el('div', { class: 'filters' }, el('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by type' }, ...chips), el('label', { class: 'sort' }, 'Sort', sel));
1326
1508
  }
1327
- /** Triage: the open asks one at a time, in the order the filters chose. Skip moves on without answering and is not stored; an answer moves on to the next ask. */
1328
- let cur = null, curAt = 0;
1329
- const reshow = (control) => { cur = null; curAt = 0; render(); document.querySelector(control)?.focus({ preventScroll: true }); };
1330
- function triage(items, hidden) {
1331
- if (!items.length) return el('section', { class: 'card caught' }, el('h2', {}, 'All caught up'), el('p', { class: 'meta' }, 'Nothing is waiting on you.'),
1332
- el('a', { class: 'more', href: '#' }, 'Back to Summary'), dismissed(hidden));
1333
- let i = items.findIndex((d) => d.key === cur);
1334
- if (i < 0) i = Math.min(curAt, items.length - 1);
1335
- const d = items[i], next = items[i + 1], n = items.length, card = row(d, { x: false }), dis = xBtn(d, 'Dismiss');
1336
- [cur, curAt] = [d.key, i];
1337
- const go = (key) => () => { cur = key; render(); scrollTo(0, 0); }, skip = n > 1 && el('button', { class: 'skip' }, 'Skip for now'), rec = card.querySelector('.choices .rec');
1338
- if (skip) skip.onclick = go(items[(i + 1) % n].key);
1339
- // On a phone a swipe right on the ask picks the recommended answer and a swipe left skips; a swipe in the picture row only scrolls it.
1340
- let start;
1341
- card.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
1342
- card.addEventListener('touchend', (e) => {
1343
- const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
1344
- if (Math.abs(dx) > 90 && Math.abs(t.clientY - start.clientY) < Math.abs(dx) / 2) (dx > 0 ? rec : skip)?.click();
1509
+ /** The plan is the lead card; the remaining asks are a grouped list linking to a focused decision. */
1510
+ function planCard(d) {
1511
+ const meta = [el('span', {}, plural(d.sections.length, 'section')), el('span', {}, plural(d.decisions.length, 'decision')),
1512
+ el('span', {}, `Agent${d.filed ? ` · ${when(d.filed)}` : ''}`)];
1513
+ return el('article', { class: 'plan-hero', id: `item-${d.key}` }, el('div', { class: 'plan-preview' }, rung(d)),
1514
+ el('div', { class: 'plan-copy' }, el('p', { class: 'eyebrow' }, `Plan${d.project ? ` · ${d.project}` : ''}`),
1515
+ el('h2', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about ?? ''),
1516
+ el('div', { class: 'plan-meta' }, ...meta), el('a', { class: 'open-link plan-open', href: `#waiting/${d.key}` }, 'Open plan')));
1517
+ }
1518
+ const askIcon = (d) => { const a = { design: 'designs', signin: 'you', plan: 'plan' }[typeOf(d)] ?? 'waiting'; return ICONS[a] ? a : 'waiting'; };
1519
+ function waitingPage(items, hidden) {
1520
+ const plans = items.filter((d) => d.type === 'plan'), asks = items.filter((d) => d.type !== 'plan');
1521
+ const rows = asks.map((d) => {
1522
+ const label = `Agent${d.silent_by ? ` · closes at ${when(d.silent_by)}` : d.filed ? ` · waiting since ${when(d.filed)}` : ''}`;
1523
+ const link = el('a', { class: 'ask-card', id: `item-${d.key}`, href: askHref(d) }, icon(askIcon(d)),
1524
+ el('span', { class: 'copy' }, el('span', { class: 'kind' }, kind(d)), el('strong', { class: 'title', dir: 'auto' }, title(d)),
1525
+ d.why || d.about ? el('span', { class: 'meta', dir: 'auto' }, d.why || d.about) : '', el('span', { class: 'meta' }, label)));
1526
+ return el('li', {}, link, xBtn(d));
1345
1527
  });
1346
- const hint = [rec && 'Swipe right for the recommended answer', skip && (rec ? 'left to skip' : 'Swipe left to skip')].filter(Boolean).join(', ');
1347
- const keys = [d.options.length && d.type !== 'design' && `1 to ${Math.min(d.options.length, 9)} answer`, skip && 'E skip', 'Z undo'].filter(Boolean).join(' · ');
1348
- const queue = el('ol', { class: 'queue', 'aria-label': `${n} ${n > 1 ? 'asks' : 'ask'}, one at a time` }, ...items.map((x) => {
1349
- const b = el('button', { ...(x === d && { 'aria-current': 'true' }) }, el('strong', { dir: 'auto' }, title(x)), el('span', { class: 'meta', dir: 'auto' }, sub(x)));
1350
- b.onclick = go(x.key);
1351
- return el('li', {}, b);
1352
- }));
1353
- return el('div', { class: 'tri' }, queue, el('div', { class: 'focus' }, el('p', { class: 'fhead' }, `${i + 1} of ${n}`),
1354
- el('div', { class: 'pbar', 'aria-hidden': 'true' }, el('i', { style: `width: ${((i + 1) / n) * 100}%` })),
1355
- d.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(d.filed)}`) : '', card,
1356
- d.kind === 'design' || skip || dis ? el('div', { class: 'quiet' }, d.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${d.key}` }, 'Open designs') : skip || '', dis) : '', next ? el('div', { class: 'card upnext' }, el('p', { class: 'meta' }, 'Up next'), el('strong', { dir: 'auto' }, title(next))) : '',
1357
- hint ? el('p', { class: 'meta hint' }, hint) : '', el('p', { class: 'meta keys' }, `Keys: ${keys}`), dismissed(hidden)));
1528
+ return el('div', { class: 'waiting-page' },
1529
+ ...plans.map(planCard), ...(asks.length ? [el('p', { class: 'pick-label' }, 'ASKS'), el('ul', { class: 'ask-group' }, ...rows)] : []),
1530
+ !items.length ? el('p', { class: 'empty' }, EMPTY.waiting) : '', dismissed(hidden));
1531
+ }
1532
+ const selected = new Map();
1533
+ function decidePage(d, next) {
1534
+ if (!d) return el('p', { class: 'empty' }, EMPTY.waiting);
1535
+ const to = next ? `#decide/${next.key}` : '#waiting', skip = next && !state.readonly ? el('button', { class: 'skip' }, 'Skip for now') : null;
1536
+ if (skip) skip.onclick = () => (location.hash = to);
1537
+ const swipe = (page, right) => {
1538
+ let start;
1539
+ page.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
1540
+ page.addEventListener('touchend', (e) => {
1541
+ const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
1542
+ if (!start || Math.abs(dx) <= 90 || Math.abs(t.clientY - start.clientY) >= Math.abs(dx) / 2) return;
1543
+ if (dx > 0) right?.(); else skip?.click();
1544
+ });
1545
+ };
1546
+ if (d.type === 'signin') {
1547
+ const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later')));
1548
+ if (!state.readonly) swipe(signinPage);
1549
+ return signinPage;
1550
+ }
1551
+ const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` }), opts = d.options ?? [], live = !state.readonly;
1552
+ // A pick remembered from an earlier round only counts while the ask still offers it.
1553
+ if (!opts.some((o) => o.value === selected.get(d.key))) selected.delete(d.key);
1554
+ const initial = selected.get(d.key) ?? d.recommend_value ?? opts[0]?.value;
1555
+ const optionButtons = opts.map((o) => {
1556
+ const recommended = o.value === d.recommend_value, b = el('button', { class: 'pick-option', 'aria-pressed': String(o.value === initial) },
1557
+ el('span', { class: 'words' }, el('span', { class: 'label-line' }, el('span', { class: 'label' }, o.label),
1558
+ recommended ? el('span', { class: 'recommend' }, 'Recommended') : ''), o.outcome ? el('span', { class: 'reason', dir: 'auto' }, o.outcome) : ''), el('span', { class: 'check', 'aria-hidden': 'true' }, '✓'));
1559
+ b.onclick = () => { selected.set(d.key, o.value); optionButtons.forEach((btn) => btn.setAttribute('aria-pressed', String(btn === b))); answer.textContent = `Answer ${o.label}`; };
1560
+ return b;
1561
+ });
1562
+ const selectedOption = () => opts.find((o) => o.value === (selected.get(d.key) ?? initial));
1563
+ const answer = el('button', { class: 'answer' }, `Answer ${selectedOption()?.label ?? 'now'}`);
1564
+ const answerWith = (o) => save(d, o, [...optionButtons, answer], err, () => (location.hash = to));
1565
+ answer.onclick = () => { const o = selectedOption(); if (o) answerWith(o); };
1566
+ const recOpt = opts.find((o) => o.value === d.recommend_value);
1567
+ const cards = d.mocks?.length && d.options?.length ? mockCards(d, []) : null;
1568
+ const preview = cards ?? (d.view ? rung(d) : pic(d) ? thumb('waiting', pic(d)) : proof(d));
1569
+ const quiet = opts.find((o) => o.value === d.default_if_silent);
1570
+ const silent = quiet ? `If you stay quiet: ${quiet.label}${d.silent_by ? ` · ${left(d.silent_by)}` : ''}` : '';
1571
+ const head = el('div', { class: 'lead' }, el('p', { class: 'eyebrow' }, kind(d)), el('h2', { dir: 'auto' }, headline(d)));
1572
+ const why = d.why || d.tradeoff || d.about;
1573
+ const back = el('a', { class: 'later', href: '#waiting' }, 'Later');
1574
+ const page = el('section', { class: 'decide' }, preview ? el('div', { class: 'decide-preview' }, preview) : '',
1575
+ ...(d.mocks?.length ? [annotations(d)] : []), head,
1576
+ why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
1577
+ ...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
1578
+ el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
1579
+ ...(live ? [other(d, answerOf(d.key), err, () => (location.hash = to))] : []),
1580
+ silent ? el('p', { class: 'silent-line' }, silent) : '', err,
1581
+ el('div', { class: 'decide-bar' }, live && opts.length ? answer : '', skip || '', back));
1582
+ if (live) swipe(page, () => { if (recOpt && !answer.disabled) answerWith(recOpt); });
1583
+ return page;
1358
1584
  }
1359
- // Triage keys at a desk: a number picks that answer (in the order shown), E skips, Z takes back the last tap.
1585
+ const reshow = (control) => { render(); document.querySelector(control)?.focus({ preventScroll: true }); };
1586
+ // On Decide, a number selects and submits an answer; E skips to the next ask, Z undoes the last answer.
1360
1587
  addEventListener('keydown', (e) => {
1361
- if (location.hash !== '#waiting' || e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
1362
- const k = e.key.toLowerCase(), pick = /^[1-9]$/.test(k) && document.querySelectorAll('.focus .choices button')[k - 1];
1363
- const act = pick || (k === 'e' ? document.querySelector('.quiet .skip') : k === 'z' && !toast.hidden && !undoBtn.hidden ? undoBtn : null);
1588
+ if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
1589
+ const k = e.key.toLowerCase(), onDecide = /^#decide\//.test(location.hash), choices = [...document.querySelectorAll('.decide .pick-option')];
1590
+ const pick = onDecide && /^[1-9]$/.test(k) ? choices[Number(k) - 1] : null;
1591
+ if (pick) { e.preventDefault(); pick.click(); document.querySelector('.decide-bar .answer')?.click(); return; }
1592
+ const act = k === 'e' && onDecide ? document.querySelector('.decide .skip') : k === 'z' && (location.hash === '#waiting' || onDecide) && !toast.hidden && !undoBtn.hidden ? undoBtn : null;
1364
1593
  if (act && !act.disabled) e.preventDefault(), act.click();
1365
1594
  });
1366
1595
 
1367
1596
  function render() {
1597
+ if (designSession?.open) return (boardStale = true);
1368
1598
  const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
1369
1599
  // The board file appends new items at the end, so each list reads newest first; answers sort by their time.
1370
1600
  // One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
@@ -1384,23 +1614,30 @@
1384
1614
  const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
1385
1615
  const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
1386
1616
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
1387
- const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1388
- const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (at ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
1617
+ const [, at, key] = /^#(waiting|decide|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1618
+ const decideQueue = by.waiting.filter((d) => d.type === 'signin' || (d.type !== 'plan' && d.kind !== 'design'));
1619
+ const askAt = decideQueue.findIndex((d) => d.key === key), decideAt = key ? askAt : 0;
1620
+ const active = at === 'decide' ? 'waiting' : at;
1621
+ const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (active ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
1389
1622
  document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
1390
1623
  document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
1391
- ...(a === at && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1624
+ ...(a === active && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1392
1625
  const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
1393
- const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: '#' }, '‹ Summary')
1626
+ const pageTitle = at === 'decide' ? 'Decide' : AREAS[at];
1627
+ const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: at === 'decide' ? '#waiting' : '#' }, at === 'decide' ? '‹ Waiting on you' : '‹ Summary')
1394
1628
  : el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
1395
- el('h1', { tabindex: '-1' }, at ? AREAS[at] : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
1396
- at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
1629
+ el('h1', { tabindex: '-1' }, at ? pageTitle : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
1630
+ at === 'waiting' && !state.readonly && by.waiting.length ? el('p', { class: 'lede waiting-lede' }, `${shown.length} ${shown.length === 1 ? 'item needs' : 'items need'} you · ${new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}`)
1631
+ : at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
1397
1632
  ? 'How the tools work, step by step. Tap one to open it.' : 'Explainers the agents made for you. Tap one to open it.')
1398
1633
  : at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, `${by.answered.filter((d) => isToday(state.answers[d.key].at)).length} answered today.`)
1399
1634
  : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
1400
- const first = by.waiting[0], more = by.waiting.length > 1 ? `Answer all ${by.waiting.length} in Triage →` : 'Open in Triage →';
1635
+ const first = by.waiting[0], more = by.waiting.length > 1 ? `Review all ${by.waiting.length} →` : 'Review ask →';
1401
1636
  const needs = el('section', { class: 'card needs' }, el('h2', {}, 'Needs you first'), ...(first ? [first.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(first.filed)}`) : '', row(first, { x: false }),
1402
1637
  el('div', { class: 'foot' }, xBtn(first, 'Dismiss'), first.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${first.key}` }, 'Open designs') : el('a', { class: 'more', href: '#waiting' }, more))] : [el('p', { class: 'meta' }, 'Nothing is waiting on you.')]), dismissed(hidden));
1403
- document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), triage(shown, hidden)]
1638
+ document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
1639
+ : at === 'decide' ? [decideAt < 0 ? el('p', { class: 'empty' }, 'Nothing to decide for that question.')
1640
+ : decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined)]
1404
1641
  : at === 'designs' ? [...designGroups(by.designs)] : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
1405
1642
  ...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes)] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'evidence' && by[at].length ? el('div', { class: 'sec-head' }, el('h2', {}, 'Updates')) : '', at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
1406
1643
  : el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [el('div', { class: 'tiles' }, ...Object.keys(AREAS).map((a) => tile(a, by[a]))),
@@ -1411,7 +1648,7 @@
1411
1648
  }
1412
1649
  function freeze() {
1413
1650
  // Answers stay as they are, but nothing in the copy can be sent.
1414
- document.querySelectorAll('#board .choices button, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
1651
+ document.querySelectorAll('#board .choices button, #board .pick-list button, #board .decide-bar .answer, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
1415
1652
  // A browser will not open a data: link in a new tab; the inline preview is the copy.
1416
1653
  document.querySelectorAll('#board a[href^="data:"]').forEach((a) => a.removeAttribute('href'));
1417
1654
  }
@@ -1419,7 +1656,7 @@
1419
1656
  // Closing a report or plan goes back to its card, with the focus on its link; an open report takes the focus on its title.
1420
1657
  addEventListener('hashchange', () => {
1421
1658
  const back = !reader.hidden && reader.dataset.key;
1422
- if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
1659
+ if (jump && location.hash === '#waiting') jump = null;
1423
1660
  if (location.hash === '#waiting') waitType = 'all';
1424
1661
  render();
1425
1662
  if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
package/web/point.js CHANGED
@@ -17,7 +17,17 @@
17
17
  };
18
18
  window.signOffFind = find;
19
19
  if (parent === window) return;
20
- let on = false;
20
+ let on = false, touch;
21
+ const navigate = (key) => parent.postMessage({ 'sign-off': 'viewer-nav', key }, '*');
22
+ addEventListener('keydown', (e) => {
23
+ if (!e.target.closest?.('textarea, input, select, [contenteditable]') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight', 'Escape'].includes(e.key)) navigate(e.key);
24
+ });
25
+ addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
26
+ addEventListener('touchend', (e) => {
27
+ const end = e.changedTouches[0];
28
+ if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) navigate(end.clientX < touch.clientX ? 'ArrowRight' : 'ArrowLeft');
29
+ touch = null;
30
+ }, { passive: true });
21
31
  const ref = (e) => {
22
32
  if (e === document.documentElement) return 'html';
23
33
  const path = [];