sign-off-axi 0.4.5 → 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
@@ -12,6 +12,7 @@ import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
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.
@@ -1021,6 +1022,11 @@ function replace(file, named, on, from, filer) {
1021
1022
  }
1022
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.
1023
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
+ };
1024
1030
  function postMedia(file, named, on, values, type, filer, message) {
1025
1031
  fits(values.title);
1026
1032
  const explainer = type === 'explainer';
@@ -1060,6 +1066,10 @@ function postMedia(file, named, on, values, type, filer, message) {
1060
1066
  throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
1061
1067
  return path;
1062
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');
1063
1073
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1064
1074
  return locked(`${file}.lock`, () => {
1065
1075
  const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
@@ -2081,6 +2091,22 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
2081
2091
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2082
2092
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2083
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
+ });
2084
2110
  const state = () => {
2085
2111
  reap();
2086
2112
  // 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 +2117,7 @@ const state = () => {
2091
2117
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
2092
2118
  // Who filed an item stays in the file: the page only learns that its filer took the answer.
2093
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.
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,
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,
2095
2121
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2096
2122
  readonly: readOnly(),
2097
2123
  answers_only: answersOnly(),
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.5",
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. */
@@ -437,14 +492,18 @@
437
492
  button:focus-visible, summary:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
438
493
  .shot:focus-visible { outline-offset: -2px; }
439
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
+ }
440
499
  @media (min-width: 45rem) {
441
500
  main { padding: 0 2rem; }
442
501
  .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
502
  .row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
445
503
  /* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
446
504
  .mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
447
505
  .rung iframe { height: min(32rem, 75vh); }
506
+ .plan-hero { grid-template-columns: 46% minmax(0, 1fr); min-height: 20rem; }
448
507
  /* A picture card stacks like a design card: the picture, then its buttons at their own width. */
449
508
  .row:has(> .rung) { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
450
509
  .row:has(> .rung) .choices { justify-content: start; }
@@ -477,12 +536,11 @@
477
536
  .tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
478
537
  .pair { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
479
538
  .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; }
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; }
486
544
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
487
545
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
488
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; }
@@ -490,7 +548,6 @@
490
548
  .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
491
549
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
492
550
  .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
551
  }
495
552
  </style>
496
553
  </head>
@@ -532,20 +589,31 @@
532
589
 
533
590
  const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
534
591
 
535
- function save(d, o, buttons, err) {
536
- 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);
537
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).
538
601
  const typed = state.answers_only || (o.value === 'text' && !d.reply) ? '' : (drafts.get(d.key) ?? '').trim();
539
602
  const note = drafts.get(d.key) ?? noteOf(d), before = answerOf(d.key);
540
603
  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) => {
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) {
546
609
  shown(err).textContent = `Couldn't save: ${e.message}`;
547
- buttons.forEach((b) => (b.disabled = false));
548
- });
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));
549
617
  }
550
618
 
551
619
  const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
@@ -571,11 +639,12 @@
571
639
  undoBtn.setAttribute('aria-label', `Undo, ${headline(d)}`);
572
640
  say(`Saved: ${now.answer === 'text' ? 'your reply' : now.label}`, 5000, () => {
573
641
  undoBtn.disabled = true;
574
- cur = d.key; // Triage shows the ask again.
575
642
  call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' },
576
643
  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));
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));
579
648
  });
580
649
  // The tap redrew its card and took the focus with it: put it on Undo so the keyboard can reach it in time.
581
650
  if (document.activeElement === document.body || !document.activeElement) undoBtn.focus();
@@ -586,11 +655,11 @@
586
655
 
587
656
  /** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
588
657
  function send(btn, err, body, draft, text) {
589
- btn.disabled = true;
658
+ const unlock = lock(btn);
590
659
  call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
591
660
  clearIfSaved(draft, drafts.get(draft))).catch((e) => {
592
661
  shown(err).textContent = `Couldn't send: ${e.message}`;
593
- btn.disabled = false;
662
+ unlock();
594
663
  });
595
664
  }
596
665
 
@@ -701,17 +770,20 @@
701
770
  ro ? '' : other(d, saved, err), err, tech(d));
702
771
  }
703
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'}`;
704
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. */
705
776
  function plan(d, lead) {
706
- const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
707
777
  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')}`)),
778
+ el('p', { class: 'meta' }, `${plural(d.sections.length, 'section')} · ${plural(d.decisions.length, 'decision')}`)),
709
779
  el('a', { class: 'open-link', href: `#${area(d)}/${d.key}`, 'aria-label': `Open plan, ${d.title}` }, 'Open plan'));
710
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` : ''}`);
711
783
  /** The time left on a sign-in link, in whole minutes. */
712
784
  const expiry = (at) => {
713
785
  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` : ''}`}`;
786
+ return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${mins(m)}`;
715
787
  };
716
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. */
717
789
  function signin(d, lead, saved, err) {
@@ -772,24 +844,24 @@
772
844
  /** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
773
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.
774
846
  * 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) {
847
+ function other(d, saved, err, after) {
776
848
  const box = el('textarea', { id: `note-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' });
777
849
  box.value = drafts.get(d.key) ?? (saved?.answer === 'text' ? '' : noteOf(d));
778
850
  box.oninput = () => { drafts.set(d.key, box.value); keep(); };
779
851
  // An option valued text would be the same answer, so that item takes words only as a note.
780
852
  const words = !d.options.some((o) => o.value === 'text'), talk = !state.answers_only, round = talk && d.type !== 'evidence';
781
853
  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.'));
854
+ sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err, after) : (shown(err).textContent = 'Write the reply first.'));
783
855
  const roundBtn = el('button', { class: 'quick', 'aria-label': `Another round, ${headline(d)}` }, 'Another round');
784
856
  roundBtn.onclick = () => {
785
- roundBtn.disabled = true;
857
+ const unlock = lock(roundBtn);
786
858
  const note = (drafts.get(d.key) ?? noteOf(d)).trim();
787
859
  const drop = clearIfSaved(d.key, drafts.get(d.key));
788
860
  call('/api/rounds', {
789
861
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
790
862
  }, () => { forget(d.key); drop(); }).catch((e) => {
791
863
  shown(err).textContent = `Couldn't ask: ${e.message}`;
792
- roundBtn.disabled = false;
864
+ unlock();
793
865
  });
794
866
  };
795
867
  const commentBtn = el('button', { 'aria-label': `Send as a comment, ${headline(d)}` }, 'Send as a comment');
@@ -989,6 +1061,13 @@
989
1061
  /** A page to open: an .html view, framed in a sandbox. */
990
1062
  const reads = (d) => /\.html$/i.test(d.view ?? '');
991
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`; };
992
1071
  const isToday = (at) => new Date(at).toDateString() === new Date().toDateString();
993
1072
  const day = (at) => new Date(at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
994
1073
  const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
@@ -1045,7 +1124,7 @@
1045
1124
  /** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
1046
1125
  function section(a, items) {
1047
1126
  const SHOWN = 4, line = (d) => {
1048
- 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));
1049
1128
  link.onclick = () => (jump = d.key);
1050
1129
  return el('li', {}, link, xBtn(d));
1051
1130
  };
@@ -1153,7 +1232,7 @@
1153
1232
  }
1154
1233
  main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
1155
1234
  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.`));
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.`));
1157
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.'));
1158
1237
  d.sections.forEach((s, i) => {
1159
1238
  const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
@@ -1427,43 +1506,90 @@
1427
1506
  sel.onchange = () => { waitSort = sel.value; try { localStorage.setItem(SORT_KEY, waitSort); } catch {} reshow('.sort select'); };
1428
1507
  return el('div', { class: 'filters' }, el('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by type' }, ...chips), el('label', { class: 'sort' }, 'Sort', sel));
1429
1508
  }
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();
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));
1448
1527
  });
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)));
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;
1461
1584
  }
1462
- // 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.
1463
1587
  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);
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;
1467
1593
  if (act && !act.disabled) e.preventDefault(), act.click();
1468
1594
  });
1469
1595
 
@@ -1488,23 +1614,30 @@
1488
1614
  const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
1489
1615
  const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
1490
1616
  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)));
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)));
1493
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)));
1494
1623
  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])));
1624
+ ...(a === active && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1496
1625
  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')
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')
1498
1628
  : 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)
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)
1501
1632
  ? 'How the tools work, step by step. Tap one to open it.' : 'Explainers the agents made for you. Tap one to open it.')
1502
1633
  : at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, `${by.answered.filter((d) => isToday(state.answers[d.key].at)).length} answered today.`)
1503
1634
  : 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 →';
1635
+ const first = by.waiting[0], more = by.waiting.length > 1 ? `Review all ${by.waiting.length} →` : 'Review ask →';
1505
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 }),
1506
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));
1507
- 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)]
1508
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]))) : '',
1509
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))
1510
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]))),
@@ -1515,7 +1648,7 @@
1515
1648
  }
1516
1649
  function freeze() {
1517
1650
  // 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));
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));
1519
1652
  // A browser will not open a data: link in a new tab; the inline preview is the copy.
1520
1653
  document.querySelectorAll('#board a[href^="data:"]').forEach((a) => a.removeAttribute('href'));
1521
1654
  }
@@ -1523,7 +1656,7 @@
1523
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.
1524
1657
  addEventListener('hashchange', () => {
1525
1658
  const back = !reader.hidden && reader.dataset.key;
1526
- if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
1659
+ if (jump && location.hash === '#waiting') jump = null;
1527
1660
  if (location.hash === '#waiting') waitType = 'all';
1528
1661
  render();
1529
1662
  if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }