sign-off-axi 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,8 @@ You need Node 22.5 or newer. `init` installs the skill for each coding agent it
45
45
 
46
46
  Four places, in the bar at the bottom of a phone and the sidebar on a desk.
47
47
 
48
+ The board installs as an app (More, then Install app; on an iPad, Share, then Add to Home Screen) and, once opened, reads offline: with the board unreachable it shows the last update under "Offline, showing the last update", and sends fail rather than queue. A phone needs the board on HTTPS or localhost to install it.
49
+
48
50
  <table>
49
51
  <tr>
50
52
  <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
package/dist/cli.js CHANGED
@@ -359,11 +359,12 @@ examples:
359
359
  sign-off-axi promote fleet-dashboard-prototype
360
360
  sign-off-axi promote fleet-dashboard-prototype --undo
361
361
  `,
362
- feedback: `usage: sign-off-axi feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>) [--filer <id>]
362
+ feedback: `usage: sign-off-axi feedback <key> [board.json] (--delivered | --read | --reply <text> [--link <url>] | --acted --link <url>) [--at <epoch seconds>] [--filer <id>]
363
363
  Tells the person how far their answer, notes, new round or comment on a card got: the board shows each one as Sent, Delivered, Read and then Replied, on the card, in its Activity,
364
364
  as a badge in the lists, and a reply in Today. A wait hands an answer over and marks it (read when it is the filer's own wait, else delivered), and an on_answer hook that ran marks it delivered.
365
365
  --read: the agent has read what was sent. --reply: what the agent did or says back, 500 characters or fewer, with --link to the new version or the pull request.
366
- --acted --link: the agent acted, and the link shows what changed (the merge hook uses this). Posting the card again marks what was sent before it as acted on too.
366
+ --delivered: it was handed to the agent. --acted --link: the agent acted, and the link shows what changed (the merge hook uses this).
367
+ --at <epoch seconds>: the step happened then (replaying history). It cannot come before the send or lie in the future, a step the card already shows is left alone (nothing is written, and a reply never replaces one), so a replay never turns a later real state back into an older one and can be run twice. Posting the card again marks what was sent before it as acted on too.
367
368
  A mark covers everything sent on that card up to now. --filer refuses a card that filer did not post. It writes one line to <board>.trail.jsonl beside the board.
368
369
  examples:
369
370
  sign-off-axi feedback fleet-dashboard-full --read
@@ -1355,18 +1356,18 @@ function promote(args) {
1355
1356
  }
1356
1357
  /** feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>): the agent tells the person how far what they sent on a card got. */
1357
1358
  function feedback(args) {
1358
- const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer'].includes(args[i - 1])), key = args[at];
1359
+ const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer', '--at'].includes(args[i - 1])), key = args[at];
1359
1360
  if (at < 0 || !isKey(key))
1360
1361
  throw fail('feedback needs the key of a card', 'VALIDATION_ERROR', 'sign-off-axi feedback <key> [board.json] --read');
1361
- const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' } });
1362
- const ops = [values.read && 'read', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
1362
+ const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, delivered: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' }, at: { type: 'string' } });
1363
+ const ops = [values.read && 'read', values.delivered && 'delivered', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
1363
1364
  if (ops.length !== 1)
1364
- throw fail('feedback takes one of --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
1365
+ throw fail('feedback takes one of --delivered, --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
1365
1366
  const text = typeof values.reply === 'string' ? values.reply.trim() : undefined, link = values.link;
1366
1367
  if (text !== undefined && (!text || [...text].length > NOTE_MAX))
1367
1368
  throw fail(`--reply must be text of ${NOTE_MAX} characters or fewer`, 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --reply "<what you did>"`);
1368
- if (link !== undefined && (values.read || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
1369
- throw fail(values.read ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1369
+ if (link !== undefined && (values.read || values.delivered || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
1370
+ throw fail(values.read || values.delivered ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1370
1371
  if (values.acted && !link)
1371
1372
  throw fail('--acted needs --link <url> to what changed', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1372
1373
  ready(file, on);
@@ -1378,9 +1379,22 @@ function feedback(args) {
1378
1379
  const sent = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket');
1379
1380
  if (!sent.length)
1380
1381
  throw fail(`nothing was sent on ${key} yet`, 'NOT_FOUND', `Run \`sign-off-axi answers${on} --key ${key}\` to see its answer`);
1381
- appendMark(file, { op: values.read ? 'read' : 'reply', key, ...(text && { text }), ...(link && { link }) });
1382
+ // --at <epoch seconds> records a step that happened earlier (replaying history). It cannot precede the send, a step the card already shows is left as it is, and it
1383
+ // never writes a reply over one that exists, so a replay never turns a later real state back into an older one; run twice it writes once.
1384
+ const op = values.read ? 'read' : values.delivered ? 'delivered' : 'reply';
1385
+ if (values.at !== undefined) {
1386
+ const secs = /^\d{9,11}$/.test(values.at) ? +values.at : NaN, first = Math.min(...sent.map((t) => Math.floor(Date.parse(t.at) / 1000)));
1387
+ if (!Number.isFinite(secs) || secs > Math.floor(Date.now() / 1000))
1388
+ throw fail('--at must be whole seconds since 1970, not in the future', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read --at 1792800000`);
1389
+ if (secs < first)
1390
+ throw fail(`--at ${values.at} is before what was sent on ${key} (${first}): a step cannot come before its send`, 'VALIDATION_ERROR', 'Use the time the step happened');
1391
+ const have = sent.some((t) => op === 'reply' ? t.reply : (t[op] ?? t.reply?.at) !== undefined);
1392
+ if (have)
1393
+ return toon({ board: tilde(file), key, shows: sent.at(-1).reply ? 'Replied' : sent.at(-1).read ? 'Read' : 'Delivered', covers: sent.length, unchanged: 'yes' }, [`${key} already shows that step; nothing was written`]);
1394
+ }
1395
+ appendMark(file, { op, key, ...(text && { text }), ...(link && { link }) }, values.at !== undefined ? new Date(+values.at * 1000).toISOString().replace(/\.\d+Z$/, 'Z') : undefined);
1382
1396
  const now = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket').at(-1);
1383
- return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : 'Read', covers: sent.length }, [values.read ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
1397
+ return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : now.read ? 'Read' : 'Delivered', covers: sent.length }, [values.read || values.delivered ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
1384
1398
  }
1385
1399
  /** ticket [board.json] [--id <id> [--title <text>] [--progress <0-100>] [--parent <id>] [--priority P0-P3] [--project <name>] [--hold yes|no] [--asked yes|no]]: lists the tickets,
1386
1400
  * shows one with the cards that name it, or writes one. A ticket stands alone in <board>.tickets.jsonl: cards name it with --ticket, and nothing else reads it.
@@ -2955,16 +2969,28 @@ const squeeze = (req, bytes) => {
2955
2969
  return { bytes: gzipSync(bytes, { level: 6 }), encoding: 'gzip' };
2956
2970
  return { bytes };
2957
2971
  };
2958
- const pageTag = `"${createHash('sha256').update(page).digest('hex').slice(0, 32)}"`;
2972
+ /** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
2973
+ const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2974
+ .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
2975
+ /** The board as an app: pwa.js (the install row and the offline line, named by its content like the stylesheets) and sw.js (the service worker, which must sit at the root and is asked again each time). */
2976
+ const APP = { pwa: readFileSync(new URL('../web/pwa.js', import.meta.url)), worker: readFileSync(new URL('../web/sw.js', import.meta.url)) };
2977
+ /** The iPad launch images (see scripts/make-splash.mjs), named <width>x<height>-<theme> in points, each linked for its own device, orientation and theme. */
2978
+ const SPLASH = readdirSync(new URL('../web/', import.meta.url)).flatMap((f) => { const m = /^splash-(\d+)x(\d+)-(light|dark)\.png$/.exec(f); return m ? [{ file: f, w: +m[1], h: +m[2], theme: m[3] }] : []; });
2979
+ for (const { file } of SPLASH)
2980
+ ICONS[`/${file}`] = { type: 'image/png', bytes: readFileSync(new URL(`../web/${file}`, import.meta.url)) };
2981
+ const splashLinks = SPLASH.map(({ file, w, h, theme }) => `<link rel="apple-touch-startup-image" href="${file}" media="(device-width: ${w}px) and (device-height: ${h}px) and (-webkit-device-pixel-ratio: 2) and (orientation: ${w < h ? 'portrait' : 'landscape'}) and (prefers-color-scheme: ${theme})">`).join('\n');
2982
+ /** The page names its two stylesheets by their content, so a phone holding an older copy (kept a day) fetches the new one with the new page: a row's badge is never drawn by half of an upgrade. */
2983
+ const served = Buffer.from(page.toString().replace(/href="(tokens|ui)\.css"/g, (_, name) => `href="${name}.css?v=${createHash('sha256').update(ICONS[`/${name}.css`].bytes).digest('hex').slice(0, 12)}"`)
2984
+ .replace('</head>', () => `${splashLinks}\n<script src="pwa.js?v=${createHash('sha256').update(APP.pwa).digest('hex').slice(0, 12)}" defer></script>\n</head>`));
2985
+ /** The worker's cache is named by the whole build, so a changed page, stylesheet or worker replaces the kept copy at the next visit and never mixes two. */
2986
+ const worker = Buffer.from(`const BUILD = ${JSON.stringify(createHash('sha256').update(served).update(APP.worker).update(APP.pwa).digest('hex').slice(0, 16))};\n${APP.worker}`);
2987
+ const pageTag = `"${createHash('sha256').update(served).digest('hex').slice(0, 32)}"`;
2959
2988
  // Level 9 takes about 50 ms and level 11 over half a second for 7% less, so the page is squeezed at 9.
2960
2989
  const squeezed = {};
2961
2990
  const pageAs = (encoding) => (squeezed[encoding] ??= encoding === 'br'
2962
- ? brotliCompressSync(page, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: page.length } }) : gzipSync(page, { level: 9 }));
2991
+ ? brotliCompressSync(served, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: served.length } }) : gzipSync(served, { level: 9 }));
2963
2992
  /** The browser keeps a reply and asks again with its tag, so an unchanged page, state or file costs a 304 and no body. */
2964
2993
  const unchanged = (req, tag) => req.headers['if-none-match']?.split(/\s*,\s*/).includes(tag) ?? false;
2965
- /** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
2966
- const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2967
- .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
2968
2994
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2969
2995
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2970
2996
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -3682,7 +3708,11 @@ const server = createServer(async (req, res) => {
3682
3708
  if (given !== null)
3683
3709
  return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
3684
3710
  }
3685
- const icon = req.method === 'GET' ? ICONS[req.url ?? ''] : undefined;
3711
+ if (req.method === 'GET' && req.url === '/sw.js')
3712
+ return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }).end(worker);
3713
+ if (req.method === 'GET' && (req.url ?? '').split('?')[0] === '/pwa.js')
3714
+ return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }).end(APP.pwa);
3715
+ const icon = req.method === 'GET' ? ICONS[(req.url ?? '').split('?')[0]] : undefined;
3686
3716
  if (icon)
3687
3717
  return res.writeHead(200, { 'content-type': icon.type, 'cache-control': 'public, max-age=86400' }).end(icon.bytes);
3688
3718
  if (req.method === 'GET' && req.url === '/favicon.ico')
@@ -3691,7 +3721,7 @@ const server = createServer(async (req, res) => {
3691
3721
  const headers = { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'", etag: pageTag, 'cache-control': 'no-cache', vary: 'accept-encoding' };
3692
3722
  if (unchanged(req, pageTag))
3693
3723
  return res.writeHead(304, headers).end();
3694
- const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : page;
3724
+ const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : served;
3695
3725
  return res.writeHead(200, { ...headers, ...(encoding && { 'content-encoding': encoding }), 'content-length': bytes.length }).end(bytes);
3696
3726
  }
3697
3727
  let status, body;
package/dist/store.js CHANGED
@@ -935,7 +935,7 @@ export function markEngagementSeen(file, id, revision) {
935
935
  appendLine(deeperPath(file), { op: 'seen', version: 1, id, answerRevision: revision, at: now() });
936
936
  }
937
937
  export const trailPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.trail.jsonl';
938
- export const appendMark = (decisionsPath, mark) => appendLine(trailPath(decisionsPath), { ...mark, at: now() });
938
+ export const appendMark = (decisionsPath, mark, at = now()) => appendLine(trailPath(decisionsPath), { ...mark, at });
939
939
  /** `delivery` is the owner dispatch's record per Go deeper ask (src/owner.ts deliveries): handed to the author's session, then acknowledged by it. */
940
940
  export function readTrail(decisionsPath, items, delivery = {}) {
941
941
  const marks = readRaw(trailPath(decisionsPath)).split('\n').flatMap((line) => {
@@ -969,7 +969,7 @@ export function readTrail(decisionsPath, items, delivery = {}) {
969
969
  return sends.map((s) => {
970
970
  // A mark covers a send made at or before it (to the second: a post's time may carry milliseconds, a send's does not).
971
971
  const mark = (...ops) => marks.find((m) => ops.includes(m.op) && m.key === s.key && (!m.id || m.id === s.id) && second(m.at) >= second(s.at));
972
- const said = s.reply ? undefined : mark('reply'), again = filed.get(s.key), posted = again && second(again) > second(s.at) ? again : undefined;
972
+ const said = s.reply ? undefined : marks.filter((m) => m.op === 'reply' && m.key === s.key && (!m.id || m.id === s.id) && second(m.at) >= second(s.at)).sort((x, y) => second(y.at) - second(x.at))[0], again = filed.get(s.key), posted = again && second(again) > second(s.at) ? again : undefined;
973
973
  const reply = s.reply ?? (said ? { at: said.at, ...(said.text && { text: said.text }), ...(said.link && { link: said.link }) } : posted ? { at: posted, acted: true } : undefined);
974
974
  const read = first(mark('read')?.at, s.read, reply?.at), delivered = first(mark('delivered')?.at, s.delivered, read);
975
975
  const seen = reply && marks.find((m) => m.op === 'seen' && m.key === s.key && second(m.at) >= second(reply.at))?.at;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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
@@ -6,7 +6,10 @@
6
6
  <title>Dashboard · sign-off</title>
7
7
  <link rel="icon" href="icon.svg" type="image/svg+xml">
8
8
  <link rel="apple-touch-icon" href="apple-touch-icon.png">
9
- <link rel="manifest" href="manifest.webmanifest">
9
+ <link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials">
10
+ <meta name="mobile-web-app-capable" content="yes">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-title" content="sign-off">
10
13
  <link rel="preload" href="inter.woff2" as="font" type="font/woff2" crossorigin>
11
14
  <link rel="stylesheet" href="tokens.css">
12
15
  <link rel="stylesheet" href="ui.css">
@@ -1165,7 +1168,8 @@
1165
1168
  .rp-page > .ui-seg { margin-top: 0.75rem; }
1166
1169
  .rp-page .ui-sec .see { min-height: 0; padding: 0; border-radius: 0; background: none; font: inherit; font-size: var(--tk-fs-row); line-height: 1.5rem; font-weight: 500; }
1167
1170
  .rp-list.ui-list { margin: 0; padding: 0.25rem 0; border-radius: var(--tk-r-card); }
1168
- .rp-list .ui-icon .mono { font-style: normal; }
1171
+ .t-sheet:focus-visible { outline: 2px solid var(--tk-ink); outline-offset: 2px; }
1172
+ .ui-icon img.design-thumb { width: 2.5rem; height: 2.5rem; object-position: top; }
1169
1173
  .rp-list.ui-list > li + li::before { right: 0.75rem; left: 0.75rem; }
1170
1174
  .rp-list .dl-face.ui-row { gap: 0.75rem; min-height: 0; padding: 0 0.75rem; background: var(--tk-card); }
1171
1175
  .rp-list .dl-face.ui-row.down { background: var(--tk-inset); }
@@ -1189,8 +1193,15 @@
1189
1193
  .rkt { display: grid; flex: none; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: 0.5rem; }
1190
1194
  .rkt.k-status, .rkt.k-other { border-radius: 0.875rem; } .rkt.k-all { background: var(--tk-raised); color: var(--tk-ink); box-shadow: var(--tk-elev-tile); }
1191
1195
  .rkt .rkg svg, .rkt .ev-g svg { width: 1rem; height: 1rem; stroke-width: 1.8; }
1192
- /* The row's second line: the kind, then the bar of how much is read (a plan's bar is its parts), then the percent. */
1193
- .rp-list .ui-row .l2 { gap: 0.5rem; }
1196
+ /* The row's second line: kind and ticket; only unread rows have a progress track below. */
1197
+ .rp-list .ui-row .l2 { gap: 0.5rem; height: 1.25rem; }
1198
+ .rp-list .ui-row .rm { position: relative; overflow: visible; }
1199
+ .rp-list .ui-row .l2.meta { overflow: visible; }
1200
+ .rp-meta { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
1201
+ .rp-ticket { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1202
+ .rp-progress { display: flex; margin-top: 0.125rem; }
1203
+ .rp-progress .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
1204
+
1194
1205
  .rp-strip { display: flex; flex: 1; gap: 2px; height: 0.25rem; }
1195
1206
  .rp-strip > i { flex: 1; min-width: 0; border-radius: 2px; background: var(--tk-track); font-style: normal; }
1196
1207
  .rp-strip > i.on { background: var(--tk-fill); }
@@ -1611,16 +1622,17 @@
1611
1622
  body.tickets .ui-row .meta, body.tickets .ui-row .l2 { margin: 0; }
1612
1623
  :is(.tk-top, .tk-dhead) .ui-rb { min-height: 0; padding: 0; border: 0; }
1613
1624
  :is(.tk-top, .tk-dhead) .ui-rb .glyph { width: 1.25rem; height: 1.25rem; stroke-width: 1.6; }
1614
- .tk-rb.on { background: var(--tk-ink); color: var(--tk-canvas); }
1625
+ :is(.tk-rb, .dl-rb).on { background: var(--tk-ink); color: var(--tk-canvas); }
1615
1626
  body.tickets :focus-visible { outline-color: var(--tk-ink); }
1616
- .tk-seg small { color: var(--tk-text-tertiary); font-size: var(--tk-fs-small); font-weight: 500; font-variant-numeric: tabular-nums; }
1627
+ :is(.tk-seg, .dl-seg) small { color: var(--tk-text-tertiary); font-size: var(--tk-fs-small); font-weight: 500; font-variant-numeric: tabular-nums; }
1617
1628
  .tk-fpill { position: relative; display: inline-flex; align-items: center; gap: 0.5rem; height: 2rem; min-height: 0; margin-top: 0.75rem; padding: 0 0.625rem 0 0.375rem; border: 0; border-radius: 1rem;
1618
1629
  background: var(--tk-card); color: var(--tk-ink); font: inherit; font-size: var(--tk-fs-row); font-weight: 500; box-shadow: var(--tk-elev-card), inset 0 1px 0 var(--tk-hl); }
1619
1630
  .tk-fpill::after { content: ""; position: absolute; inset: -0.375rem 0; }
1620
1631
  .tk-fpill .glyph { width: 0.875rem; height: 0.875rem; color: var(--tk-text-tertiary); }
1621
1632
  .tk-pt { display: inline-grid; flex: none; place-items: center; width: 1.25rem; height: 1.25rem; }
1622
1633
  .tk-pt img, .tk-pt .mono { width: 1.25rem; height: 1.25rem; border-radius: 0.3125rem; object-fit: cover; }
1623
- .tk-pt .mono { background: var(--tk-ink); color: var(--tk-raised); font-size: 0.5rem; line-height: 1.25rem; font-weight: 600; text-align: center; }
1634
+ .tk-shd .ui-icon { width: 1.25rem; height: 1.25rem; border-radius: 0.3125rem; box-shadow: none; }
1635
+ .tk-shd .ui-icon :is(img, svg) { width: 1.25rem; height: 1.25rem; }
1624
1636
  /* The hero: its label and how many were done this week, the big count, how many move, and one square per ticket. */
1625
1637
  .tk-hero { container-type: inline-size; }
1626
1638
  /* The shared hero chip (ui.css), with the prototype's green rise before its count. */
@@ -1638,8 +1650,8 @@
1638
1650
  .tk-empty p, .tk-none { margin: 0.25rem 0 0; color: var(--tk-text-secondary); font-size: var(--tk-fs-row); line-height: var(--tk-lh-row); }
1639
1651
  .tk-none { margin: 0.75rem 0 0; color: var(--tk-text-tertiary); }
1640
1652
  /* The project filter's sheet: Every project, then each project with its tickets' count, the one picked checked. */
1641
- .tk-pick { width: 100%; min-height: 3rem; padding: 0; border: 0; border-radius: 0; background: none; font: inherit; font-size: var(--tk-fs-row); text-align: left; }
1642
- .tk-pick .k { color: var(--tk-ink); } .tk-pick:active { background: none; } .tk-pick .glyph { width: 1rem; height: 1rem; color: var(--tk-ink); }
1653
+ :is(.tk-pick, .dl-pick) { width: 100%; min-height: 3rem; padding: 0; border: 0; border-radius: 0; background: none; font: inherit; font-size: var(--tk-fs-row); text-align: left; }
1654
+ :is(.tk-pick, .dl-pick) .k { color: var(--tk-ink); } :is(.tk-pick, .dl-pick):active { background: none; } :is(.tk-pick, .dl-pick) .glyph { width: 1rem; height: 1rem; color: var(--tk-ink); }
1643
1655
  /* A ticket over the list, on a phone: a sheet from the bottom over a dimmed list, up to 48px from the top, scrolling inside. */
1644
1656
  .tk-over { position: fixed; z-index: 30; inset: 0; }
1645
1657
  .tk-scrim { position: absolute; inset: 0; background: var(--tk-scrim); }
@@ -1735,7 +1747,17 @@
1735
1747
  try { for (const [k, v] of JSON.parse(localStorage.getItem(stored())) ?? []) drafts.set(k, v); } catch {}
1736
1748
  }
1737
1749
  await sent?.();
1750
+ renderKeepingFocus();
1751
+ }
1752
+
1753
+ // A response can arrive after the person has started typing on the next card.
1754
+ // Keep the active editor and its selection across that redraw, just as a poll does.
1755
+ function renderKeepingFocus() {
1756
+ const typing = (document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.id === 'find') && document.activeElement;
1757
+ const start = typing && typing.selectionStart, end = typing && typing.selectionEnd, direction = typing && typing.selectionDirection;
1738
1758
  render();
1759
+ const box = typing && document.getElementById(typing.id);
1760
+ if (box) box.focus(), box.setSelectionRange(start, end, direction);
1739
1761
  }
1740
1762
 
1741
1763
  const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
@@ -1754,9 +1776,10 @@
1754
1776
  const note = drafts.get(words) ?? noteOf(d), before = answerOf(d.key);
1755
1777
  const drop = clearIfSaved(words, drafts.get(words));
1756
1778
  try {
1757
- await call('/api/answers', {
1779
+ const sent = await sendWithFeedback(`card:${d.key}`, buttons.at(-1), () => call('/api/answers', {
1758
1780
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
1759
- }, async () => { forget(d.key); unfolded.delete(d.key); if (!typed) drop(); await land?.(); });
1781
+ }, async () => { forget(d.key); unfolded.delete(d.key); if (!typed) drop(); await land?.(); }));
1782
+ if (!sent) { buttons.forEach((b) => b.classList.remove('done')); return unlock(); }
1760
1783
  } catch (e) {
1761
1784
  buttons.forEach((b) => b.classList.remove('done'));
1762
1785
  shown(err).textContent = `Couldn't save: ${e.message}`;
@@ -1764,9 +1787,12 @@
1764
1787
  }
1765
1788
  offerUndo(d, before);
1766
1789
  after?.();
1767
- if (typed) call('/api/messages', {
1768
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
1769
- }, drop).catch(() => say('Could not save your comment', 8000));
1790
+ if (typed) {
1791
+ const sendNote = () => sendWithFeedback(`card:${d.key}`, null, () => call('/api/messages', {
1792
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
1793
+ }, drop), sendNote);
1794
+ sendNote();
1795
+ }
1770
1796
  }
1771
1797
 
1772
1798
  const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
@@ -1830,14 +1856,36 @@
1830
1856
  // A render while a request was out replaced the error line; write to the one on screen.
1831
1857
  const shown = (err) => document.getElementById(err.id) ?? err;
1832
1858
 
1859
+ // The local, in-flight step of the feedback trail. Receipts survive a redraw; the saved trail still owns delivery and read times.
1860
+ const sending = new Map();
1861
+ function sendLine(id) {
1862
+ const line = el('div', { class: 'ui-send', 'data-send-feedback': id, role: 'status', 'aria-live': 'polite' });
1863
+ paintSend(line); return line;
1864
+ }
1865
+ function paintSend(line) {
1866
+ const s = sending.get(line.dataset.sendFeedback);
1867
+ line.hidden = !s;
1868
+ if (!s) return;
1869
+ line.replaceChildren(s.busy ? 'Sending…' : s.error ? `Not sent · ${s.error} ` : `Sent · ${hhmm(s.at)}`);
1870
+ if (s.error) { const retry = el('button', { type: 'button', class: 'gd-lnk' }, 'Retry'); retry.onclick = s.retry; line.append(retry); }
1871
+ }
1872
+ async function sendWithFeedback(id, btn, post, retry = () => btn?.click()) {
1873
+ if (sending.get(id)?.busy) return false;
1874
+ const paint = () => document.querySelectorAll('[data-send-feedback]').forEach((line) => { paintSend(line); if (line.dataset.sendFeedback === id && !line.hidden) line.scrollIntoView({ block: 'nearest', behavior: 'instant' }); });
1875
+ sending.set(id, { busy: true }); btn?.setAttribute('aria-busy', 'true'); paint();
1876
+ try {
1877
+ await post(); sending.set(id, { at: new Date().toISOString() }); return true;
1878
+ } catch (e) {
1879
+ sending.set(id, { error: e.message, retry }); return false;
1880
+ } finally { btn?.removeAttribute('aria-busy'); paint(); }
1881
+ }
1882
+
1833
1883
  /** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
1834
1884
  function send(btn, err, body, draft, text) {
1885
+ if (!text) return;
1835
1886
  const unlock = lock(btn);
1836
- call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
1837
- clearIfSaved(draft, drafts.get(draft))).catch((e) => {
1838
- shown(err).textContent = `Couldn't send: ${e.message}`;
1839
- unlock();
1840
- });
1887
+ sendWithFeedback(`card:${body.key ?? 'prompt'}`, btn, () => call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
1888
+ clearIfSaved(draft, drafts.get(draft)))).finally(unlock);
1841
1889
  }
1842
1890
 
1843
1891
  /** A linked run's result in plain words, for the line over the headline; the third word is the one the Evidence screens use. */
@@ -2122,12 +2170,9 @@
2122
2170
  const unlock = lock(roundBtn);
2123
2171
  const note = (drafts.get(d.key) ?? noteOf(d)).trim();
2124
2172
  const drop = clearIfSaved(d.key, drafts.get(d.key));
2125
- call('/api/rounds', {
2173
+ sendWithFeedback(`card:${d.key}`, roundBtn, () => call('/api/rounds', {
2126
2174
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
2127
- }, () => { forget(d.key); drop(); }).catch((e) => {
2128
- shown(err).textContent = `Couldn't ask: ${e.message}`;
2129
- unlock();
2130
- });
2175
+ }, () => { forget(d.key); drop(); })).finally(unlock);
2131
2176
  };
2132
2177
  const commentBtn = el('button', { 'aria-label': `Send without answering, ${name}` }, 'Send without answering');
2133
2178
  commentBtn.onclick = () => send(commentBtn, err, { kind: 'comment', key: d.key }, d.key, box.value.trim());
@@ -2148,7 +2193,7 @@
2148
2193
  fold.ontoggle = () => (fold.open ? unfolded.add(d.key) : unfolded.delete(d.key));
2149
2194
  fold.firstChild.onclick = () => setTimeout(() => fold.open && box.focus());
2150
2195
  return el('div', { class: 'note' }, saved?.answer === 'text' ? el('p', { class: 'meta saved-note', dir: 'auto' }, `Your reply: ${saved.note}`) : '',
2151
- kept ? el('p', { class: 'meta saved-note', dir: 'auto' }, `Your comment, sent with ${saved.label}: ${kept}`) : '', fold);
2196
+ kept ? el('p', { class: 'meta saved-note', dir: 'auto' }, `Your comment, sent with ${saved.label}: ${kept}`) : '', fold, sendLine(`card:${d.key}`));
2152
2197
  }
2153
2198
 
2154
2199
  /** Free-text prompts for the agent, which it reads with sign-off-axi poll. */
@@ -2162,7 +2207,7 @@
2162
2207
  return el('details', { class: 'note ask', ...(drafts.get('') && { open: '' }) },
2163
2208
  el('summary', {}, ['Ask the agent', queued && `${queued} queued`, prompts.length - queued && `${prompts.length - queued} read by the agent`].filter(Boolean).join(' · ')),
2164
2209
  ...prompts.map((m) => el('p', { class: 'meta saved-note', dir: 'auto' }, `${m.delivered ? 'Read by the agent' : 'Queued'}: ${m.text}`)),
2165
- el('label', { for: box.id }, 'A prompt the agent reads the next time it checks in'), box, el('div', { class: 'actions' }, btn), err);
2210
+ el('label', { for: box.id }, 'A prompt the agent reads the next time it checks in'), box, el('div', { class: 'actions' }, btn), sendLine('card:prompt'), err);
2166
2211
  }
2167
2212
 
2168
2213
  /** The proof on the card itself: its own captures, or else the media of its linked run.
@@ -2250,13 +2295,14 @@
2250
2295
  was && { at: was.at, text: `Your answer on the earlier round: ${was.label}${wrote.length === 1 && on(wrote[0]) ? ` (${on(wrote[0])})` : ''}`,
2251
2296
  note: wrote.length === 1 ? wrote[0].note : wrote.map((n) => (on(n) ? `${on(n)}: ${n.note}` : n.note)).join(' · ') },
2252
2297
  a?.replaces?.at && { at: a.replaces.at, text: `Your answer: ${said(a.replaces)}`, note: a.replaces.note },
2253
- a && !d.earlier_answer && { at: a.at, text: `${a.replaces ? 'Changed to' : 'Your answer'}: ${said(a)}`, note: a.note },
2298
+ a && !d.earlier_answer && { at: a.at, text: `${a.replaces ? 'Changed to' : 'Your answer'}: ${said(a)}`, note: a.note || a.notes?.map((n) => n.note).filter(Boolean).join(' · ') },
2254
2299
  ...trailOf(d.key).flatMap((t) => [t.delivered && { at: t.delivered, text: `Delivered to ${by}` }, t.read && { at: t.read, text: `${by} read it` },
2255
2300
  t.reply && !t.reply.acted && { at: t.reply.at, text: `${by} ${t.reply.text ? 'replied' : 'acted on it'}`, note: t.reply.text, link: t.reply.link && [linkName(t.reply.link), t.reply.link] }]),
2256
2301
  ...(state.rounds ?? []).filter((r) => r.key === d.key).map((r) => ({ at: r.at, text: 'You asked for a new round', note: r.note })),
2257
2302
  ...trailOf(d.key, 'discard').map((t) => ({ at: t.at, text: `Discarded: ${reasonOf(t.reason)}` })),
2303
+ ...(state.comments ?? []).filter((c) => c.key === d.key && c.text).map((c) => ({ at: c.at, text: 'Your comment', note: c.text })),
2258
2304
  ...(state.messages ?? []).filter((m) => m.kind === 'comment' && m.key === d.key).map((m) => ({ at: m.at, text: 'Your comment', note: m.text }))]
2259
- .filter((e, i, all) => e && !Number.isNaN(Date.parse(e.at)) && all.findIndex((x) => x && x.text === e.text && bySecond(x, e) === 0) === i).sort(bySecond);
2305
+ .filter((e, i, all) => e && !Number.isNaN(Date.parse(e.at)) && all.findIndex((x) => x && x.text === e.text && x.note === e.note && bySecond(x, e) === 0) === i).sort(bySecond);
2260
2306
  };
2261
2307
  /** A card's Activity (ui.css): its steps as one sunk group, each in plain words with its time, the note that went with it, and the card it is on where the steps
2262
2308
  * are a ticket's. A card whose only step is its posting shows none (its time is already on it). */
@@ -2343,7 +2389,7 @@
2343
2389
  check: '<path d="M5 10.4l3.2 3.1L15 6.6"/>', cross: '<path d="M6.5 6.5l7 7M13.5 6.5l-7 7"/>' };
2344
2390
  const evGlyph = (a) => { const s = el('span', { class: 'ev-g', 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 20 20">${EV_ICONS[a]}</svg>`; return s; };
2345
2391
  /** A proof's tile: its project's mark (the Reports tile), with done-done's check at its corner. */
2346
- const evTile = (name, mark = '') => { const t = projectTile(name); t.append(mark); return t; };
2392
+ const evTile = (name, mark = '') => { const t = projectTile(name, () => icon('evidence')); t.append(mark); return t; };
2347
2393
  const judgedMark = (d) => (judged(d) ? el('span', { class: `ev-ok ${judged(d)}` }, evGlyph(judged(d) === 'held' ? 'check' : 'cross')) : '');
2348
2394
  const prio = (d) => (/^P[0-3]$/.test(d.priority ?? '') ? d.priority : '');
2349
2395
  const evWhen = (d) => (d.filed ? (isToday(d.filed) ? hhmm(d.filed) : day(d.filed)) : '');
@@ -2351,7 +2397,7 @@
2351
2397
  const begun = (t) => !!t && t.progress > 0;
2352
2398
  /** One proof as a row: its project's tile with done-done's check, its title and P0-P3 chip, then its ticket's progress once begun, or its project and time. */
2353
2399
  function evRow(d) {
2354
- const t = begun(ticketOf(d)) && ticketOf(d), project = projectOf(d), meta = [project, evWhen(d)].filter(Boolean).join(' · '), title = plain(headline(d));
2400
+ const t = begun(ticketOf(d)) && ticketOf(d), project = projectName(d), meta = [project, evWhen(d)].filter(Boolean).join(' · '), title = plain(headline(d));
2355
2401
  return el('li', {}, el('a', { class: 'ui-row ev-card', href: `#evidence/${d.key}`, id: `item-${d.key}`, 'data-row': '',
2356
2402
  'aria-label': [title, judgedWord(d), prio(d), t ? `${t.title}, ${t.progress}% done` : meta].filter(Boolean).join(', ') },
2357
2403
  ...uiRow({ tile: evTile(project, judgedMark(d)), title, priority: prio(d), ...(t ? { progress: t.progress } : { meta }) })));
@@ -2479,6 +2525,7 @@
2479
2525
 
2480
2526
  /** A mock or view is served from inside its own folder, so the files beside it load by their relative names. */
2481
2527
  const mockUrl = (key, value, path) => `${state.mockBase}${key}~${value}/${encodeURIComponent(path.split('?')[0].split('/').pop())}`;
2528
+ const designFrame = (d) => Object.values(d.journeys?.[0]?.screens?.[0]?.views ?? {}).find(p => /\.(png|jpe?g|webp|svg)$/i.test(p)) || d.preview;
2482
2529
  const designUrl = (d, path) => `${state.mockBase.replace('/mock/', '/design/')}${d.key}/${encodeURIComponent(path)}`;
2483
2530
  /** A diagram shows inline at card width; an interactive page runs in a sandboxed frame. A tap or the link opens either full size. */
2484
2531
  function rung(d) {
@@ -2809,7 +2856,7 @@
2809
2856
  const stamp = (at) => (isToday(at) ? `Today, ${hhmm(at)}` : `${day(at)}, ${hhmm(at)}`);
2810
2857
  const pic = (d) => {
2811
2858
  const i = d.media?.findIndex((m) => !/\.(mp4|webm)$/i.test(m.path)), m = d.mocks?.find((x) => !/\.html?$/i.test(x.path));
2812
- return d.kind === 'design' && d.preview ? designUrl(d, d.preview) : i >= 0 ? filesOf(d)[i][3] : d.preview ? mockUrl(d.key, '', d.preview) : d.type === 'diagram' ? mockUrl(d.key, '', d.view)
2859
+ return d.kind === 'design' && designFrame(d) ? designUrl(d, designFrame(d)) : i >= 0 ? filesOf(d)[i][3] : d.preview ? mockUrl(d.key, '', d.preview) : d.type === 'diagram' ? mockUrl(d.key, '', d.view)
2813
2860
  : m ? mockUrl(d.key, m.value, m.path) : d.evidenceCard?.media.find((x) => x.type.startsWith('image/'))?.url;
2814
2861
  };
2815
2862
  const thumb = (a, url) => { if (!url) return icon(a); const img = el('img', { class: 'thumb', src: src(url), alt: '', loading: 'lazy', decoding: 'async' }); img.onerror = () => img.replaceWith(icon(a)); return img; };
@@ -2842,18 +2889,22 @@
2842
2889
  /** The project a card is for: a plan names it, a report or design names its group, proof names the app its done-done check verified, and an ask takes the project of the proof it rests on,
2843
2890
  * or, filed by a tool that is itself a project on the board (its icon set with project --icon), that project. */
2844
2891
  const projectName = (d) => d.project || d.group || (d.type === 'evidence' ? projectOf(d) : d.proof && projectOf(state.decisions.find((x) => x.key === d.proof) ?? {}))
2845
- || (d.by && (state.projects ?? []).find((p) => p.name.toLowerCase() === d.by.toLowerCase())?.name) || undefined;
2846
- /** A project's picture: the icon the board serves for it (set with sign-off-axi project --icon), else its initials, as also when the icon cannot be served. */
2847
- function projectPic(name) {
2848
- const parts = name.split(/[-\s_]+/).filter(Boolean), mono = el('span', { class: 'mono' }, (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : name.slice(0, 2)).toUpperCase());
2849
- if (!(state.projects ?? []).some((p) => p.icon && p.name.toLowerCase() === name.toLowerCase())) return mono;
2850
- const img = el('img', { src: `/api/project-icon?name=${encodeURIComponent(name)}`, alt: '' });
2851
- img.onerror = () => img.replaceWith(mono);
2892
+ || (ticketOf(d) && tkProject(ticketOf(d))) || (d.by && (state.projects ?? []).find((p) => p.name.toLowerCase() === d.by.toLowerCase())?.name) || undefined;
2893
+ /** One project picture and tile for every list; use the pinned record, with a kind icon when the project picture is unavailable. */
2894
+ function projectPic(name, fallback = () => glyph('layers')) {
2895
+ const p = name && (state.projects ?? []).find((p) => p.name.toLowerCase() === name.toLowerCase());
2896
+ if (!p?.icon) return fallback();
2897
+ const img = el('img', { src: `/api/project-icon?name=${encodeURIComponent(p.name)}&h=${p.icon.sha256.slice(0, 16)}`, alt: '' });
2898
+ img.onerror = () => img.replaceWith(fallback());
2852
2899
  return img;
2853
2900
  }
2854
- /** A card's 40px tile: its project's picture, else the line drawing of what it is. */
2855
- const cardTile = (d) => { const name = projectName(d), a = area(d); if (!name) return uiIcon({ draw: a === 'waiting' ? askIcon(d) : { evidence: 'evidence', answered: 'done', designs: 'phone' }[a] ?? 'plan' });
2856
- const t = el('span', { class: 'ui-icon', 'aria-hidden': 'true' }, projectPic(name)); return t; };
2901
+ const projectTile = (name, fallback) => el('span', { class: 'ui-icon', role: 'img', 'aria-label': name || 'No project' }, projectPic(name, fallback));
2902
+ const cardTile = (d) => {
2903
+ const design = d.kind === 'design' || d.type === 'design', tile = projectTile(projectName(d), () => design ? icon('designs') : d.type === 'plan' ? icon('plan') : d.kind === 'report' || d.type === 'explainer' ? kindSvg(kindOf(d)) : icon(d.type === 'evidence' ? 'evidence' : d.reply ? 'reply' : 'yesno'));
2904
+ const frame = design && designFrame(d);
2905
+ if (frame) { const img = el('img', { class: 'design-thumb', src: src(designUrl(d, frame)), alt: '' }); img.onerror = () => img.replaceWith(projectPic(projectName(d), () => icon('designs'))); tile.replaceChildren(img); }
2906
+ return tile;
2907
+ };
2857
2908
  /** A card as the new look's row, a link to where it opens: its tile, its title and priority, then its ticket's progress once it has some (a full bar when done), or else its project (or what it is) and when. */
2858
2909
  const progressOf = (n) => (n > 0 ? Math.min(n, 100) : undefined);
2859
2910
  function cardRow(d) {
@@ -2928,6 +2979,7 @@
2928
2979
  const links = [['#answered', 'Answered', by.answered], ['#designs', 'Designs', by.designs]].filter(([, , l]) => l.length);
2929
2980
  todayMore.replaceChildren(el('div', {}, sheetHead('t-more', 'More'), links.length ? el('div', { class: 'ui-props' }, ...links.map(([h, name, l]) => go(h, name, l.length))) : '',
2930
2981
  ...(state.leash ? leash() : []), ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()]),
2982
+ FROZEN ? '' : window.signOffInstall?.() ?? '',
2931
2983
  state.version ? el('div', { class: 'ui-props' }, el('div', { class: 'ui-pr' }, el('span', { class: 'k' }, 'Version'), el('span', { class: 'v' }, el('span', {}, state.version)))) : ''));
2932
2984
  }
2933
2985
  const hasMore = (by) => by.answered.length || by.designs.length || state.leash || state.version || !(FROZEN || state.readonly || state.answers_only);
@@ -2941,7 +2993,7 @@
2941
2993
  const q = query.trim().toLowerCase(), gone = new Set(state.dismissed ?? []), hit = (...s) => s.join(' ').toLowerCase().includes(q);
2942
2994
  const cards = q ? state.decisions.filter((d) => !gone.has(d.key) && hit(title(d), d.about ?? '', projectName(d) ?? '')).reverse() : [];
2943
2995
  const group = (name, ...as) => [name, cards.filter((d) => as.includes(area(d))).slice(0, 4).map(cardRow)];
2944
- const tickets = q ? (state.tickets ?? []).filter((t) => hit(t.title, t.id)).slice(0, 4).map((t) => el('li', {}, el('a', { class: 'ui-row', href: `#ticket/${t.id}` }, ...uiRow({ tile: uiIcon({ draw: 'pick' }), title: t.title, progress: progressOf(t.progress), meta: 'Not started' })))) : [];
2996
+ const tickets = q ? (state.tickets ?? []).filter((t) => hit(t.title, t.id)).slice(0, 4).map((t) => el('li', {}, el('a', { class: 'ui-row', href: `#ticket/${t.id}` }, ...uiRow({ tile: tkTile(t), title: t.title, progress: progressOf(t.progress), meta: 'Not started' })))) : [];
2945
2997
  const groups = [group('Asks', 'waiting'), group('Proof', 'evidence'), group('Reports', 'reports', 'notes'), group('Answered', 'answered'), group('Designs', 'designs'), ['Tickets', tickets]].filter(([, rows]) => rows.length);
2946
2998
  out.replaceChildren(...(!q ? [el('p', { class: 't-grp' }, 'Type to search the whole board.')] : groups.length ? groups.flatMap(([name, rows]) => [el('p', { class: 't-grp' }, name), el('ul', { class: 'ui-card ui-list' }, ...rows)])
2947
2999
  : [el('p', { class: 't-grp' }, `Nothing matches “${query.trim()}”.`)]));
@@ -2980,8 +3032,8 @@
2980
3032
  const tkWhen = (at) => (isToday(at) ? hhmm(at) : day(at));
2981
3033
  /** Under an epic its tickets; under a ticket its sub-tasks. */
2982
3034
  const tkKids = (t) => (tkAbove(t).length ? 'sub-task' : 'ticket');
2983
- /** A ticket's 40px tile: its project's picture or initials, else the list drawing. */
2984
- const tkTile = (t) => (tkProject(t) ? el('span', { class: 'ui-icon', 'aria-hidden': 'true' }, projectPic(tkProject(t))) : uiIcon({ draw: 'pick' }));
3035
+ /** A ticket uses the same project tile as every card list. */
3036
+ const tkTile = (t) => projectTile(tkProject(t), () => icon('pick'));
2985
3037
  /** A ticket's row: its tile, its title and priority, then a bar with its percent once it has progress (a full bar when done), or else where it stands and when it last moved,
2986
3038
  * with how many of what sits under it are done. On a desk the open ticket is marked. */
2987
3039
  function tkRow(t) {
@@ -3046,6 +3098,17 @@
3046
3098
  see.onclick = () => { all ? tkMore.delete(key) : tkMore.add(key); render(); };
3047
3099
  return el('section', { 'aria-label': name }, el('h2', { class: 'ui-sec' }, name, el('small', {}, String(list.length)), list.length > 3 ? see : ''), el('ul', { class: 'ui-card ui-list' }, ...(all ? list : list.slice(0, 3)).map(tkRow)));
3048
3100
  }
3101
+ // A tap is aimed where the finger went down. When the list moves under it before the tap lands (a redraw, an image, the page settling), the tap lands on the row that slid under
3102
+ // the finger and opens a ticket; a tap that began on a section's See all or Show fewer does that button's work instead.
3103
+ let tkSeeDown = null;
3104
+ addEventListener('pointerdown', (e) => { const b = e.target.closest?.('body.tickets .ui-sec button.see'); tkSeeDown = b && { label: b.closest('section')?.getAttribute('aria-label'), at: e.timeStamp }; }, true);
3105
+ addEventListener('click', (e) => {
3106
+ const down = tkSeeDown;
3107
+ tkSeeDown = null;
3108
+ if (!down || e.timeStamp - down.at > 1000 || e.target.closest?.('.ui-sec button.see')) return;
3109
+ e.preventDefault(), e.stopPropagation();
3110
+ [...document.querySelectorAll('main section[aria-label]')].find((s) => s.getAttribute('aria-label') === down.label)?.querySelector('.ui-sec button.see')?.click();
3111
+ }, true);
3049
3112
  /** The Tickets screen. On a phone: the title row, Your asks or System, the hero, then In progress, On hold, To do and Done, and two tiles, with the open ticket as a sheet over them.
3050
3113
  * On a desk: one filled screen, the head across, then the hero over In progress, On hold over To do, and the open ticket (the first one moving when none is picked). */
3051
3114
  function tickets(by, id) {
@@ -3093,7 +3156,7 @@
3093
3156
  // Its cards' steps after their postings (Linked shows those), newest first, each naming its card.
3094
3157
  const steps = cards.flatMap((d) => threadOf(d).filter((e) => !e.posted).map((e) => ({ ...e, card: d }))).sort(bySecond).reverse(), said = trailOf(t.id, 'ticket');
3095
3158
  seeReply(t.id, 'ticket');
3096
- return [el('div', { class: 'tk-shd' }, el('p', {}, proj ? el('span', { class: 'tk-pt', 'aria-hidden': 'true' }, projectPic(proj)) : '', el('span', {}, [proj, TK_WORDS[s]].filter(Boolean).join(' · '))), close),
3159
+ return [el('div', { class: 'tk-shd' }, el('p', {}, tkTile(t), el('span', {}, [proj, TK_WORDS[s]].filter(Boolean).join(' · '))), close),
3097
3160
  el('h2', { class: 'tk-dt', tabindex: '-1', dir: 'auto' }, t.title),
3098
3161
  t.release ? el('span', { class: 'ui-pc tk-release', title: t.release }, t.release) : '',
3099
3162
  el('div', { class: 'ui-props' }, t.priority ? prop('flag', 'Priority', el('span', { class: `ui-pc ${t.priority.toLowerCase()}` }, t.priority)) : '',
@@ -3180,7 +3243,9 @@
3180
3243
  const queue = [...by.waiting].sort((x, y) => lead(x) - lead(y)), up = upNext(queue), rest = waitingOn(queue.slice(1)), tiles = todayTiles(by), more = moreLine(by), replies = repliesOn();
3181
3244
  if (todayMore.open) fillMore(by);
3182
3245
  if (location.hash === '#find' && !searchSheet.open) openSearch();
3183
- return phone() ? [up, replies, rest, tiles, more] : [el('div', { class: 't-cols' }, el('div', {}, up, tiles, more), el('div', {}, replies, rest))];
3246
+ const open = by.designs.filter(dzOpen), designs = open.length ? el('section', { class: 't-wait', 'aria-label': 'Designs to review' }, el('h2', { class: 'ui-sec' }, 'Designs to review', el('a', { class: 'see', href: '#designs' }, 'See all')),
3247
+ el('ul', { class: 'ui-card ui-list' }, ...open.slice(0, 2).map(designRow))) : '';
3248
+ return phone() ? [up, replies, rest, designs, tiles, more] : [el('div', { class: 't-cols' }, el('div', {}, up, tiles, more), el('div', {}, replies, rest, designs))];
3184
3249
  }
3185
3250
 
3186
3251
  /** A short row's second line: what changed, unless the headline already says it. */
@@ -3247,7 +3312,7 @@
3247
3312
  const kindOf = (d) => (KINDS[d.purpose] ? d.purpose : d.type === 'plan' ? 'plan' : GUESS.find(([, re]) => re.test([d.title, d.about, ...(d.sections ?? []).map((s) => s.heading)].join(' ')))?.[0] ?? 'other');
3248
3313
  const kindSvg = (k) => { const s = el('span', { class: 'rkg', 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24">${KINDS[k][3]}</svg>`; return s; };
3249
3314
  /** The kind's chip: its icon and its short name on a row, its full name on a report's head. */
3250
- const kindChip = (d, full) => { const k = kindOf(d); return el('span', { class: `rkc k-${k}`, 'data-kind': k }, kindSvg(k), full ? KINDS[k][1] : KINDS[k][0]); };
3315
+ const kindChip = (d, full) => { const k = kindOf(d); return el('span', { class: `rkc k-${k}`, 'data-kind': k }, kindSvg(k), (full ? KINDS[k][1] : KINDS[k][0]) + (!KINDS[d.purpose] && d.type !== 'plan' ? ' · kind guessed' : '')); };
3251
3316
  /** The kind's tile in the filter and its pill: its icon on the same treatment as its chip; Every kind is the filter's own drawing. */
3252
3317
  const kindTile = (k) => (k ? el('span', { class: `rkt k-${k}` }, kindSvg(k)) : el('span', { class: 'rkt k-all' }, evGlyph('filter')));
3253
3318
  /** One line under a report's title: what this kind of report is for. */
@@ -3265,7 +3330,7 @@
3265
3330
  const sourcesOf = (d) => { const x = (d.sections ?? []).find((y) => y.part === 'sources'), its = (x?.items ?? []).filter((it) => it.link); return x && its.length ? { id: x.id, n: its.length, users: its.filter((it) => it.source === 'user').length } : null; };
3266
3331
  /** How much of a report is read: all of it once read; one not read yet, by the parts of it read (a report 5's kind parts; a page report has none). */
3267
3332
  /** A report's parts as a strip, one segment each, filled once read (all of it once the report is read); on a desk's head each segment names its part. */
3268
- // On a row a read report's strip is full, as its percent is; over its parts (a sheet, a desk's head) each segment is its part, filled once that part is read.
3333
+ // On an unread row, each segment is a part; over its parts (a sheet, a desk's head), each segment records that part's read state.
3269
3334
  const partsStrip = (d, parts = false) => el('span', { class: 'rp-strip', 'aria-hidden': 'true' }, ...kindParts(d).map(([p]) => el('i', { class: (!parts && !unread(d)) || partRead(d, p) ? 'on' : '' })));
3270
3335
  /** Over a report's parts: the strip of them, and the next part to read. */
3271
3336
  const stripRow = (d) => { const next = kindParts(d).find(([p]) => !partRead(d, p)); return el('div', { class: 'rp-strip-row' }, partsStrip(d, true), next ? el('p', { class: 'rp-next', dir: 'auto' }, `Next: ${next[1]}`) : ''); };
@@ -3276,18 +3341,6 @@
3276
3341
  .replace(/(?:~|\/home)\/\S+/g, '').replace(/\b(?:[\w.-]+\/)+([\w.-]+)\.(?:md|json|png|jpe?g|html|sh|mjs|js|ts|py|yaml)\b/g, (m, b) => (/^(report|plan)$/i.test(b) ? `the ${b.toLowerCase()}` : 'the file')).replace(/\s{2,}/g, ' ').trim();
3277
3342
  return s && s[0].toUpperCase() + s.slice(1);
3278
3343
  };
3279
- /** A project's mark: the icon the board pins for it, else its initials; a report with no project gets the page drawing. */
3280
- const initials = (name) => { const ps = name.split(/[-\s_]+/).filter(Boolean); return (ps.length > 1 ? ps[0][0] + ps.at(-1)[0] : name.slice(0, 2)).toUpperCase(); };
3281
- function projectMark(name) {
3282
- const p = name && (state.projects ?? []).find((x) => x.name.toLowerCase() === name.toLowerCase());
3283
- const mono = name ? el('i', { class: 'mono' }, initials(name)) : '';
3284
- if (!p?.icon) return mono;
3285
- // A file changed or moved since it was pinned answers 404; the initials stand in.
3286
- const img = el('img', { src: `/api/project-icon?name=${encodeURIComponent(p.name)}&h=${p.icon.sha256.slice(0, 16)}`, alt: '' });
3287
- img.onerror = () => img.replaceWith(mono);
3288
- return img;
3289
- }
3290
- const projectTile = (name) => { const m = projectMark(name); return m ? el('span', { class: 'ui-icon', 'aria-hidden': 'true' }, m) : uiIcon({ draw: 'plan' }); };
3291
3344
  let rpRisen = false;
3292
3345
  function markRead(keys, read, text) {
3293
3346
  keys.forEach((k) => ((state.opened ??= {})[k] = read));
@@ -3319,12 +3372,16 @@
3319
3372
  await archive(d);
3320
3373
  if (next && location.hash === `#reports/${d.key}`) rpPicked = true, location.hash = next;
3321
3374
  }
3322
- /** A report: a row with its project's icon, its title (and its priority when it has one), then a bar of how much of it is read with its percent, as the prototype draws every row.
3375
+ /** A report: a row with its project's icon, its title (and its priority when it has one), then its kind and ticket, with a quiet progress track only while unread.
3323
3376
  * A tap opens its page; an explainer with no page opens in place, to its numbered steps or its video card. */
3324
3377
  function report(d, i = 0) {
3325
3378
  const n = d.steps?.length;
3326
- // The second line leads with the report's kind; a plan's bar is its parts, one segment each, filled once read.
3327
- const face = uiRow({ tile: projectTile(d.group), title: plain(title(d)), priority: d.priority, progress: readPct(d), badge: trailBadge(d.key), lead: kindChip(d), bar: kindOf(d) === 'plan' && kindParts(d).length ? partsStrip(d) : undefined });
3379
+ // Keep the kind and ticket readable on line two; read progress sits quietly below while unread.
3380
+ const t = ticketOf(d), ticket = d.ticket || t?.title || 'No ticket';
3381
+ const meta = el('span', { class: 'rp-meta' }, kindChip(d), el('span', { class: 'rp-ticket', title: t ? `${t.id}: ${t.title}` : ticket }, ticket));
3382
+ const face = uiRow({ tile: cardTile(d), title: plain(title(d)), priority: d.priority, after: trailBadge(d.key), meta });
3383
+ if (unread(d)) face[1].append(el('span', { class: 'rp-progress', role: 'progressbar', 'aria-label': 'Report read', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(readPct(d)) },
3384
+ kindOf(d) === 'plan' && kindParts(d).length ? partsStrip(d) : el('span', { class: 'ui-bar' }, el('i', { style: `width: ${readPct(d)}%` })), el('span', { class: 'pct sr-only' }, `${readPct(d)}%`)));
3328
3385
  const sel = rpSel === d.key;
3329
3386
  const li = (...kids) => el('li', { class: `report dl-row${flash === d.key ? ' returned' : ''}${sel ? ' sel' : ''}`, style: `--n:${i}`, ...(reads(d) && { 'data-key': d.key, 'data-list': 'reports' }) }, ...kids);
3330
3387
  const act = (cls, mark, words) => el('div', { class: `dl-act ${cls}`, 'aria-hidden': 'true' }, glyph(mark), el('span', {}, words));
@@ -3486,11 +3543,12 @@
3486
3543
  el('h3', {}, 'Whole-plan comment'), el('label', { for: commentBox.id }, 'Comment for the agent on the whole plan (optional)', commentBox)));
3487
3544
  send.onclick = () => {
3488
3545
  send.disabled = true; err.textContent = '';
3489
- call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note: comment,
3546
+ sendWithFeedback(`plan:${d.key}`, send, () => call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note: comment,
3490
3547
  notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries([...picks].map(([k, v]) => [k, v.trim()])) }) }, () => {
3491
3548
  drafts.delete(draftKey); keep(); location.hash = `#answered/${d.key}`;
3492
- }).catch((e) => { err.textContent = `Couldn't send back: ${e.message}`; update(); });
3549
+ })).then((ok) => { if (!ok) update(); });
3493
3550
  };
3551
+ main.append(sendLine(`plan:${d.key}`));
3494
3552
  panel.append(el('h2', {}, 'Your answers'), progress, el('div', { class: 'plan-next-row' }, status, next), send, err); panel.hidden = !!saved; update();
3495
3553
  return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
3496
3554
  }
@@ -3561,15 +3619,15 @@
3561
3619
  const notes = roundNotes().filter((n) => (!n.journey || live.has(`${n.journey}/${n.screen}`)) && (n.journey !== selected?.journey.id || n.screen !== selected?.screen.id));
3562
3620
  if (writes) notes.push({ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), verdict, note: text });
3563
3621
  saving = true; approve.disabled = change.disabled = true;
3564
- return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
3565
- notes, ...(pick.value && { pick: pick.value }) }) }, () => session.drafts.delete(screenKey(selected)))
3622
+ return sendWithFeedback(`design:${d.key}`, verdict === 'approve' ? approve : change, () => call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
3623
+ notes, ...(pick.value && { pick: pick.value }) }) }, () => { session.drafts.set(screenKey(selected), ''); note.value = ''; }))
3566
3624
  .finally(() => { saving = false; approve.disabled = change.disabled = state.readonly; if (session.open) { screenStatus.textContent = screenLine(); stamp(true); } });
3567
3625
  };
3568
3626
  const approve = el('button', { class: 'open-link dz-ink' }, 'Approve'), change = el('button', { class: 'dz-change' }, 'Change');
3569
3627
  approve.disabled = change.disabled = state.readonly;
3570
3628
  approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
3571
3629
  change.onclick = () => { if (!note.value.trim()) { err.textContent = 'Add a note describing what should change.'; note.focus(); return; } save('change').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; }); };
3572
- const foot = el('div', { class: 'foot' }, approve, change);
3630
+ const foot = el('div', { class: 'foot' }, approve, change, sendLine(`design:${d.key}`));
3573
3631
  const controls = el('div', { class: 'dz-ctl' }, d.options?.length ? el('label', {}, 'Pick a direction', pick) : '', el('label', {}, screens.length ? 'Screen note' : 'Design note', note));
3574
3632
  const open = el('button', { class: 'open-link dz-full', 'aria-haspopup': 'dialog', 'data-design': d.key }, 'Review full screen');
3575
3633
  // Where it stands: its state, the direction picked and when it was answered, then what was said on this round.
@@ -3829,12 +3887,12 @@
3829
3887
  const s = same(part, action, q, selection), prior = sentAs(s);
3830
3888
  if (prior) { already(part, prior); return prior; }
3831
3889
  if (outbox.has(s)) { outbox.set(s, true); return; }
3832
- outbox.set(s, false); btn?.setAttribute('aria-busy', 'true');
3833
- try { await post(); sync(); const a = sentAs(s); if (a) (outbox.get(s) ? already(part, a) : showAsk(part, a.id)); return a; }
3834
- finally { outbox.delete(s); btn?.removeAttribute('aria-busy'); }
3890
+ outbox.set(s, false);
3891
+ try { if (!await sendWithFeedback(`ask:${key}:${part}`, btn, post)) throw new Error(sending.get(`ask:${key}:${part}`).error); sync(); const a = sentAs(s); if (a) (outbox.get(s) ? already(part, a) : showAsk(part, a.id)); return a; }
3892
+ finally { outbox.delete(s); }
3835
3893
  }
3836
3894
  async function ask(part, q, btn) {
3837
- try { await sendAsk(part, 'deeper', q, undefined, btn, () => postJson('/api/deeper', { key, part, ...(q && { q }) })); } catch (e) { panels.get(part).hidden = false; panels.get(part).append(E('p', 'gd-err', `Could not ask: ${e.message}`)); }
3895
+ try { await sendAsk(part, 'deeper', q, undefined, btn, () => postJson('/api/deeper', { key, part, ...(q && { q }) })); } catch { panels.get(part).hidden = false; }
3838
3896
  }
3839
3897
  const deepBtn = (part) => {
3840
3898
  const b = el('button', { class: 'gd-lnk deep', type: 'button', 'aria-expanded': 'false', 'aria-controls': `gd-a-${part.replace(/\W/g, '-')}` }, gic('deeper'), 'Go deeper');
@@ -3856,19 +3914,14 @@
3856
3914
  };
3857
3915
  async function engage(part, action, q, id = crypto.randomUUID(), selection, btn) {
3858
3916
  try { return await sendAsk(part, action, q, selection, btn, () => postJson('/api/engagement', { version: 1, id, key, ...d.engagement, part, action, ...(q !== undefined && { q }), ...(selection && { selection }) })); }
3859
- catch (e) {
3860
- const retry = el('button', { type: 'button', class: 'gd-lnk' }, 'Retry');
3861
- const error = E('p', 'gd-err', `Could not ask: ${e.message} `, retry);
3862
- retry.onclick = () => { error.remove(); engage(part, action, q, id, selection, retry); };
3863
- panels.get(part).hidden = false; panels.get(part).append(error);
3864
- }
3917
+ catch { panels.get(part).hidden = false; }
3865
3918
  }
3866
3919
  // The presets and the composer for one part. With a selection they ask about those words, through this same engage().
3867
3920
  const controls = (part, selection) => {
3868
3921
  const form = el('form', { class: 'gd-fu' }), input = el('input', { 'aria-label': selection ? 'Ask about the selected text' : 'Ask about this part', placeholder: selection ? 'Ask about these words' : 'Ask about this' }), send = el('button', { type: 'submit' }, 'Send');
3869
3922
  let action = 'custom';
3870
3923
  form.append(input, send); form.hidden = !!selection;
3871
- form.onsubmit = (e) => { e.preventDefault(); if (input.value.trim()) { const sent = engage(part, action, input.value, undefined, selection, send); input.value = ''; action = 'custom'; input.placeholder = 'Ask a follow-up'; if (selection) asked(part, sent); } };
3924
+ form.onsubmit = async (e) => { e.preventDefault(); if (input.value.trim()) { const sent = engage(part, action, input.value, undefined, selection, send); if (selection) asked(part, sent); if (await sent) { input.value = ''; action = 'custom'; input.placeholder = 'Ask a follow-up'; } } };
3872
3925
  const buttons = Object.entries(actionLabels).filter(([a]) => a !== 'custom').map(([a, label]) => {
3873
3926
  const b = el('button', { class: `gd-lnk ${a === 'deeper' ? 'deep' : a}`, type: 'button' }, label);
3874
3927
  b.onclick = () => { if (a === 'question') { action = a; form.hidden = false; input.focus(); } else { const sent = engage(part, a, undefined, undefined, selection, b); if (selection) asked(part, sent); } };
@@ -3906,11 +3959,12 @@
3906
3959
  const close = () => { box.hidden = true; ta.value = ''; lean = undefined; comment.setAttribute('aria-expanded', 'false'); paint(); };
3907
3960
  comment.onclick = () => (box.hidden || mode !== 'comment' ? open('comment') : close());
3908
3961
  cancel.onclick = close; ta.oninput = paint; ta.onkeydown = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
3909
- const post = async (body) => { try { await postJson('/api/annotations', { key, part, ...body }); sync(); return true; } catch (e) { err.textContent = 'Could not send: ' + e.message; box.hidden = false; return false; } };
3962
+ const post = (body) => sendWithFeedback(`part:${key}:${part}`, send, async () => { await postJson('/api/annotations', { key, part, ...body }); sync(); });
3910
3963
  box.onsubmit = async (e) => {
3911
- e.preventDefault(); const text = ta.value.trim(); if (!text) return;
3912
- send.disabled = true;
3913
- if (mode === 'question') { await engage(part, 'question', text, undefined, undefined, send); close(); } else if (await post({ text, ...(lean && { verdict: lean }) })) close(); else send.disabled = false;
3964
+ e.preventDefault(); const text = ta.value.trim(); if (!text || sending.get(`part:${key}:${part}`)?.busy) return;
3965
+ send.disabled = ta.disabled = cancel.disabled = comment.disabled = true;
3966
+ if (mode === 'question') { if (await engage(part, 'question', text, undefined, undefined, send)) close(); } else if (await post({ text, ...(lean && { verdict: lean }) })) close();
3967
+ ta.disabled = cancel.disabled = comment.disabled = false; paint();
3914
3968
  };
3915
3969
  const vote = (name, label, icon) => el('button', { type: 'button', class: 'gd-v ' + name + (inItem ? ' icon-only' : ''), 'aria-pressed': 'false', title: label }, gic(icon), E('span', '', label));
3916
3970
  const agree = vote('agree', 'Agree', 'up'), disagree = vote('disagree', 'Disagree', 'down');
@@ -3931,7 +3985,7 @@
3931
3985
  ask = [E('div', 'gd-menuwrap', more, menu)];
3932
3986
  } else if (live) ask = [deepBtn(part)];
3933
3987
  queueMicrotask(() => paintTalk(part));
3934
- return [...(dp ? [dp] : []), thread, box, E('div', 'gd-acts gd-foot' + (d.engagement ? '' : ' legacy'), agree, disagree, comment, E('span', 'sp'), ...(db ? [db] : []), ...ask), panel];
3988
+ return [...(dp ? [dp] : []), thread, box, sendLine(`part:${key}:${part}`), sendLine(`ask:${key}:${part}`), E('div', 'gd-acts gd-foot' + (d.engagement ? '' : ' legacy'), agree, disagree, comment, E('span', 'sp'), ...(db ? [db] : []), ...ask), panel];
3935
3989
  };
3936
3990
 
3937
3991
  // ---- selected words: native selection of one authored field, offered the same presets right under that field
@@ -4056,8 +4110,7 @@
4056
4110
  noteBox.oninput = () => { S.note = noteBox.value; if (!plan) syncU(); };
4057
4111
  // Said as a comment on the understood part, so the author's wait --filer takes it and the part shows Sent to the agent, then Agent read it.
4058
4112
  async function sayU(body, btn) {
4059
- tell.parentElement.querySelector(':scope > .gd-err')?.remove(); btn.disabled = true;
4060
- try { await postJson('/api/annotations', { key, part: 'understood', ...body }); sync(); return true; } catch (e) { tell.after(E('p', 'gd-err', `Could not send: ${e.message}`)); btn.disabled = false; return false; }
4113
+ return sendWithFeedback(`tell:${key}`, btn, async () => { await postJson('/api/annotations', { key, part: 'understood', ...body }); sync(); });
4061
4114
  }
4062
4115
  tell.onclick = async () => { if (await sayU({ verdict: 'disagree', text: S.note.trim() }, tell)) { S.told = true; S.note = noteBox.value = ''; syncU(); } };
4063
4116
  function syncU() {
@@ -4068,7 +4121,7 @@
4068
4121
  }
4069
4122
  const understood = card('understood', 'understood', head('Check that I have this right', 'This is your ask in my words. If it is off, say what is off. That is the only correction that matters.'),
4070
4123
  el('dl', { class: 'gd-uq' }, ...d.understood.map((u, n) => E('div', '', fld('understood', `understood.${n}.label`, el('dt', {}, u.label)), fld('understood', `understood.${n}.text`, mdSpan('dd', '', u.text))))),
4071
- E('div', 'gd-seg', ...choices), noteBox, tell, ...(live ? acts('understood') : []), rc('understood'));
4124
+ E('div', 'gd-seg', ...choices), noteBox, tell, sendLine(`tell:${key}`), ...(live ? acts('understood') : []), rc('understood'));
4072
4125
 
4073
4126
  // A comparison: every item's detail is the same "Axis: value" lines in the same order, so the section is one table.
4074
4127
  // The detail stays plain lines, so a board that predates the table still reads it as text.
@@ -4125,12 +4178,12 @@
4125
4178
  // ---- next step and Go ahead
4126
4179
  const sendSlot = E('div', ''), err = E('p', 'gd-err');
4127
4180
  err.setAttribute('role', 'alert');
4128
- const nextCard = card('next', 'next', head(d.next.heading, undefined, undefined, 'next', 'heading'), E('ul', 'gd-checks', ...d.next.lines.map((l, n) => E('li', '', E('span', 'tk', gic('check')), fld('next', `lines.${n}`, mdSpan('span', '', l))))), ...(plan ? [sendSlot, err] : []), ...(d.engagement || live ? acts('next') : []), rc('next'));
4181
+ const nextCard = card('next', 'next', head(d.next.heading, undefined, undefined, 'next', 'heading'), E('ul', 'gd-checks', ...d.next.lines.map((l, n) => E('li', '', E('span', 'tk', gic('check')), fld('next', `lines.${n}`, mdSpan('span', '', l))))), ...(plan ? [sendSlot, sendLine(`plan:${key}`), err] : []), ...(d.engagement || live ? acts('next') : []), rc('next'));
4129
4182
  async function go(b) {
4130
4183
  if (S.understood === 'no' && !S.note.trim()) { noteBox.focus(); noteBox.scrollIntoView({ block: 'center' }); err.textContent = 'Say what is off first, or choose That is right.'; return; }
4131
4184
  err.textContent = ''; b.disabled = true;
4132
4185
  const notes = S.understood ? [{ section: 'understood', verdict: S.understood === 'yes' ? 'approve' : 'change', ...(S.note.trim() && { note: S.note.trim() }) }] : [];
4133
- try { await postJson('/api/answers', { key, note: '', notes, decisions: S.picks }); sync(); } catch (e) { err.textContent = `Couldn't send: ${e.message}`; b.disabled = false; }
4186
+ if (!await sendWithFeedback(`plan:${key}`, b, async () => { await postJson('/api/answers', { key, note: '', notes, decisions: S.picks }); S.note = noteBox.value = ''; sync(); })) b.disabled = false;
4134
4187
  }
4135
4188
  const goBtn = () => { const b = el('button', { class: 'gd-btn block', type: 'button' }, gic('send'), changes() ? `Send ${plural(changes(), 'change')}` : 'Go ahead'); b.onclick = () => go(b); return b; };
4136
4189
 
@@ -4187,7 +4240,7 @@
4187
4240
  stageBlocks.push(c);
4188
4241
  }
4189
4242
  const who = plan ? 'Planning agent' : d.asked_by ?? 'Reporting agent';
4190
- const top = E('div', 'gd-top', E('p', 'geye', kindChip(d, true), E('span', '', [plan ? d.project : d.group, [who, d.filed && when(d.filed)].filter(Boolean).join(', ')].filter(Boolean).join(' · '))), fld('this', 'title', E('h1', '', d.title)), purposeLine(d), fld('this', 'about', mdSpan('p', 'sub', d.about)), ticketRow(d), thread(d),
4243
+ const top = E('div', 'gd-top', E('p', 'geye', kindChip(d, true), E('span', '', [plan ? d.project : d.group, [who, d.filed && when(d.filed)].filter(Boolean).join(', ')].filter(Boolean).join(' · '))), fld('this', 'title', E('h1', '', d.title)), purposeLine(d), fld('this', 'about', mdSpan('p', 'sub', d.about)), ticketRow(d), el('div', { class: 'gd-activity' }, thread(d)),
4191
4244
  E('div', 'howto', gic('info'), E('span', '', `A few short parts, in order. Tap Details for the numbers and Go deeper to ask me more.${plan ? ' Nothing is required: Go ahead accepts my picks.' : ''}`)));
4192
4245
  const rail = el('aside', { class: 'gd-rail' }, E('h4', '', plan ? 'This plan' : 'This report'), nav, ...(plan ? [sumHost] : []));
4193
4246
  const progress = E('div', 'gd-progress', now, segs);
@@ -4220,6 +4273,7 @@
4220
4273
  let timer;
4221
4274
  function sync() {
4222
4275
  if (!root.isConnected && timer) { clearInterval(timer); timer = undefined; return; }
4276
+ root.querySelector('.gd-activity')?.replaceChildren(thread(d));
4223
4277
  syncU(); paintOpts(); paintSummary(); paintRc(); paintRail(); votes.forEach((_, part) => paintTalk(part));
4224
4278
  panels.forEach((_, part) => { paintAns(part); if (asksOf(part).length && (d.engagement || (deeps.get(part).getAttribute('aria-expanded') === 'false' && !deeps.get(part).dataset.shut))) { deeps.get(part)?.setAttribute('aria-expanded', 'true'); panels.get(part).hidden = false; } });
4225
4279
  const need = live && (unfinished() || (state.comments ?? []).some((c) => c.key === key && c.text && !c.reply) || (plan && sent() && !state.seen_at?.[key]));
@@ -4407,10 +4461,11 @@
4407
4461
  }
4408
4462
  if (sel) h1.textContent = sel.title;
4409
4463
  const changes = items.filter((d) => designStatus(d) === 'Changes requested').length;
4464
+ if (phone() && !sel) h1.textContent = 'Decide';
4410
4465
  if (phone()) top.className = 'ui-top dz-top', top.replaceChildren(h1, ...(sel ? [sel.all, sel.bar] : [more, theme]));
4411
4466
  else top.className = 'ui-dhead dz-head', top.replaceChildren(el('div', { class: 'tx' }, el('p', { class: 'crumb' }, 'Sign-off', el('span', { 'aria-hidden': 'true' }, '›'), 'Designs'), h1,
4412
4467
  el('p', {}, items.length ? `${plural(items.length, 'design')} on your board${changes ? ` · changes requested on ${changes > 1 && changes === items.length ? `all ${changes}` : changes}` : ''}` : EMPTY.designs), ro), el('div', { class: 'acts' }, ...(sel ? [sel.all, sel.bar] : [more, theme])));
4413
- return [phone() ? ro : ''];
4468
+ return phone() ? [ro, decideSeg('designs')] : [''];
4414
4469
  }
4415
4470
  /** Designs, newest first: on a phone To review and the rows, then Archived; on a desk the cards and the open design (the one in the address, else the newest). */
4416
4471
  function designGroups(items, top, key, gone = []) {
@@ -4430,9 +4485,13 @@
4430
4485
  return [...head, el('section', { class: 'ui-card ui-hero dz-hero', 'aria-label': 'To review' }, el('div', { class: 'hl' }, el('span', { class: 'lab' }, 'To review')),
4431
4486
  el('div', { class: 'hv' }, el('span', { class: 'big' }, String(toReview), el('small', {}, ` of ${items.length}`)), approved ? el('span', { class: 'vs' }, `${approved} approved`) : '')),
4432
4487
  el('h2', { class: 'ui-sec' }, 'Newest', el('span', { class: 'n' }, String(items.length))),
4433
- el('ul', { class: 'ui-card ui-list dz-list' }, ...items.map((d) => { const t = ticketOf(d);
4434
- return el('li', { id: `item-${d.key}` }, el('a', { class: 'ui-row', href: `#designs/${d.key}`, 'aria-label': `Open design, ${d.title}${t?.progress > 0 ? `, ticket ${t.title}, ${t.progress}% done` : ''}` },
4435
- ...uiRow({ tile: cardTile(d), title: d.title, priority: d.priority, badge: trailBadge(d.key), meta: [designStatus(d), d.filed && day(d.filed)].filter(Boolean).join(' · '), progress: progressOf(t?.progress) }), selMark(d.key))); })), archived(gone)];
4488
+ el('ul', { class: 'ui-card ui-list dz-list' }, ...items.map(designRow)), archived(gone)];
4489
+ }
4490
+ /** A design as a row: its tile (a thumbnail of its first screen), the title, its state and day (or its ticket's bar); it opens the design's review. */
4491
+ function designRow(d) {
4492
+ const t = ticketOf(d);
4493
+ return el('li', { id: `item-${d.key}` }, el('a', { class: 'ui-row', href: `#designs/${d.key}`, 'aria-label': `Open design, ${d.title}${t?.progress > 0 ? `, ticket ${t.title}, ${t.progress}% done` : ''}` },
4494
+ ...uiRow({ tile: cardTile(d), title: d.title, priority: d.priority, badge: trailBadge(d.key), meta: [designStatus(d), d.filed && day(d.filed)].filter(Boolean).join(' · '), progress: progressOf(t?.progress) }), selMark(d.key)));
4436
4495
  }
4437
4496
 
4438
4497
  /** Archived: what was discarded or dismissed from a screen, and the evidence and reports archived a day after they were filed. One quiet line at the list's end opens them
@@ -4543,6 +4602,28 @@
4543
4602
  return el('li', { class: `dl-row g-done${sel ? ' sel' : ''}`, 'data-key': d.key, style: `--n:${i}` }, el('a', { class: 'dl-face ui-row', href: `#decide/${d.key}`, draggable: 'false', 'data-row': '', ...(sel && { 'aria-current': 'true' }) },
4544
4603
  ...uiRow({ tile: uiIcon({ draw: 'done' }), title: title(d), badge: trailBadge(d.key), meta: `${s.replaces ? 'Changed to ' : ''}${saidOf(d, s)} · ${filedAt(s.at)}` })));
4545
4604
  }
4605
+ /** On a phone Decide holds two lists, as Tickets holds Your asks and System: Asks (what waits on you) and Designs (the reviews still open), each with its count.
4606
+ * A desk keeps Designs in the sidebar. */
4607
+ let decideCounts = { asks: 0, designs: 0 };
4608
+ const dzOpen = (d) => ['In review', 'Draft'].includes(designStatus(d));
4609
+ function decideSeg(on) {
4610
+ const b = (v, href, name, n) => { const x = el('button', { type: 'button', 'aria-pressed': String(on === v) }, name, el('small', {}, String(n))); x.onclick = () => { if (on !== v) buzz('tick'), location.hash = href; }; return x; };
4611
+ return el('div', { class: 'ui-seg dl-seg', role: 'group', 'aria-label': 'Decide' }, b('asks', '#waiting', 'Asks', decideCounts.asks), b('designs', '#designs', 'Designs', decideCounts.designs));
4612
+ }
4613
+ /** On a phone Decide's order (Newest or By priority) is a round button in its head, marked while By priority is on, opening a sheet of the two. */
4614
+ const dlSortSheet = sheetOf('dl-sort');
4615
+ function decideSortButton() {
4616
+ const b = el('button', { class: `ui-rb dl-rb${dsort === 'priority' ? ' on' : ''}`, 'aria-label': `Order: ${dsort === 'priority' ? 'By priority' : 'Newest'}`, 'aria-haspopup': 'dialog' });
4617
+ b.innerHTML = '<svg class="glyph" viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6h13M6 10h8M8.5 14h3"/></svg>';
4618
+ b.onclick = () => {
4619
+ const pick = (v, name) => { const x = el('button', { class: 'ui-pr dl-pick', ...(dsort === v && { 'aria-current': 'true' }) }, el('span', { class: 'k' }, name), el('span', { class: 'v' }, dsort === v ? glyph('check') : ''));
4620
+ x.onclick = () => { dsort = v; try { localStorage.setItem('sign-off-decide-sort', v); } catch {} dlSortSheet.close(); render(); }; return x; };
4621
+ dlSortSheet.replaceChildren(el('div', {}, sheetHead('dl-sort', 'Order'), el('div', { class: 'ui-props' }, pick('new', 'Newest'), pick('priority', 'By priority'))));
4622
+ dlSortSheet.showModal();
4623
+ dlSortSheet.querySelector('h2').focus();
4624
+ };
4625
+ return b;
4626
+ }
4546
4627
  /** Decide's list: Newest or By priority, the hero (how many wait on you, how many you answered today, how many are for later), the asks in one card, then Later and Answered. */
4547
4628
  function decideList(items, hidden, done = [], today = 0) {
4548
4629
  const rows = { now: [], later: [] };
@@ -4566,7 +4647,7 @@
4566
4647
  // On a phone the asks' head sits over their card; on a desk, where the hero is hidden, the card carries its head and count inside it, as the prototype's list card.
4567
4648
  const desk = deskSel !== null, asks = rows.now.length ? card('Asks', rows.now, desk)
4568
4649
  : el('div', { class: `${desk ? '' : 'ui-card '}dl-clear` }, el('b', {}, 'All clear'), el('span', {}, `Nothing is waiting on you${state.name ? `, ${state.name}` : ''}.`));
4569
- return el('div', { class: `waiting-page decide-list${fresh ? ' rise' : ''}` }, seg, hero, ...(desk ? [el('section', { class: 'ui-card dl-asks' }, sec('Asks', n), asks)] : [sec('Asks'), asks]),
4650
+ return el('div', { class: `waiting-page decide-list${fresh ? ' rise' : ''}` }, phone() ? decideSeg('asks') : seg, hero, ...(desk ? [el('section', { class: 'ui-card dl-asks' }, sec('Asks', n), asks)] : [sec('Asks'), asks]),
4570
4651
  ...(rows.later.length ? [sec('Later', later), card('Later', rows.later)] : []),
4571
4652
  // Every answer given, newest first.
4572
4653
  ...(done.length ? [sec('Answered', done.length), card('Answered', done.map(doneRow))] : []), items.length ? decideMoreLine(items) : '', archived(hidden));
@@ -4868,8 +4949,8 @@
4868
4949
  // pinned under a hairline at the foot, as cards side by side over one line of the note, Ask me later and Answer.
4869
4950
  const page = desk ? el('section', { class: `${kind} ui-card ui-pane ask2 s8 split-detail`, 'aria-labelledby': 'ask-title' },
4870
4951
  el('div', { class: 'pbody' }, askHead(d, sink), el('div', { class: 'pmain' }, lead, ...about, ...said, quietLine), el('div', { class: 'pside' }, ...askReport(d), thread(d))),
4871
- el('div', { class: 'ppin' }, ...picks, ...words, err, bar(noteField)))
4872
- : el('section', { class: kind }, lead, ...about, ...said, ...picks, ...words, noteField, quietLine, thread(d), err, bar());
4952
+ el('div', { class: 'ppin' }, ...picks, ...words, sendLine(`card:${d.key}`), err, bar(noteField)))
4953
+ : el('section', { class: kind }, lead, ...about, ...said, ...picks, ...words, noteField, quietLine, thread(d), sendLine(`card:${d.key}`), err, bar());
4873
4954
  return page;
4874
4955
  }
4875
4956
  const kc = (k) => el('kbd', { class: 'kc' }, k);
@@ -5027,6 +5108,7 @@
5027
5108
  const decideDone = by.answered.filter(redo), walk = [...decideDone].reverse().concat(decideQueue);
5028
5109
  // On a desk, Decide's list and its ask share the screen, Evidence's list and its update, and Reports' list and its report.
5029
5110
  // On a phone an ask opens as a sheet over Decide's list, so the page under it keeps the list's head.
5111
+ decideCounts = { asks: by.waiting.filter((d) => sideOf(d) === 'now').length, designs: by.designs.filter(dzOpen).length };
5030
5112
  const deskSplit = !phone() && ((at === 'waiting' || at === 'decide' || at === 'reports') && !state.readonly || at === 'evidence'), head = at === 'decide' ? 'waiting' : at;
5031
5113
  deskSel = null;
5032
5114
  // A report in the address is shown (and read) beside the list; with none, the newest is shown, not yet marked read.
@@ -5063,7 +5145,7 @@
5063
5145
  el('a', { class: 'sfind', href: '#find' }, document.getElementById('dock-find').firstChild.cloneNode(true), 'Search'), ...SIDE.map(link),
5064
5146
  el('div', { class: 'me' }, el('i', { 'aria-hidden': 'true' }, [...who][0].toUpperCase()), el('span', { dir: 'auto' }, who), themeButton()),
5065
5147
  state.version ? el('p', { class: 'ver' }, `Version ${state.version}`) : '');
5066
- paintTabs(active === 'designs' ? '' : active, count, badge);
5148
+ paintTabs(active === 'designs' ? 'waiting' : active, count, badge);
5067
5149
  const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
5068
5150
  const pageTitle = head === 'decide' || head === 'waiting' ? 'Decide' : head === 'reports' ? 'Reports' : AREAS[head];
5069
5151
  document.body.classList.toggle('today', !at);
@@ -5072,7 +5154,7 @@
5072
5154
  const answeredToday = by.answered.filter((d) => isToday(state.answers[d.key].at)).length;
5073
5155
  // Decide's head, as ui.css draws it: the title and the light-or-dark button at its right edge; More sits under the list.
5074
5156
  const sel = selecting && canDismiss() && bulk.length && ['waiting', 'designs'].includes(head) && selectHead(bulk);
5075
- const top = head === 'waiting' && !state.readonly ? el('header', { class: 'ui-top' }, el('h1', { tabindex: '-1', 'data-crumb': pageTitle }, sel ? sel.title : pageTitle), ro ? el('p', { class: 'meta' }, ro) : '', ...(sel ? [sel.all, sel.bar] : [themeButton()]))
5157
+ const top = head === 'waiting' && !state.readonly ? el('header', { class: 'ui-top' }, el('h1', { tabindex: '-1', 'data-crumb': pageTitle }, sel ? sel.title : pageTitle), ro ? el('p', { class: 'meta' }, ro) : '', ...(sel ? [sel.all, sel.bar] : [phone() ? decideSortButton() : '', themeButton()]))
5076
5158
  : el('header', { class: head === 'reports' && !state.readonly ? 'top ui-top rp-top' : head ? 'top' : 'ui-top today-top' }, head && !TABS.some(([a]) => a === head)
5077
5159
  ? el('a', { class: 'back', href: '#' }, '‹ Today') : '',
5078
5160
  ...(head ? [el('h1', { tabindex: '-1' }, pageTitle)] : todayHead()),
@@ -5220,12 +5302,8 @@
5220
5302
  if (!res.ok) throw new Error(body.error);
5221
5303
  banner.hidden = true;
5222
5304
  if (JSON.stringify(body) === JSON.stringify(state)) return;
5223
- // A redraw keeps the reply box or search being typed in focused, with its caret where it was.
5224
- const typing = (document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.id === 'find') && document.activeElement, at = typing && typing.selectionStart;
5225
5305
  state = body;
5226
- render();
5227
- const box = typing && document.getElementById(typing.id);
5228
- if (box) box.focus(), box.setSelectionRange(at, at);
5306
+ renderKeepingFocus();
5229
5307
  } catch (e) { failed(e); }
5230
5308
  }
5231
5309
  if (FROZEN) (state = FROZEN), render();
@@ -1,14 +1,18 @@
1
1
  {
2
+ "id": "/",
2
3
  "name": "sign-off",
3
4
  "short_name": "sign-off",
4
5
  "description": "One calm page for every question the programs working for you need answered.",
5
- "start_url": "./",
6
+ "start_url": "/",
7
+ "scope": "/",
6
8
  "display": "standalone",
7
- "background_color": "#F5F5F7",
8
- "theme_color": "#0071E3",
9
+ "orientation": "any",
10
+ "categories": ["productivity"],
11
+ "background_color": "#E8E4E0",
12
+ "theme_color": "#E8E4E0",
9
13
  "icons": [
10
- { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
11
- { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" },
14
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
15
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
12
16
  { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
13
17
  ]
14
18
  }
package/web/pwa.js ADDED
@@ -0,0 +1,55 @@
1
+ // The board as an app: the service worker (sw.js), the line shown while the board is read from the last update, and the install row in Today's More.
2
+ // Loaded by src/cli.ts into the served page only, so an exported copy and the demo carry none of it.
3
+ (() => {
4
+ if (!('serviceWorker' in navigator)) return;
5
+ navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {});
6
+
7
+ // The worker answers the state with a marked copy while the board is out of reach; any real answer clears the line.
8
+ const line = document.createElement('p');
9
+ line.id = 'offline';
10
+ line.setAttribute('role', 'status');
11
+ line.hidden = true;
12
+ line.textContent = 'Offline, showing the last update';
13
+ const style = document.createElement('style');
14
+ style.textContent = '#offline { position: sticky; top: 0; z-index: 30; margin: 0; padding: calc(0.5rem + env(safe-area-inset-top)) 1rem 0.5rem; background: var(--tk-ink); color: var(--tk-canvas); font-size: var(--tk-fs-secondary); text-align: center; } #offline[hidden] { display: none; } #install-app { width: 100%; padding: 0; border: 0; background: none; font-family: inherit; font-size: var(--tk-fs-row); line-height: var(--tk-lh-row); text-align: left; cursor: pointer; }';
15
+ document.head.append(style);
16
+ document.body.prepend(line);
17
+ const real = window.fetch;
18
+ window.fetch = async (...args) => {
19
+ const res = await real.apply(window, args);
20
+ if (new URL(typeof args[0] === 'string' ? args[0] : args[0].url, location.href).pathname === '/api/decisions') line.hidden = res.headers.get('x-sign-off-offline') !== '1';
21
+ return res;
22
+ };
23
+ addEventListener('online', () => real('/api/decisions').then((res) => { if (res.headers.get('x-sign-off-offline') !== '1') line.hidden = true; }, () => {}));
24
+
25
+ // Install: Chrome and Edge offer their own prompt once the page is installable; Safari on an iPhone or iPad has no prompt, only the Share menu.
26
+ let offered;
27
+ addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); offered = e; });
28
+ addEventListener('appinstalled', () => { offered = undefined; });
29
+ const installed = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
30
+ const safari = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
31
+ const row = (tag, words, hint) => {
32
+ const r = document.createElement(tag), k = document.createElement('span'), v = document.createElement('span'), s = document.createElement('span');
33
+ r.className = 'ui-pr';
34
+ k.className = 'k';
35
+ v.className = 'v';
36
+ k.textContent = words;
37
+ s.textContent = hint;
38
+ v.append(s);
39
+ r.append(k, v);
40
+ return r;
41
+ };
42
+ /** The row Today's More adds under its links, or nothing once the app is installed or this browser cannot install it. */
43
+ window.signOffInstall = () => {
44
+ if (installed()) return '';
45
+ if (offered) {
46
+ const b = row('button', 'Install app', 'On this device');
47
+ b.type = 'button';
48
+ b.id = 'install-app';
49
+ b.onclick = async () => { const e = offered; offered = undefined; b.closest('dialog')?.close(); e.prompt(); await e.userChoice; };
50
+ return wrap(b);
51
+ }
52
+ return safari ? wrap(row('div', 'Add to Home Screen', 'In Share')) : '';
53
+ };
54
+ const wrap = (r) => { const w = document.createElement('div'); w.className = 'ui-props'; w.append(r); return w; };
55
+ })();
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/web/sw.js ADDED
@@ -0,0 +1,57 @@
1
+ // The board's service worker: the page and its files load with no network, and the last state it read is read back, marked, until the board answers again.
2
+ // Writes and answers are never cached or queued: a send with no network fails on the page as it does now. BUILD is put above this file by src/cli.ts.
3
+ const SHELL = `shell-${BUILD}`, DATA = 'state', STATE = '/api/decisions', SAVED = 'x-sign-off-saved', OFFLINE = 'x-sign-off-offline';
4
+ const FILES = /^\/(?:tokens\.css|ui\.css|pwa\.js|inter\.woff2|icon\.svg|apple-touch-icon\.png|icon-(?:192|512|maskable-512)\.png)$/;
5
+
6
+ self.addEventListener('install', (e) => e.waitUntil((async () => {
7
+ const shell = await caches.open(SHELL), page = await fetch('/', { cache: 'reload' });
8
+ if (!page.ok) throw new Error(`The board answered ${page.status}`);
9
+ // The page names its files, with their content hashes: keep exactly those.
10
+ const names = [...(await page.clone().text()).matchAll(/(?:href|src)="([^"#]+)"/g)].map((m) => m[1]).filter((n) => FILES.test(`/${n.split('?')[0]}`));
11
+ await shell.put('/', page);
12
+ await shell.addAll(names.map((n) => new Request(n, { cache: 'reload' })));
13
+ await self.skipWaiting();
14
+ })()));
15
+
16
+ self.addEventListener('activate', (e) => e.waitUntil((async () => {
17
+ for (const name of await caches.keys()) if (name.startsWith('shell-') && name !== SHELL) await caches.delete(name);
18
+ await self.clients.claim();
19
+ })()));
20
+
21
+ /** The state, from the board when it answers; the last good copy, marked, when it cannot be reached. A refused token empties the copy: it is not for this person any more. */
22
+ async function state(request) {
23
+ const store = await caches.open(DATA);
24
+ try {
25
+ const res = await fetch(request);
26
+ if (res.status === 401 || res.status === 403) await store.delete(STATE);
27
+ else if (res.ok) await store.put(STATE, new Response(await res.clone().blob(), { headers: { 'content-type': 'application/json', [SAVED]: String(Date.now()) } }));
28
+ return res;
29
+ } catch {
30
+ const kept = await store.match(STATE);
31
+ if (kept) return new Response(kept.body, { headers: { 'content-type': 'application/json', [SAVED]: kept.headers.get(SAVED), [OFFLINE]: '1' } });
32
+ return Response.json({ error: 'You are offline and this device has no saved copy of the board yet.' }, { status: 503 });
33
+ }
34
+ }
35
+
36
+ /** The page: the board's own when it answers, the kept one otherwise. Only the bare address is kept, so a link's access token is never in a cache key. */
37
+ async function page(request) {
38
+ const shell = await caches.open(SHELL);
39
+ try {
40
+ const res = await fetch(request);
41
+ if (res.ok) await shell.put('/', res.clone());
42
+ return res;
43
+ } catch {
44
+ return (await shell.match('/')) ?? new Response('You are offline and this device has not opened the board yet.', { status: 503, headers: { 'content-type': 'text/plain; charset=utf-8' } });
45
+ }
46
+ }
47
+
48
+ self.addEventListener('fetch', (e) => {
49
+ const { request } = e, url = new URL(request.url);
50
+ if (request.method !== 'GET' || url.origin !== self.location.origin) return;
51
+ if (request.mode === 'navigate' && url.pathname === '/' && !url.search) return e.respondWith(page(request));
52
+ if (url.pathname === STATE && !url.search) return e.respondWith(state(request));
53
+ if (FILES.test(url.pathname)) e.respondWith(caches.match(request, { cacheName: SHELL }).then((kept) => kept ?? fetch(request).then((res) => {
54
+ if (res.ok) caches.open(SHELL).then((c) => c.put(request, res.clone()));
55
+ return res;
56
+ })));
57
+ });
package/web/ui.css CHANGED
@@ -24,6 +24,8 @@
24
24
  .dock-find { position: fixed; z-index: 2; left: calc(var(--dock-at) + var(--dock-w) + 0.75rem); bottom: calc(1.5rem + env(safe-area-inset-bottom)); display: grid; place-items: center;
25
25
  width: 3.5rem; height: 3.5rem; border-radius: 1.75rem; background: var(--tk-go); color: var(--tk-on-go); box-shadow: var(--tk-elev-go); -webkit-tap-highlight-color: transparent; }
26
26
  .dock-find:active { transform: scale(0.94); }
27
+ /* Under the dock the page fades to its ground, so a row or a line never reads half hidden behind it; scrolled to its end, a list clears it by the body's room. */
28
+ body::after { content: ''; position: fixed; z-index: 1; left: 0; right: 0; bottom: 0; height: calc(6.5rem + env(safe-area-inset-bottom)); background: linear-gradient(to top, var(--bg) calc(5rem + env(safe-area-inset-bottom)), transparent); pointer-events: none; }
27
29
  .dock-find svg, .sfind svg { width: 1.5rem; height: 1.5rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
28
30
 
29
31
  /* The page's title row: 8px from the top (24px on a desk), a 24px medium title in a 44px row, round 40px buttons at its right edge. */
@@ -105,7 +107,7 @@
105
107
  The mark, Search's glass and every area's icon are 20px on one left edge, and the name, Search and every label start on the next one;
106
108
  the mark sits on the name's line, centred on it; at the foot, whose board it is with the light-or-dark button. */
107
109
  @media (min-width: 64rem) {
108
- .tabs, .dock-find { display: none; }
110
+ .tabs, .dock-find, body::after { display: none; }
109
111
  .ui-top { margin-top: 1.5rem; }
110
112
  .side { display: flex; flex-direction: column; gap: 0.25rem; position: sticky; top: 0; height: 100vh; padding: 1.5rem 1rem; background: none; }
111
113
  .brand { display: flex; align-items: center; gap: 0.75rem; min-height: 2.5rem; margin: 0; padding: 0 0.75rem; color: var(--tk-ink); font-size: var(--tk-fs-body); line-height: 1.25rem; font-weight: 600; letter-spacing: -0.01em; }
@@ -231,3 +233,11 @@
231
233
  .ui-pane.ask2 > .pbody { flex: 1 1 auto; min-height: 0; padding: 1.25rem 1.5rem 1rem; overflow: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-width: none; }
232
234
  .ui-pane.ask2 > .ppin { flex: none; max-height: 65%; padding: 1rem 1.5rem 1.25rem; overflow: auto; overscroll-behavior: contain; scrollbar-width: none; border-top: 1px solid var(--tk-line); }
233
235
  }
236
+
237
+ /* The pending step and receipt beside a composer share the feedback trail's new-look tokens. */
238
+ .ui-send { scroll-margin-block: 4.5rem; margin: 0.5rem 0; color: var(--tk-text-secondary); font-size: var(--tk-fs-meta); line-height: var(--tk-lh-meta); }
239
+ .ui-send[hidden] { display: none; }
240
+ .ui-send button { color: var(--tk-ink); background: none; border: 0; min-height: 2.75rem; text-decoration: underline; }
241
+ [aria-busy="true"] { opacity: 0.65; }
242
+ .foot:has(> .ui-send) { flex-wrap: wrap; }
243
+ :is(.design-review, .dz-info, .design-viewer-panel) .foot > .ui-send { flex: 0 0 100%; }