sign-off-axi 0.4.5 → 0.4.7

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
@@ -5,13 +5,15 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
7
7
  import { lookup } from 'node:dns/promises';
8
- import { homedir, tmpdir, userInfo } from 'node:os';
8
+ import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
9
+ import { Worker } from 'node:worker_threads';
9
10
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
11
  import { parseArgs } from 'node:util';
11
12
  import { encode } from '@toon-format/toon';
12
13
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
14
  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
15
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
16
+ import { faults, isStill } from './evidence-text.js';
15
17
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
16
18
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
17
19
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -206,7 +208,7 @@ An empty items list removes that filer's items.
206
208
  --type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
207
209
  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
210
  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.
211
+ --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
212
  The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
211
213
  --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
214
  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.
@@ -325,8 +327,10 @@ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
325
327
  const MANY = ['option', 'outcome', 'merge'];
326
328
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
327
329
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
328
- /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
329
- const asSent = ({ filed: _filed, ...rest }) => rest;
330
+ /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none, and the notice record (`announced`) is the board's too. */
331
+ const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
332
+ /** A report names its own `filed` time, so only the notice record goes. */
333
+ const asReport = ({ announced: _announced, ...rest }) => rest;
330
334
  /** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
331
335
  * A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
332
336
  * that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
@@ -348,7 +352,7 @@ function contractItems(items, from, file, replace = false) {
348
352
  // item, a board-format one and every item outside a --replace feed are not checked again.
349
353
  const asMessage = (x) => {
350
354
  if (m?.kind !== 'evidence')
351
- return m?.kind === 'report' ? x : asSent(x);
355
+ return m?.kind === 'report' ? asReport(x) : asSent(x);
352
356
  const { type: _type, number: _number, media, evidence: run, ...message } = asSent(x);
353
357
  return { ...message, ...(media !== undefined && { media: Array.isArray(media) ? media.map((r) => r?.path) : media }), ...(run && { run }) };
354
358
  };
@@ -918,7 +922,7 @@ function postSignin(file, named, on, values, filer) {
918
922
  let status = same ? (done ? 'already done' : 'already waiting') : had ? 'new link' : 'posted';
919
923
  if (!same) {
920
924
  fits(d.title, d.key);
921
- const next = { ...item, filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
925
+ const next = { ...item, ...(had?.announced && { announced: had.announced }), filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
922
926
  if (had)
923
927
  items[at] = next;
924
928
  else
@@ -989,7 +993,9 @@ function replace(file, named, on, from, filer) {
989
993
  }
990
994
  }
991
995
  const old = existsSync(file) ? readItems(file) : undefined;
992
- let items = feed, extra = fed.extra, resulting = incoming;
996
+ // The notice record belongs to the card key, so a feed that rewrites a card keeps it: the receiver was told once.
997
+ const told = new Map((old?.items ?? []).filter((i) => i?.key && i.announced).map((i) => [i.key, i.announced]));
998
+ let items = feed.map((i) => (told.has(i?.key) ? { ...i, announced: told.get(i.key) } : i)), extra = fed.extra, resulting = incoming;
993
999
  if (filer) {
994
1000
  // The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
995
1001
  const fedKeys = new Set(feed.map((i) => i.key));
@@ -1021,6 +1027,11 @@ function replace(file, named, on, from, filer) {
1021
1027
  }
1022
1028
  /** 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.
1023
1029
  * `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`. */
1030
+ const UNFIT = {
1031
+ text: (path) => `--media ${path} is mostly text: a picture of a log, receipt or summary is not evidence`,
1032
+ 'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
1033
+ placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
1034
+ };
1024
1035
  function postMedia(file, named, on, values, type, filer, message) {
1025
1036
  fits(values.title);
1026
1037
  const explainer = type === 'explainer';
@@ -1060,6 +1071,10 @@ function postMedia(file, named, on, values, type, filer, message) {
1060
1071
  throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
1061
1072
  return path;
1062
1073
  });
1074
+ // A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
1075
+ const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
1076
+ if (unfit)
1077
+ 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');
1063
1078
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1064
1079
  return locked(`${file}.lock`, () => {
1065
1080
  const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
@@ -1067,8 +1082,8 @@ function postMedia(file, named, on, values, type, filer, message) {
1067
1082
  const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
1068
1083
  const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
1069
1084
  if (had) {
1070
- // The stored item, read back as the message it came from: its run, and each media file by hash.
1071
- const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1085
+ // The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
1086
+ const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1072
1087
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1073
1088
  throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1074
1089
  }
@@ -1362,7 +1377,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1362
1377
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1363
1378
  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)) });
1364
1379
  // A message that names no filed time matches the one the board stamped.
1365
- const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
1380
+ const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }), announced: undefined }) !== canon(raw[i]) : same(had) !== same(d);
1366
1381
  if (had && differs())
1367
1382
  throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
1368
1383
  // Each new item records when it was raised, unless it names its own time.
@@ -1664,13 +1679,23 @@ function waitingKeys(file) {
1664
1679
  return undefined;
1665
1680
  }
1666
1681
  }
1667
- /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command. */
1682
+ /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command.
1683
+ * A card is announced once: the card keeps the record, so one that leaves Waiting and comes back (a new sign-in link) stays quiet,
1684
+ * the same silent return an Undo gets. */
1668
1685
  function notifyEntered(file, named, before) {
1669
1686
  try {
1670
1687
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1671
1688
  if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1672
1689
  return;
1673
- for (const d of board(file, () => { }).filter((d) => waits(d) && !before.has(d.key)))
1690
+ const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1691
+ if (!fresh.length)
1692
+ return;
1693
+ const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1694
+ for (const i of items)
1695
+ if (fresh.some((d) => d.key === i?.key))
1696
+ i.announced = at;
1697
+ writeItems(file, items);
1698
+ for (const d of fresh)
1674
1699
  sendNotice(so, notify, d);
1675
1700
  }
1676
1701
  catch { }
@@ -1984,6 +2009,7 @@ async function serve(args) {
1984
2009
  tokenOf(parsed.file, true);
1985
2010
  await sweep('startup');
1986
2011
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2012
+ guardReady = warmGuard();
1987
2013
  await new Promise((ok, no) => server.once('error', no).listen(port, bind, ok)).catch((e) => {
1988
2014
  throw fail(e.code === 'EADDRINUSE' ? `port ${port} is in use; pass --port` : e.code === 'EADDRNOTAVAIL' ? `${bind} is not an address of this machine; pass another --bind` : e.message, 'SERVE_FAILED', 'Run `sign-off-axi --help` for the flags, then start it again');
1989
2015
  });
@@ -2038,6 +2064,8 @@ async function exportBoard(args) {
2038
2064
  const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
2039
2065
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
2040
2066
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2067
+ guardReady = warmGuard();
2068
+ await guardReady;
2041
2069
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2042
2070
  let missing = 0;
2043
2071
  // The same URLs the page asks the server for, read through the same hash and path checks.
@@ -2081,6 +2109,79 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
2081
2109
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2082
2110
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2083
2111
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
2112
+ /** 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
2113
+ * stored hash, so each picture is measured once. */
2114
+ const unfitMedia = new Map();
2115
+ const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => isStill(m.path) && unfitMedia.get(m.sha256) === true);
2116
+ /** Every still on the board, once, so a request never walks the media: the board's files are hash-checked and immutable. */
2117
+ const stillJobs = () => {
2118
+ const jobs = [], seen = new Set();
2119
+ for (const d of loadDecisions(file)) {
2120
+ if (d.type !== 'evidence')
2121
+ continue;
2122
+ for (const m of d.media ?? []) {
2123
+ if (!isStill(m.path) || seen.has(m.sha256) || unfitMedia.has(m.sha256))
2124
+ continue;
2125
+ seen.add(m.sha256);
2126
+ try {
2127
+ jobs.push({ path: under(dirname(file), m.path), sha256: m.sha256 });
2128
+ }
2129
+ catch { /* an absent file has no faults */ }
2130
+ }
2131
+ }
2132
+ return jobs;
2133
+ };
2134
+ /** Measures the board's stills in parallel and off the request path. A fresh board is therefore ready before it is asked for cards: the
2135
+ * page and media answer at once, and the guard's pass never blocks them. One follow-up pass takes whatever a lost worker never reported. */
2136
+ let guardReady = Promise.resolve();
2137
+ /** A board read waits this long for the warm pass, so a still that is slow or stuck never holds the cards. */
2138
+ const GUARD_WAIT = 500;
2139
+ /** A worker still measuring after this is stuck in a convert that outlived its own timeout: it is stopped and its stills stay unmeasured. */
2140
+ const WORKER_WAIT = 35_000;
2141
+ const warmGuard = () => new Promise((done) => {
2142
+ const pass = (retry) => {
2143
+ let jobs;
2144
+ try {
2145
+ jobs = stillJobs();
2146
+ }
2147
+ catch {
2148
+ return done();
2149
+ }
2150
+ if (!jobs.length)
2151
+ return done();
2152
+ const workers = Math.max(1, Math.min(8, availableParallelism(), jobs.length));
2153
+ let left = workers;
2154
+ const ended = () => {
2155
+ if (--left)
2156
+ return;
2157
+ if (retry)
2158
+ pass(false);
2159
+ else
2160
+ done();
2161
+ };
2162
+ for (let i = 0; i < workers; i++) {
2163
+ let worker;
2164
+ try {
2165
+ worker = new Worker(new URL('./stills-worker.js', import.meta.url), { workerData: jobs.filter((_, j) => j % workers === i) });
2166
+ }
2167
+ catch {
2168
+ ended();
2169
+ continue;
2170
+ }
2171
+ let stop;
2172
+ const arm = () => {
2173
+ clearTimeout(stop);
2174
+ stop = setTimeout(() => { void worker.terminate(); }, WORKER_WAIT);
2175
+ stop.unref();
2176
+ };
2177
+ arm();
2178
+ worker.on('message', ([sha, unfit]) => { unfitMedia.set(sha, unfit); arm(); });
2179
+ worker.once('error', () => clearTimeout(stop));
2180
+ worker.once('exit', () => { clearTimeout(stop); ended(); });
2181
+ }
2182
+ };
2183
+ pass(true);
2184
+ });
2084
2185
  const state = () => {
2085
2186
  reap();
2086
2187
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
@@ -2091,7 +2192,7 @@ const state = () => {
2091
2192
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
2092
2193
  // Who filed an item stays in the file: the page only learns that its filer took the answer.
2093
2194
  // 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.
2094
- 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,
2195
+ 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,
2095
2196
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2096
2197
  readonly: readOnly(),
2097
2198
  answers_only: answersOnly(),
@@ -2266,6 +2367,9 @@ async function route(req) {
2266
2367
  if (req.method === 'GET' && req.url === '/api/board')
2267
2368
  return [200, { file, pid: process.pid }];
2268
2369
  if (req.method === 'GET' && req.url === '/api/decisions') {
2370
+ let timer;
2371
+ await Promise.race([guardReady, new Promise((ok) => { timer = setTimeout(ok, GUARD_WAIT); })]);
2372
+ clearTimeout(timer);
2269
2373
  return [200, state()];
2270
2374
  }
2271
2375
  if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
@@ -0,0 +1,238 @@
1
+ import { spawn, 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
+ const shareOf = (rgb) => {
30
+ const total = rgb.length / 3, bins = new Uint32Array(32768);
31
+ const centre = (b) => [(b >> 10) * 8 + 4, ((b >> 5) & 31) * 8 + 4, (b & 31) * 8 + 4];
32
+ for (let i = 0; i < rgb.length; i += 3)
33
+ bins[(rgb[i] >> 3) << 10 | (rgb[i + 1] >> 3) << 5 | (rgb[i + 2] >> 3)]++;
34
+ const paperBin = bins.indexOf(Math.max(...bins)), paper = centre(paperBin);
35
+ let inkBin = -1;
36
+ for (let b = 0; b < bins.length; b++) {
37
+ if (bins[b] && dist(centre(b), paper) > 128 && (inkBin < 0 || bins[b] > bins[inkBin]))
38
+ inkBin = b;
39
+ }
40
+ let solid = 0;
41
+ bins.forEach((count, b) => {
42
+ if (b !== paperBin && b !== inkBin && count / total > solid)
43
+ solid = count / total;
44
+ });
45
+ // Saturated flat colours: a colour-bar test card has several, each a band of the picture.
46
+ let bars = 0, barShare = 0;
47
+ bins.forEach((count, b) => {
48
+ const c = centre(b), share = count / total;
49
+ if (Math.max(...c) - Math.min(...c) >= SATURATED && share >= BAND_MIN)
50
+ bars++, (barShare += share);
51
+ });
52
+ const top = bins[paperBin] / total;
53
+ if (inkBin < 0)
54
+ return { ink: 0, off: 0, solid: 0, top, bars, barShare };
55
+ const [pr, pg, pb] = paper, [ar, ag, ab] = centre(inkBin).map((c, i) => c - paper[i]), len2 = ar * ar + ag * ag + ab * ab;
56
+ let ink = 0, off = 0;
57
+ for (let i = 0; i < rgb.length; i += 3) {
58
+ const dr = rgb[i] - pr, dg = rgb[i + 1] - pg, db = rgb[i + 2] - pb;
59
+ if (dr * dr + dg * dg + db * db <= NEAR * NEAR)
60
+ continue;
61
+ const t = Math.min(1, Math.max(0, (dr * ar + dg * ag + db * ab) / len2));
62
+ const er = dr - t * ar, eg = dg - t * ag, eb = db - t * ab;
63
+ if (er * er + eg * eg + eb * eb <= NEAR * NEAR)
64
+ ink++;
65
+ else
66
+ off++;
67
+ }
68
+ return { ink: ink / total, off: off / total, solid, top, bars, barShare };
69
+ };
70
+ export function palette(file) {
71
+ const rgb = decode(file);
72
+ return rgb && shareOf(rgb);
73
+ }
74
+ /** The still formats this guard measures. A recording is never measured: it is not a still, and decoding it would push a video through
75
+ * ImageMagick on every board load. */
76
+ const STILL = /\.(png|jpe?g|webp)$/i;
77
+ /** Whether the guard has a still to measure. Callers that scan a whole board use this to skip recordings, as post does. */
78
+ export const isStill = (file) => STILL.test(file);
79
+ /** 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
80
+ * standing in for a picture). Anything that is not a still, and a file that cannot be decoded, has no faults: it is not refused. */
81
+ function faultsOf(share) {
82
+ if (!share)
83
+ return [];
84
+ const text = share.ink >= INK_MIN && share.off <= OFF_LINE_MAX && share.solid <= SOLID_MAX;
85
+ return [
86
+ ...(text ? ['text'] : []),
87
+ ...(share.bars >= BARS_MIN && share.barShare >= BARS_SHARE_MIN ? ['colour bars'] : []),
88
+ ...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
89
+ ];
90
+ }
91
+ export function faults(file) {
92
+ if (!isStill(file))
93
+ return [];
94
+ return faultsOf(palette(file));
95
+ }
96
+ /** The same faults, decoded without blocking the caller's thread, so a hung child can be killed instead of holding a worker. */
97
+ export async function faultsAsync(file) {
98
+ if (!isStill(file))
99
+ return [];
100
+ return faultsOf(await paletteAsync(file));
101
+ }
102
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
103
+ /** 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. */
104
+ function decode(file) {
105
+ const bytes = readFileSync(file);
106
+ if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
107
+ const own = pngRgb(bytes);
108
+ if (own)
109
+ return own;
110
+ }
111
+ const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000, killSignal: 'SIGKILL' });
112
+ return out.status === 0 && out.stdout.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out.stdout) : undefined;
113
+ }
114
+ /** ImageMagick's output, bounded in time and size: the child, and anything it delegated to, is killed when either is passed. */
115
+ async function paletteAsync(file) {
116
+ const bytes = readFileSync(file);
117
+ if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
118
+ const own = pngRgb(bytes);
119
+ if (own)
120
+ return shareOf(own);
121
+ }
122
+ const out = await convert(file), rgb = out && out.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out) : undefined;
123
+ return rgb && shareOf(rgb);
124
+ }
125
+ const CONVERT_WAIT = 30_000, CONVERT_BYTES = 1 << 30;
126
+ function convert(file) {
127
+ return new Promise((done) => {
128
+ const child = spawn('convert', [file, 'png24:-'], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
129
+ const chunks = [];
130
+ let finished = false, code = null, bytes = 0;
131
+ const finish = (out) => { if (!finished) {
132
+ finished = true;
133
+ clearTimeout(timer);
134
+ done(out);
135
+ } };
136
+ const stop = () => {
137
+ finish(undefined);
138
+ try {
139
+ process.kill(-child.pid, 'SIGKILL');
140
+ }
141
+ catch {
142
+ child.kill('SIGKILL');
143
+ }
144
+ child.stdout.destroy();
145
+ };
146
+ const timer = setTimeout(stop, CONVERT_WAIT);
147
+ child.stdout.on('data', (b) => { bytes += b.length; if (bytes > CONVERT_BYTES)
148
+ stop();
149
+ else
150
+ chunks.push(b); });
151
+ child.on('error', () => finish(undefined));
152
+ child.on('exit', (c) => { code = c; });
153
+ child.on('close', () => finish(code === 0 ? Buffer.concat(chunks) : undefined));
154
+ });
155
+ }
156
+ /** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
157
+ function pngRgb(buf) {
158
+ try {
159
+ let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
160
+ const idat = [];
161
+ for (let at = 8; at + 8 <= buf.length;) {
162
+ const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
163
+ if (name === 'IHDR')
164
+ [width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
165
+ else if (name === 'PLTE')
166
+ palette = data;
167
+ else if (name === 'IDAT')
168
+ idat.push(data);
169
+ else if (name === 'IEND')
170
+ break;
171
+ at += 12 + len;
172
+ }
173
+ const channels = CHANNELS[type];
174
+ if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
175
+ return undefined;
176
+ const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
177
+ const rgb = Buffer.alloc(width * height * 3);
178
+ const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
179
+ let at = 0;
180
+ for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
181
+ const pw = Math.ceil((width - x0) / dx), ph = Math.ceil((height - y0) / dy);
182
+ if (pw <= 0 || ph <= 0)
183
+ continue;
184
+ const rowBytes = Math.ceil(pw * bits / 8), px = Buffer.alloc(rowBytes * ph);
185
+ for (let y = 0; y < ph; y++, at += rowBytes + 1) {
186
+ const filter = raw[at];
187
+ for (let i = 0; i < rowBytes; i++) {
188
+ const a = i >= bpp ? px[y * rowBytes + i - bpp] : 0, b = y ? px[(y - 1) * rowBytes + i] : 0;
189
+ let predict = 0;
190
+ if (filter === 1)
191
+ predict = a;
192
+ else if (filter === 2)
193
+ predict = b;
194
+ else if (filter === 3)
195
+ predict = (a + b) >> 1;
196
+ else if (filter === 4) {
197
+ const c = i >= bpp && y ? px[(y - 1) * rowBytes + i - bpp] : 0, p = a + b - c;
198
+ const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
199
+ predict = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
200
+ }
201
+ px[y * rowBytes + i] = (raw[at + 1 + i] + predict) & 0xff;
202
+ }
203
+ }
204
+ // 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.
205
+ const sample = (y, n) => {
206
+ if (depth === 16)
207
+ return px[y * rowBytes + n * 2];
208
+ if (depth === 8)
209
+ return px[y * rowBytes + n];
210
+ const shift = 8 - depth - ((n * depth) & 7);
211
+ return (px[y * rowBytes + ((n * depth) >> 3)] >> shift) & ((1 << depth) - 1);
212
+ };
213
+ for (let y = 0; y < ph; y++) {
214
+ for (let x = 0; x < pw; x++) {
215
+ const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * 3;
216
+ if (type === 3) {
217
+ const k = sample(y, n) * 3;
218
+ rgb[o] = palette[k] ?? 0;
219
+ rgb[o + 1] = palette[k + 1] ?? 0;
220
+ rgb[o + 2] = palette[k + 2] ?? 0;
221
+ }
222
+ else if (channels <= 2) {
223
+ rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
224
+ }
225
+ else {
226
+ rgb[o] = to8(sample(y, n));
227
+ rgb[o + 1] = to8(sample(y, n + 1));
228
+ rgb[o + 2] = to8(sample(y, n + 2));
229
+ }
230
+ }
231
+ }
232
+ }
233
+ return rgb;
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ }
@@ -0,0 +1,12 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { faultsAsync } from './evidence-text.js';
3
+ /** One slice of a board's stills, measured off the server's thread so page and media requests keep answering. */
4
+ const jobs = workerData;
5
+ for (const { path, sha256 } of jobs) {
6
+ let unfit = false;
7
+ try {
8
+ unfit = (await faultsAsync(path)).some((f) => f !== 'text');
9
+ }
10
+ catch { /* an undecodable or unreadable still is not refused */ }
11
+ parentPort.postMessage([sha256, unfit]);
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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. */
@@ -354,7 +409,8 @@
354
409
  .design-preview-full { display: block; max-width: 56rem; margin: 0 auto; color: var(--accent); }
355
410
  @media (min-width: 60rem) { .design-preview-window { max-width: 24.375rem; } }
356
411
  .design-review label { display: grid; gap: 0.5rem; max-width: 56rem; margin: 1rem auto; color: var(--muted); }
357
- .design-review textarea { min-height: 6rem; padding: 0.75rem; background: var(--surface); }
412
+ /* The design note fills its column, so it lines up with the buttons under it at every width (the 70ch prose cap would narrow it at tablet width). */
413
+ .design-review textarea { max-width: none; min-height: 6rem; padding: 0.75rem; background: var(--surface); }
358
414
  .design-review .foot { max-width: 56rem; margin: 1rem auto; }
359
415
  .design-review .foot > * { flex: 1; }
360
416
  .design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
@@ -376,7 +432,7 @@
376
432
  .design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
377
433
  .design-viewer-panel .foot > * { flex: 1; }
378
434
  .design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
379
- .design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
435
+ .design-viewer-panel textarea { max-width: none; min-height: 3rem; max-height: 6rem; margin: 0; }
380
436
  .design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
381
437
  @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; } }
382
438
  @media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }
@@ -437,14 +493,18 @@
437
493
  button:focus-visible, summary:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
438
494
  .shot:focus-visible { outline-offset: -2px; }
439
495
  .empty { margin: 0; padding: 1.5rem 1rem; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: 0.875rem; }
496
+ @media (max-width: 44.999rem) {
497
+ .plan-preview { display: none; }
498
+ .plan-copy .about { display: block; -webkit-line-clamp: unset; -webkit-box-orient: initial; overflow: visible; }
499
+ }
440
500
  @media (min-width: 45rem) {
441
501
  main { padding: 0 2rem; }
442
502
  .row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
443
- .in-waiting .row { padding-left: calc(1.25rem - 3px); }
444
503
  .row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
445
504
  /* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
446
505
  .mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
447
506
  .rung iframe { height: min(32rem, 75vh); }
507
+ .plan-hero { grid-template-columns: 46% minmax(0, 1fr); min-height: 20rem; }
448
508
  /* A picture card stacks like a design card: the picture, then its buttons at their own width. */
449
509
  .row:has(> .rung) { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
450
510
  .row:has(> .rung) .choices { justify-content: start; }
@@ -477,12 +537,11 @@
477
537
  .tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
478
538
  .pair { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
479
539
  .areas { grid-template-columns: 1fr 1fr; align-items: start; }
480
- .tri { display: grid; grid-template-columns: minmax(16rem, 2fr) minmax(0, 3fr); gap: 2rem; align-items: start; }
481
- .queue { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
482
- .queue button { display: grid; width: 100%; padding: 0.75rem 1rem; border-color: transparent; background: none; text-align: left; }
483
- .queue button[aria-current] { border-color: var(--line); background: var(--surface); }
484
- .queue .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
485
- .hint, .upnext { display: none; }
540
+ .plan-preview { height: 20rem; }
541
+ .plan-preview .rung .frame, .plan-preview .rung iframe { min-height: 0; }
542
+ .plan-copy { padding: 2rem; }
543
+ .plan-copy h2, .decide h2 { font-size: 2.5rem; line-height: 1.1; }
544
+ .decide-bar { position: static; margin: 1rem 0 0; padding: .75rem 0; background: transparent; }
486
545
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
487
546
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
488
547
  .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; }
@@ -490,7 +549,6 @@
490
549
  .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
491
550
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
492
551
  .list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
493
- .keys { display: block; }
494
552
  }
495
553
  </style>
496
554
  </head>
@@ -532,20 +590,31 @@
532
590
 
533
591
  const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
534
592
 
535
- function save(d, o, buttons, err) {
536
- buttons.forEach((b) => (b.disabled = true));
593
+ function lock(...btns) {
594
+ const controls = [...btns, ...(btns[0].closest('.decide, .row')?.querySelectorAll('button') ?? [])].filter((b) => !b.disabled);
595
+ controls.forEach((b) => (b.disabled = true));
596
+ return () => controls.forEach((b) => (b.disabled = false));
597
+ }
598
+
599
+ async function save(d, o, buttons, err, after) {
600
+ const unlock = lock(...buttons);
537
601
  // 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).
538
602
  const typed = state.answers_only || (o.value === 'text' && !d.reply) ? '' : (drafts.get(d.key) ?? '').trim();
539
603
  const note = drafts.get(d.key) ?? noteOf(d), before = answerOf(d.key);
540
604
  const drop = clearIfSaved(d.key, drafts.get(d.key));
541
- call('/api/answers', {
542
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
543
- }, () => { forget(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
544
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
545
- }, drop)).catch((e) => {
605
+ try {
606
+ await call('/api/answers', {
607
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
608
+ }, () => { forget(d.key); if (!typed) drop(); });
609
+ } catch (e) {
546
610
  shown(err).textContent = `Couldn't save: ${e.message}`;
547
- buttons.forEach((b) => (b.disabled = false));
548
- });
611
+ return unlock();
612
+ }
613
+ offerUndo(d, before);
614
+ after?.();
615
+ if (typed) call('/api/messages', {
616
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
617
+ }, drop).catch(() => say('Could not save your comment', 8000));
549
618
  }
550
619
 
551
620
  const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
@@ -571,11 +640,12 @@
571
640
  undoBtn.setAttribute('aria-label', `Undo, ${headline(d)}`);
572
641
  say(`Saved: ${now.answer === 'text' ? 'your reply' : now.label}`, 5000, () => {
573
642
  undoBtn.disabled = true;
574
- cur = d.key; // Triage shows the ask again.
575
643
  call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' },
576
644
  body: JSON.stringify({ key: d.key, answer: before?.answer ?? '', note: before?.note ?? '' }) })
577
- .then(() => say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000),
578
- (e) => say(`Couldn't undo: ${e.message}`, 8000));
645
+ .then(() => {
646
+ if (!before) location.hash = askHref(d);
647
+ say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000);
648
+ }, (e) => say(`Couldn't undo: ${e.message}`, 8000));
579
649
  });
580
650
  // The tap redrew its card and took the focus with it: put it on Undo so the keyboard can reach it in time.
581
651
  if (document.activeElement === document.body || !document.activeElement) undoBtn.focus();
@@ -586,11 +656,11 @@
586
656
 
587
657
  /** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
588
658
  function send(btn, err, body, draft, text) {
589
- btn.disabled = true;
659
+ const unlock = lock(btn);
590
660
  call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
591
661
  clearIfSaved(draft, drafts.get(draft))).catch((e) => {
592
662
  shown(err).textContent = `Couldn't send: ${e.message}`;
593
- btn.disabled = false;
663
+ unlock();
594
664
  });
595
665
  }
596
666
 
@@ -701,17 +771,20 @@
701
771
  ro ? '' : other(d, saved, err), err, tech(d));
702
772
  }
703
773
 
774
+ /** A count with its unit in the form it deserves: 1 section, 2 sections. */
775
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
704
776
  /** A plan: its one-line answer, how many sections and decisions it asks about, and one Open plan for its page over the board. */
705
777
  function plan(d, lead) {
706
- const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
707
778
  return el('article', { class: 'row plan', id: `item-${d.key}` }, el('div', {}, lead,
708
- el('p', { class: 'meta' }, `${count(d.sections.length, 'section')} · ${count(d.decisions.length, 'decision')}`)),
779
+ el('p', { class: 'meta' }, `${plural(d.sections.length, 'section')} · ${plural(d.decisions.length, 'decision')}`)),
709
780
  el('a', { class: 'open-link', href: `#${area(d)}/${d.key}`, 'aria-label': `Open plan, ${d.title}` }, 'Open plan'));
710
781
  }
782
+ /** A span of minutes: whole minutes under the hour, hours and the rest over it. */
783
+ const mins = (m) => (m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`);
711
784
  /** The time left on a sign-in link, in whole minutes. */
712
785
  const expiry = (at) => {
713
786
  const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000);
714
- 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` : ''}`}`;
787
+ return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${mins(m)}`;
715
788
  };
716
789
  /** 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. */
717
790
  function signin(d, lead, saved, err) {
@@ -772,24 +845,24 @@
772
845
  /** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
773
846
  * Sent on its own the words are the answer (`text`); typed before a button is picked they go with that answer as its note.
774
847
  * On a normal board they can also ask for another round (not on an evidence update) or go as a comment. */
775
- function other(d, saved, err) {
848
+ function other(d, saved, err, after) {
776
849
  const box = el('textarea', { id: `note-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' });
777
850
  box.value = drafts.get(d.key) ?? (saved?.answer === 'text' ? '' : noteOf(d));
778
851
  box.oninput = () => { drafts.set(d.key, box.value); keep(); };
779
852
  // An option valued text would be the same answer, so that item takes words only as a note.
780
853
  const words = !d.options.some((o) => o.value === 'text'), talk = !state.answers_only, round = talk && d.type !== 'evidence';
781
854
  const sendBtn = el('button', { 'aria-label': `Send reply, ${headline(d)}` }, 'Send reply');
782
- sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err) : (shown(err).textContent = 'Write the reply first.'));
855
+ sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err, after) : (shown(err).textContent = 'Write the reply first.'));
783
856
  const roundBtn = el('button', { class: 'quick', 'aria-label': `Another round, ${headline(d)}` }, 'Another round');
784
857
  roundBtn.onclick = () => {
785
- roundBtn.disabled = true;
858
+ const unlock = lock(roundBtn);
786
859
  const note = (drafts.get(d.key) ?? noteOf(d)).trim();
787
860
  const drop = clearIfSaved(d.key, drafts.get(d.key));
788
861
  call('/api/rounds', {
789
862
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
790
863
  }, () => { forget(d.key); drop(); }).catch((e) => {
791
864
  shown(err).textContent = `Couldn't ask: ${e.message}`;
792
- roundBtn.disabled = false;
865
+ unlock();
793
866
  });
794
867
  };
795
868
  const commentBtn = el('button', { 'aria-label': `Send as a comment, ${headline(d)}` }, 'Send as a comment');
@@ -989,6 +1062,13 @@
989
1062
  /** A page to open: an .html view, framed in a sandbox. */
990
1063
  const reads = (d) => /\.html$/i.test(d.view ?? '');
991
1064
  const hhmm = (at) => new Date(at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
1065
+ /** 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. */
1066
+ const askHref = (d) => {
1067
+ const a = area(d);
1068
+ if (a !== 'waiting') return `#${a === 'notes' ? 'reports' : a}`;
1069
+ return d.type === 'plan' ? `#waiting/${d.key}` : d.kind === 'design' ? `#designs/${d.key}` : `#decide/${d.key}`;
1070
+ };
1071
+ const left = (at) => { const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000); return m <= 0 ? 'deadline passed' : `${mins(m)} left`; };
992
1072
  const isToday = (at) => new Date(at).toDateString() === new Date().toDateString();
993
1073
  const day = (at) => new Date(at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
994
1074
  const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
@@ -1045,7 +1125,7 @@
1045
1125
  /** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
1046
1126
  function section(a, items) {
1047
1127
  const SHOWN = 4, line = (d) => {
1048
- const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : `#${a}` }, ...compact(d, a));
1128
+ const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : a === 'waiting' ? askHref(d) : `#${a}` }, ...compact(d, a));
1049
1129
  link.onclick = () => (jump = d.key);
1050
1130
  return el('li', {}, link, xBtn(d));
1051
1131
  };
@@ -1153,7 +1233,7 @@
1153
1233
  }
1154
1234
  main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
1155
1235
  if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
1156
- 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.`));
1236
+ 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.`));
1157
1237
  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.'));
1158
1238
  d.sections.forEach((s, i) => {
1159
1239
  const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
@@ -1427,43 +1507,90 @@
1427
1507
  sel.onchange = () => { waitSort = sel.value; try { localStorage.setItem(SORT_KEY, waitSort); } catch {} reshow('.sort select'); };
1428
1508
  return el('div', { class: 'filters' }, el('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by type' }, ...chips), el('label', { class: 'sort' }, 'Sort', sel));
1429
1509
  }
1430
- /** 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. */
1431
- let cur = null, curAt = 0;
1432
- const reshow = (control) => { cur = null; curAt = 0; render(); document.querySelector(control)?.focus({ preventScroll: true }); };
1433
- function triage(items, hidden) {
1434
- if (!items.length) return el('section', { class: 'card caught' }, el('h2', {}, 'All caught up'), el('p', { class: 'meta' }, 'Nothing is waiting on you.'),
1435
- el('a', { class: 'more', href: '#' }, 'Back to Summary'), dismissed(hidden));
1436
- let i = items.findIndex((d) => d.key === cur);
1437
- if (i < 0) i = Math.min(curAt, items.length - 1);
1438
- const d = items[i], next = items[i + 1], n = items.length, card = row(d, { x: false }), dis = xBtn(d, 'Dismiss');
1439
- [cur, curAt] = [d.key, i];
1440
- const go = (key) => () => { cur = key; render(); scrollTo(0, 0); }, skip = n > 1 && el('button', { class: 'skip' }, 'Skip for now'), rec = card.querySelector('.choices .rec');
1441
- if (skip) skip.onclick = go(items[(i + 1) % n].key);
1442
- // 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.
1443
- let start;
1444
- card.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
1445
- card.addEventListener('touchend', (e) => {
1446
- const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
1447
- if (Math.abs(dx) > 90 && Math.abs(t.clientY - start.clientY) < Math.abs(dx) / 2) (dx > 0 ? rec : skip)?.click();
1510
+ /** The plan is the lead card; the remaining asks are a grouped list linking to a focused decision. */
1511
+ function planCard(d) {
1512
+ const meta = [el('span', {}, plural(d.sections.length, 'section')), el('span', {}, plural(d.decisions.length, 'decision')),
1513
+ el('span', {}, `Agent${d.filed ? ` · ${when(d.filed)}` : ''}`)];
1514
+ return el('article', { class: 'plan-hero', id: `item-${d.key}` }, el('div', { class: 'plan-preview' }, rung(d)),
1515
+ el('div', { class: 'plan-copy' }, el('p', { class: 'eyebrow' }, `Plan${d.project ? ` · ${d.project}` : ''}`),
1516
+ el('h2', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about ?? ''),
1517
+ el('div', { class: 'plan-meta' }, ...meta), el('a', { class: 'open-link plan-open', href: `#waiting/${d.key}` }, 'Open plan')));
1518
+ }
1519
+ const askIcon = (d) => { const a = { design: 'designs', signin: 'you', plan: 'plan' }[typeOf(d)] ?? 'waiting'; return ICONS[a] ? a : 'waiting'; };
1520
+ function waitingPage(items, hidden) {
1521
+ const plans = items.filter((d) => d.type === 'plan'), asks = items.filter((d) => d.type !== 'plan');
1522
+ const rows = asks.map((d) => {
1523
+ const label = `Agent${d.silent_by ? ` · closes at ${when(d.silent_by)}` : d.filed ? ` · waiting since ${when(d.filed)}` : ''}`;
1524
+ const link = el('a', { class: 'ask-card', id: `item-${d.key}`, href: askHref(d) }, icon(askIcon(d)),
1525
+ el('span', { class: 'copy' }, el('span', { class: 'kind' }, kind(d)), el('strong', { class: 'title', dir: 'auto' }, title(d)),
1526
+ d.why || d.about ? el('span', { class: 'meta', dir: 'auto' }, d.why || d.about) : '', el('span', { class: 'meta' }, label)));
1527
+ return el('li', {}, link, xBtn(d));
1448
1528
  });
1449
- const hint = [rec && 'Swipe right for the recommended answer', skip && (rec ? 'left to skip' : 'Swipe left to skip')].filter(Boolean).join(', ');
1450
- 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(' · ');
1451
- const queue = el('ol', { class: 'queue', 'aria-label': `${n} ${n > 1 ? 'asks' : 'ask'}, one at a time` }, ...items.map((x) => {
1452
- const b = el('button', { ...(x === d && { 'aria-current': 'true' }) }, el('strong', { dir: 'auto' }, title(x)), el('span', { class: 'meta', dir: 'auto' }, sub(x)));
1453
- b.onclick = go(x.key);
1454
- return el('li', {}, b);
1455
- }));
1456
- return el('div', { class: 'tri' }, queue, el('div', { class: 'focus' }, el('p', { class: 'fhead' }, `${i + 1} of ${n}`),
1457
- el('div', { class: 'pbar', 'aria-hidden': 'true' }, el('i', { style: `width: ${((i + 1) / n) * 100}%` })),
1458
- d.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(d.filed)}`) : '', card,
1459
- 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))) : '',
1460
- hint ? el('p', { class: 'meta hint' }, hint) : '', el('p', { class: 'meta keys' }, `Keys: ${keys}`), dismissed(hidden)));
1529
+ return el('div', { class: 'waiting-page' },
1530
+ ...plans.map(planCard), ...(asks.length ? [el('p', { class: 'pick-label' }, 'ASKS'), el('ul', { class: 'ask-group' }, ...rows)] : []),
1531
+ !items.length ? el('p', { class: 'empty' }, EMPTY.waiting) : '', dismissed(hidden));
1532
+ }
1533
+ const selected = new Map();
1534
+ function decidePage(d, next) {
1535
+ if (!d) return el('p', { class: 'empty' }, EMPTY.waiting);
1536
+ const to = next ? `#decide/${next.key}` : '#waiting', skip = next && !state.readonly ? el('button', { class: 'skip' }, 'Skip for now') : null;
1537
+ if (skip) skip.onclick = () => (location.hash = to);
1538
+ const swipe = (page, right) => {
1539
+ let start;
1540
+ page.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
1541
+ page.addEventListener('touchend', (e) => {
1542
+ const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
1543
+ if (!start || Math.abs(dx) <= 90 || Math.abs(t.clientY - start.clientY) >= Math.abs(dx) / 2) return;
1544
+ if (dx > 0) right?.(); else skip?.click();
1545
+ });
1546
+ };
1547
+ if (d.type === 'signin') {
1548
+ const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later')));
1549
+ if (!state.readonly) swipe(signinPage);
1550
+ return signinPage;
1551
+ }
1552
+ const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` }), opts = d.options ?? [], live = !state.readonly;
1553
+ // A pick remembered from an earlier round only counts while the ask still offers it.
1554
+ if (!opts.some((o) => o.value === selected.get(d.key))) selected.delete(d.key);
1555
+ const initial = selected.get(d.key) ?? d.recommend_value ?? opts[0]?.value;
1556
+ const optionButtons = opts.map((o) => {
1557
+ const recommended = o.value === d.recommend_value, b = el('button', { class: 'pick-option', 'aria-pressed': String(o.value === initial) },
1558
+ el('span', { class: 'words' }, el('span', { class: 'label-line' }, el('span', { class: 'label' }, o.label),
1559
+ recommended ? el('span', { class: 'recommend' }, 'Recommended') : ''), o.outcome ? el('span', { class: 'reason', dir: 'auto' }, o.outcome) : ''), el('span', { class: 'check', 'aria-hidden': 'true' }, '✓'));
1560
+ b.onclick = () => { selected.set(d.key, o.value); optionButtons.forEach((btn) => btn.setAttribute('aria-pressed', String(btn === b))); answer.textContent = `Answer ${o.label}`; };
1561
+ return b;
1562
+ });
1563
+ const selectedOption = () => opts.find((o) => o.value === (selected.get(d.key) ?? initial));
1564
+ const answer = el('button', { class: 'answer' }, `Answer ${selectedOption()?.label ?? 'now'}`);
1565
+ const answerWith = (o) => save(d, o, [...optionButtons, answer], err, () => (location.hash = to));
1566
+ answer.onclick = () => { const o = selectedOption(); if (o) answerWith(o); };
1567
+ const recOpt = opts.find((o) => o.value === d.recommend_value);
1568
+ const cards = d.mocks?.length && d.options?.length ? mockCards(d, []) : null;
1569
+ const preview = cards ?? (d.view ? rung(d) : pic(d) ? thumb('waiting', pic(d)) : proof(d));
1570
+ const quiet = opts.find((o) => o.value === d.default_if_silent);
1571
+ const silent = quiet ? `If you stay quiet: ${quiet.label}${d.silent_by ? ` · ${left(d.silent_by)}` : ''}` : '';
1572
+ const head = el('div', { class: 'lead' }, el('p', { class: 'eyebrow' }, kind(d)), el('h2', { dir: 'auto' }, headline(d)));
1573
+ const why = d.why || d.tradeoff || d.about;
1574
+ const back = el('a', { class: 'later', href: '#waiting' }, 'Later');
1575
+ const page = el('section', { class: 'decide' }, preview ? el('div', { class: 'decide-preview' }, preview) : '',
1576
+ ...(d.mocks?.length ? [annotations(d)] : []), head,
1577
+ why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
1578
+ ...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
1579
+ el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
1580
+ ...(live ? [other(d, answerOf(d.key), err, () => (location.hash = to))] : []),
1581
+ silent ? el('p', { class: 'silent-line' }, silent) : '', err,
1582
+ el('div', { class: 'decide-bar' }, live && opts.length ? answer : '', skip || '', back));
1583
+ if (live) swipe(page, () => { if (recOpt && !answer.disabled) answerWith(recOpt); });
1584
+ return page;
1461
1585
  }
1462
- // Triage keys at a desk: a number picks that answer (in the order shown), E skips, Z takes back the last tap.
1586
+ const reshow = (control) => { render(); document.querySelector(control)?.focus({ preventScroll: true }); };
1587
+ // On Decide, a number selects and submits an answer; E skips to the next ask, Z undoes the last answer.
1463
1588
  addEventListener('keydown', (e) => {
1464
- if (location.hash !== '#waiting' || e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
1465
- const k = e.key.toLowerCase(), pick = /^[1-9]$/.test(k) && document.querySelectorAll('.focus .choices button')[k - 1];
1466
- const act = pick || (k === 'e' ? document.querySelector('.quiet .skip') : k === 'z' && !toast.hidden && !undoBtn.hidden ? undoBtn : null);
1589
+ if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
1590
+ const k = e.key.toLowerCase(), onDecide = /^#decide\//.test(location.hash), choices = [...document.querySelectorAll('.decide .pick-option')];
1591
+ const pick = onDecide && /^[1-9]$/.test(k) ? choices[Number(k) - 1] : null;
1592
+ if (pick) { e.preventDefault(); pick.click(); document.querySelector('.decide-bar .answer')?.click(); return; }
1593
+ const act = k === 'e' && onDecide ? document.querySelector('.decide .skip') : k === 'z' && (location.hash === '#waiting' || onDecide) && !toast.hidden && !undoBtn.hidden ? undoBtn : null;
1467
1594
  if (act && !act.disabled) e.preventDefault(), act.click();
1468
1595
  });
1469
1596
 
@@ -1488,23 +1615,30 @@
1488
1615
  const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
1489
1616
  const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
1490
1617
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
1491
- const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1492
- 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)));
1618
+ const [, at, key] = /^#(waiting|decide|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1619
+ const decideQueue = by.waiting.filter((d) => d.type === 'signin' || (d.type !== 'plan' && d.kind !== 'design'));
1620
+ const askAt = decideQueue.findIndex((d) => d.key === key), decideAt = key ? askAt : 0;
1621
+ const active = at === 'decide' ? 'waiting' : at;
1622
+ 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)));
1493
1623
  document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
1494
1624
  document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
1495
- ...(a === at && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1625
+ ...(a === active && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1496
1626
  const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
1497
- const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: '#' }, '‹ Summary')
1627
+ const pageTitle = at === 'decide' ? 'Decide' : AREAS[at];
1628
+ const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: at === 'decide' ? '#waiting' : '#' }, at === 'decide' ? '‹ Waiting on you' : '‹ Summary')
1498
1629
  : el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
1499
- el('h1', { tabindex: '-1' }, at ? AREAS[at] : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
1500
- at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
1630
+ el('h1', { tabindex: '-1' }, at ? pageTitle : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
1631
+ 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' })}`)
1632
+ : at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
1501
1633
  ? 'How the tools work, step by step. Tap one to open it.' : 'Explainers the agents made for you. Tap one to open it.')
1502
1634
  : at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, `${by.answered.filter((d) => isToday(state.answers[d.key].at)).length} answered today.`)
1503
1635
  : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
1504
- const first = by.waiting[0], more = by.waiting.length > 1 ? `Answer all ${by.waiting.length} in Triage →` : 'Open in Triage →';
1636
+ const first = by.waiting[0], more = by.waiting.length > 1 ? `Review all ${by.waiting.length} →` : 'Review ask →';
1505
1637
  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 }),
1506
1638
  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));
1507
- document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), triage(shown, hidden)]
1639
+ document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
1640
+ : at === 'decide' ? [decideAt < 0 ? el('p', { class: 'empty' }, 'Nothing to decide for that question.')
1641
+ : decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined)]
1508
1642
  : 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]))) : '',
1509
1643
  ...(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))
1510
1644
  : 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]))),
@@ -1515,7 +1649,7 @@
1515
1649
  }
1516
1650
  function freeze() {
1517
1651
  // Answers stay as they are, but nothing in the copy can be sent.
1518
- document.querySelectorAll('#board .choices button, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
1652
+ 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));
1519
1653
  // A browser will not open a data: link in a new tab; the inline preview is the copy.
1520
1654
  document.querySelectorAll('#board a[href^="data:"]').forEach((a) => a.removeAttribute('href'));
1521
1655
  }
@@ -1523,7 +1657,7 @@
1523
1657
  // 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.
1524
1658
  addEventListener('hashchange', () => {
1525
1659
  const back = !reader.hidden && reader.dataset.key;
1526
- if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
1660
+ if (jump && location.hash === '#waiting') jump = null;
1527
1661
  if (location.hash === '#waiting') waitType = 'all';
1528
1662
  render();
1529
1663
  if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }