sign-off-axi 0.4.3 → 0.4.4

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
@@ -352,7 +352,9 @@ event, by itself: the agent raises with `--filer my-agent` and blocks on
352
352
  answers, each once, and the card shows **Seen by agent**. An agent that
353
353
  cannot block can be woken instead: an `on_answer:` line in the
354
354
  `config.yaml` beside the board names a command the board runs for every
355
- answer. The full reference, with the worked example, is in
355
+ answer. A `notify:` block in that config can send an HTTP POST when a card
356
+ enters Waiting on you, with its bearer token read from a named environment
357
+ variable. The full reference, with the worked example, is in
356
358
  [docs/commands.md](docs/commands.md).
357
359
 
358
360
  Every command takes `--help` and prints compact output with next-step hints.
package/dist/cli.js CHANGED
@@ -779,27 +779,27 @@ function post(args, cmd = 'post') {
779
779
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
780
780
  media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
781
781
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
782
- const { file, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
782
+ const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
783
783
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
784
784
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
785
785
  if (values.kind !== undefined) {
786
786
  const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
787
787
  if (odd)
788
788
  throw fail(`--kind cannot be combined with --${odd}`, 'VALIDATION_ERROR', 'Put every field in the message file: sign-off-axi post --kind ask --from ask.json');
789
- return postKind(file, on, values);
789
+ return postKind(file, named, on, values);
790
790
  }
791
791
  if (values.from !== undefined && given.length)
792
792
  throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
793
793
  if (values.replace)
794
- return replace(file, on, values.from, filerOf(values));
794
+ return replace(file, named, on, values.from, filerOf(values));
795
795
  const filer = filerOf(values, true);
796
796
  const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
797
797
  if (told && type !== 'decision')
798
798
  throw fail(`--${told} needs --type decision`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
799
799
  if (type === 'report')
800
- return postReport(file, on, values);
800
+ return postReport(file, named, on, values);
801
801
  if (type === 'signin')
802
- return postSignin(file, on, values, filer);
802
+ return postSignin(file, named, on, values, filer);
803
803
  const odd = SIGNIN_FIELDS.find((f) => values[f] !== undefined);
804
804
  if (odd)
805
805
  throw fail(`--${odd} needs --type signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
@@ -812,7 +812,7 @@ function post(args, cmd = 'post') {
812
812
  if (!['decision', 'design', 'evidence', 'diagram', 'interactive', 'explainer'].includes(type))
813
813
  throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
814
814
  if (type === 'evidence' || type === 'explainer')
815
- return postMedia(file, on, values, type, filer);
815
+ return postMedia(file, named, on, values, type, filer);
816
816
  if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
817
817
  throw fail('--media, --link and --chapter need --type evidence or explainer', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
818
818
  if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
@@ -821,7 +821,7 @@ function post(args, cmd = 'post') {
821
821
  if (view !== (values.view !== undefined))
822
822
  throw fail(view ? `--type ${type} needs --view <path>` : '--view needs --type diagram or interactive', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
823
823
  if (type === 'decision')
824
- return storeIncoming(file, on, buildIncoming(values, {}, filer, file), filer);
824
+ return storeIncoming(file, named, on, buildIncoming(values, {}, filer, file), filer);
825
825
  const root = resolve(dirname(file));
826
826
  if (view) {
827
827
  // The same real-path check the board serves with, so a post that passes always renders.
@@ -844,7 +844,7 @@ function post(args, cmd = 'post') {
844
844
  }
845
845
  if (!size.isFile() || size.size > MOCK_BYTES)
846
846
  throw fail(`--view ${values.view} must be a file of 5 MB or less`, 'VALIDATION_ERROR', 'Shrink the picture, or post an interactive page instead of a diagram');
847
- return storeIncoming(file, on, buildIncoming(values, { type, view: values.view }, filer, file), filer);
847
+ return storeIncoming(file, named, on, buildIncoming(values, { type, view: values.view }, filer, file), filer);
848
848
  }
849
849
  const mocks = values.mock?.map((m) => {
850
850
  const at = m.indexOf('=');
@@ -868,7 +868,7 @@ function post(args, cmd = 'post') {
868
868
  }
869
869
  return { value: m.slice(0, at), path };
870
870
  });
871
- return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
871
+ return storeIncoming(file, named, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
872
872
  }
873
873
  const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
874
874
  const SIGNIN_EXAMPLE = 'sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m';
@@ -883,7 +883,7 @@ function expiresAt(value) {
883
883
  }
884
884
  /** post --type signin: one card with a link to open. The same key again with another link or time swaps them on that card, which waits again.
885
885
  * The link can hold a one-time code, so no output prints it. */
886
- function postSignin(file, on, values, filer) {
886
+ function postSignin(file, named, on, values, filer) {
887
887
  const odd = ['title', 'about', 'detail', 'option', 'recommend', 'default', 'close', 'tradeoff', 'mock', 'media', 'view', 'preview', 'chapter'].find((f) => values[f] !== undefined);
888
888
  if (odd)
889
889
  throw fail(`--${odd} does not apply to --type signin`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
@@ -921,11 +921,13 @@ function postSignin(file, on, values, filer) {
921
921
  items[at] = next;
922
922
  else
923
923
  items.push(next);
924
+ const before = waitingKeys(file);
924
925
  writeItems(file, items);
925
926
  // A new link is a new sign-in: the card waits again, also when it was done or dismissed.
926
927
  appendAnswer(file, d, CLEARED);
927
928
  if (had)
928
929
  appendDismissed(file, [d.key], false);
930
+ notifyEntered(file, named, before);
929
931
  }
930
932
  return toon({ board: tilde(file), posted: [{ key: d.key, status, service: d.title, ...(expires && { expires }) }] }, [
931
933
  ...(status === 'already done' ? [] : [`Run \`sign-off-axi open${on}\` so the user can sign in`]),
@@ -952,7 +954,7 @@ function signin(args) {
952
954
  }
953
955
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
954
956
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
955
- function replace(file, on, from, filer) {
957
+ function replace(file, named, on, from, filer) {
956
958
  if (from === undefined)
957
959
  throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
958
960
  const source = resolve(from), fed = readItems(source);
@@ -988,8 +990,8 @@ function replace(file, on, from, filer) {
988
990
  let items = feed, extra = fed.extra, resulting = incoming;
989
991
  if (filer) {
990
992
  // The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
991
- const named = new Set(feed.map((i) => i.key));
992
- items = [...feed, ...(old?.items ?? []).filter((i) => i?.filer !== filer && !named.has(i?.key))];
993
+ const fedKeys = new Set(feed.map((i) => i.key));
994
+ items = [...feed, ...(old?.items ?? []).filter((i) => i?.filer !== filer && !fedKeys.has(i?.key))];
993
995
  extra = { ...old?.extra, ...fed.extra };
994
996
  // The merged board is checked before it is written, so a feed that breaks another filer's items leaves the board as it was.
995
997
  const check = `${file}.${process.pid}.check`;
@@ -1007,14 +1009,17 @@ function replace(file, on, from, filer) {
1007
1009
  checkMerges(incoming, resulting, current);
1008
1010
  // Written only when it changed, so a feeder that runs every few seconds leaves the file alone.
1009
1011
  const changed = JSON.stringify(old && { ...old.extra, schema: old.schema, items: old.items }) !== JSON.stringify({ ...extra, schema: 2, items });
1010
- if (changed)
1012
+ if (changed) {
1013
+ const before = waitingKeys(file);
1011
1014
  writeItems(file, items, extra);
1015
+ notifyEntered(file, named, before);
1016
+ }
1012
1017
  return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
1013
1018
  });
1014
1019
  }
1015
1020
  /** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key.
1016
1021
  * `message` is a checked contract evidence message (post --kind evidence): its key, if any, takes only the same message again, and its run is stored as `evidence`. */
1017
- function postMedia(file, on, values, type, filer, message) {
1022
+ function postMedia(file, named, on, values, type, filer, message) {
1018
1023
  fits(values.title);
1019
1024
  const explainer = type === 'explainer';
1020
1025
  const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
@@ -1115,14 +1120,16 @@ function postMedia(file, on, values, type, filer, message) {
1115
1120
  finally {
1116
1121
  rmSync(check, { force: true });
1117
1122
  }
1123
+ const before = waitingKeys(file);
1118
1124
  writeItems(file, items);
1125
+ notifyEntered(file, named, before);
1119
1126
  if (explainer)
1120
1127
  return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
1121
1128
  return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
1122
1129
  });
1123
1130
  }
1124
1131
  /** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
1125
- function postReport(file, on, values) {
1132
+ function postReport(file, named, on, values) {
1126
1133
  const odd = ['option', 'recommend', 'default', 'close', 'detail', 'mock', 'tradeoff', 'media', 'chapter'].find((f) => values[f] !== undefined);
1127
1134
  if (odd)
1128
1135
  throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
@@ -1134,11 +1141,11 @@ function postReport(file, on, values) {
1134
1141
  // Posting the same report again is a no-op, so it keeps the time it was first filed.
1135
1142
  const filed = (had?.kind === 'report' && had.filed) || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1136
1143
  const { title, about, view, preview, link } = values;
1137
- return postKind(file, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, ...(link !== undefined && { link }), filed });
1144
+ return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, ...(link !== undefined && { link }), filed });
1138
1145
  }
1139
1146
  /** Posts one contract message (docs/contract.md), from a file or from stdin with --from -: checked against --kind, then stored like a raised item.
1140
1147
  * `built` is a message post --type report made from its flags. */
1141
- async function postKind(file, on, values, built) {
1148
+ async function postKind(file, named, on, values, built) {
1142
1149
  const { kind, from } = values, example = 'sign-off-axi post --kind ask --from ask.json';
1143
1150
  const name = built ? '--type report' : from === '-' ? 'stdin' : from;
1144
1151
  let bytes, m = built;
@@ -1177,9 +1184,9 @@ async function postKind(file, on, values, built) {
1177
1184
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
1178
1185
  try {
1179
1186
  if (m.kind === 'evidence')
1180
- return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
1187
+ return await postMedia(file, named, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
1181
1188
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
1182
- return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
1189
+ return await storeIncoming(file, named, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
1183
1190
  }
1184
1191
  catch (e) {
1185
1192
  if (!(e instanceof AxiError && e.code === 'CONFLICT'))
@@ -1332,7 +1339,7 @@ function checkMerges(incoming, resulting, current) {
1332
1339
  }
1333
1340
  }
1334
1341
  /** exact: a contract post, where a key takes only the same message again, filer and filed included (contract section 4). */
1335
- function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
1342
+ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
1336
1343
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1337
1344
  return locked(`${file}.lock`, () => {
1338
1345
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
@@ -1362,8 +1369,11 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
1362
1369
  }
1363
1370
  return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
1364
1371
  });
1365
- if (raised.some((r) => r.status === 'raised'))
1372
+ if (raised.some((r) => r.status === 'raised')) {
1373
+ const before = waitingKeys(file);
1366
1374
  writeItems(file, items);
1375
+ notifyEntered(file, named, before);
1376
+ }
1367
1377
  const key = raised.length === 1 ? ` --key ${raised[0].key}` : '';
1368
1378
  // Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
1369
1379
  const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered'));
@@ -1464,7 +1474,7 @@ async function running(file) {
1464
1474
  /** Loopback, or one private address: 10/8, 172.16/12, 192.168/16, the 100.64/10 range mesh VPNs use, IPv6 fc00::/7. Never all interfaces or a public one. */
1465
1475
  const PRIVATE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|::1$|f[cd][0-9a-f]{2}:)/i;
1466
1476
  const privateBind = (host) => isIP(host) > 0 && PRIVATE.test(host);
1467
- const loopback = (host) => /^127\./.test(host) || host === '::1';
1477
+ const loopback = (host) => isIP(host) > 0 && (/^127\./.test(host) || host === '::1');
1468
1478
  const urlHost = (host) => (isIP(host) === 6 ? `[${host}]` : host);
1469
1479
  /** The config.yaml beside the board as it is named, never beside the file a symlinked board points to: the one place open, serve and doctor read it. */
1470
1480
  function setupOf(named) {
@@ -1580,14 +1590,26 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1580
1590
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1581
1591
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1582
1592
  const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
1583
- const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.board.pid\n*.board.log\n*.token\n*.export.html\n';
1593
+ const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1584
1594
  /** The board, port, bind and hosts in <so>/config.yaml (the `key: value` lines init writes). A bad line falls back on its own default
1585
1595
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1586
1596
  function readSetup(so) {
1587
- const config = {}, problems = [], hosts = [];
1588
- let onAnswer = [];
1597
+ const config = {}, problems = [], hosts = [], notify = {};
1598
+ let onAnswer = [], notifyBlock = false, inNotify = false;
1589
1599
  readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
1590
- const line = l.split('#')[0].trim(), kv = line.match(/^([A-Za-z]+)\s*:\s*(\S+)$/), key = line.match(/^(board|port|bind)(\s|:|$)/i);
1600
+ // The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
1601
+ if (inNotify && (!l.trim() || /^\s/.test(l))) {
1602
+ const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
1603
+ if (field)
1604
+ notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
1605
+ else if (text)
1606
+ problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
1607
+ return;
1608
+ }
1609
+ inNotify = false;
1610
+ const line = l.split('#')[0].trim(), kv = line.match(/^([A-Za-z]+)\s*:\s*(\S+)$/), key = line.match(/^(board|port|bind|notify)(\s|:|$)/i);
1611
+ if (/^notify\s*:$/i.test(line))
1612
+ return void (inNotify = notifyBlock = true);
1591
1613
  // hosts: private DNS names that reach the bind address (a mesh VPN name), let in besides it; commas or spaces between them.
1592
1614
  const names = /^hosts\s*:(.*)$/i.exec(line)?.[1].split(/[\s,]+/).filter(Boolean).map((h) => h.toLowerCase().replace(/\.$/, ''));
1593
1615
  for (const h of names ?? []) {
@@ -1615,7 +1637,50 @@ function readSetup(so) {
1615
1637
  const bind = config.bind ?? '127.0.0.1';
1616
1638
  if (!privateBind(bind))
1617
1639
  problems.push(`bind ${bind} is not 127.0.0.1 or a private address`);
1618
- return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems };
1640
+ const parsed = (u) => { try {
1641
+ return new URL(u ?? '');
1642
+ }
1643
+ catch {
1644
+ return undefined;
1645
+ } };
1646
+ const sendTo = parsed(notify.url), base = parsed(notify.link_base);
1647
+ const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
1648
+ && ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
1649
+ if (notifyBlock && !usable)
1650
+ problems.push('notify needs url (https://, or http:// on this machine) and link_base (http:// or https://) lines, and token_env must name an environment variable');
1651
+ return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
1652
+ notify: notifyBlock && usable ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base } : undefined };
1653
+ }
1654
+ const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000];for(let i=0;i<4;i++){try{if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(process.env.SIGN_OFF_NOTIFY_TOKEN&&{authorization:'Bearer '+process.env.SIGN_OFF_NOTIFY_TOKEN})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
1655
+ /** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
1656
+ function waitingKeys(file) {
1657
+ try {
1658
+ return new Set(board(file, () => { }).filter(waits).map((d) => d.key));
1659
+ }
1660
+ catch {
1661
+ return undefined;
1662
+ }
1663
+ }
1664
+ /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command. */
1665
+ function notifyEntered(file, named, before) {
1666
+ try {
1667
+ const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1668
+ if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1669
+ return;
1670
+ for (const d of board(file, () => { }).filter((d) => waits(d) && !before.has(d.key)))
1671
+ sendNotice(so, notify, d);
1672
+ }
1673
+ catch { }
1674
+ }
1675
+ function sendNotice(so, notify, d) {
1676
+ const token = notify.token_env ? process.env[notify.token_env] : undefined;
1677
+ const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1678
+ const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1679
+ title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
1680
+ url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
1681
+ const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: body, SIGN_OFF_NOTIFY_TOKEN: token ?? '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
1682
+ child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
1683
+ child.unref();
1619
1684
  }
1620
1685
  /** --port, else the port in a config.yaml beside the board (init writes one), else 4410. */
1621
1686
  const boardPort = (named, given) => portOf(given ?? String(setupOf(named)?.port ?? 4410));
@@ -1768,6 +1833,8 @@ async function doctor(args) {
1768
1833
  check('config', `board ${basename(board)}, port ${port}`);
1769
1834
  else
1770
1835
  check('config', `${basename(config)}: ${setup.problems.join('; ')}`, `Edit ${shellPath(config)} to hold "board: <name>.json", "port: <0-65535>" and "bind: <127.0.0.1 or a private address>" lines, such as board: decisions.json, port: 4410 and bind: 127.0.0.1, and hosts: only DNS names${setup.goodBoard ? '' : `, or delete it and run \`${rerun}\``}`);
1836
+ if (setup.notify?.token_env && !process.env[setup.notify.token_env])
1837
+ check('notify', `${setup.notify.token_env} is not set, so no notice is sent`, `Export ${setup.notify.token_env} with the receiver's token`);
1771
1838
  // A hosts: name is only of use when it leads to the bind address.
1772
1839
  const astray = [];
1773
1840
  for (const h of setup.hosts) {
@@ -2129,7 +2196,7 @@ async function route(req) {
2129
2196
  if (req.method === 'GET' && req.url?.startsWith('/design/')) {
2130
2197
  const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
2131
2198
  const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
2132
- const allowed = d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
2199
+ const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
2133
2200
  const type = assetTypes[extname(path).toLowerCase()];
2134
2201
  try {
2135
2202
  if (!allowed || !type)
package/dist/contract.js CHANGED
@@ -265,7 +265,7 @@ export function checkMessage(m, want, flags = false) {
265
265
  if (!['draft', 'in_review'].includes(m.status))
266
266
  throw bad('status', 'must be draft or in_review', 'Set "status" to "draft" or "in_review"');
267
267
  page('preview', /\.(png|jpe?g|webp)$/i, '.png, .jpg, .jpeg or .webp', '"design/preview.png"', 'a design preview');
268
- const journeys = list('journeys', 1, 40, ['id', 'title', 'screens'], '{"id":"signup","title":"Sign up","screens":[...]}');
268
+ const journeys = list('journeys', 0, 40, ['id', 'title', 'screens'], '{"id":"signup","title":"Sign up","screens":[...]}');
269
269
  const ids = [];
270
270
  journeys.forEach((j, n) => {
271
271
  if (typeof j.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(j.id))
package/dist/store.js CHANGED
@@ -288,11 +288,14 @@ export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick) {
288
288
  if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
289
289
  throw bad('notes must be a list, with at least one note on change');
290
290
  const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
291
- for (const n of notes)
292
- if (!n || !screens.has(`${n.journey}/${n.screen}`) || !isText(n.note) || !n.note.trim() || [...n.note].length > NOTE_MAX)
293
- throw bad('each note needs a known journey and screen, and text of 500 characters or fewer');
294
- if (new Set(notes.map(n => `${n.journey}/${n.screen}`)).size !== notes.length)
295
- throw bad('notes must name each screen once');
291
+ for (const n of notes) {
292
+ const hasJourney = n?.journey !== undefined, hasScreen = n?.screen !== undefined;
293
+ if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) || !isText(n.note) || !n.note.trim() || [...n.note].length > NOTE_MAX) {
294
+ throw bad('each note needs both a known journey and screen, or neither for a design-level note, with text of 500 characters or fewer');
295
+ }
296
+ }
297
+ if (new Set(notes.map(n => n.journey === undefined ? '' : `${n.journey}/${n.screen}`)).size !== notes.length)
298
+ throw bad('notes must name each screen once, or contain one design-level note');
296
299
  const options = d.options ?? [];
297
300
  if (options.length ? !options.some(o => o.value === pick) : pick !== undefined)
298
301
  throw bad('pick must be one listed option, or omitted when no options were offered');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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
@@ -20,11 +20,11 @@
20
20
  /* A phone keeps room at the bottom for the tab bar. */
21
21
  body { margin: 0; padding-bottom: calc(5.125rem + env(safe-area-inset-bottom)); background: var(--bg); color: var(--text); overflow-wrap: anywhere;
22
22
  -webkit-font-smoothing: antialiased; font: 1.0625rem/1.4706 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; letter-spacing: -0.022em; }
23
- /* The four areas each have a colour, used on icons, dots and marks. */
23
+ /* The board areas each have a colour, used on icons, dots and marks. */
24
24
  .i-waiting { --area: var(--c-waiting); } .i-evidence { --area: var(--c-evidence); } .i-reports { --area: var(--c-reports); } .i-answered { --area: var(--c-answered); }
25
25
  main { max-width: 72rem; margin: 0 auto; padding: 0 max(1.25rem, env(safe-area-inset-right)) 0 max(1.25rem, env(safe-area-inset-left)); }
26
26
  .side { display: none; }
27
- /* A phone moves between the areas with a tab bar, solid so no page text shows through it. */
27
+ /* A phone moves between areas with a tab bar, solid so no page text shows through it. */
28
28
  .tabs { position: fixed; z-index: 2; left: 0; right: 0; bottom: 0; display: flex; padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
29
29
  background: var(--bar); border-top: 0.5px solid var(--line); }
30
30
  .tabs a { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.125rem; min-height: 4.125rem; color: var(--muted);
@@ -204,6 +204,10 @@
204
204
  .plan-section, .plan-decision { padding: 1.5rem; }
205
205
  }
206
206
  body.reading { overflow: hidden; }
207
+ @media (min-width: 60rem) {
208
+ body.design-reading main { display: none; }
209
+ body.design-reading #reader { position: static; inset: auto; z-index: auto; grid-column: 2; grid-row: 1; width: 100%; height: 100vh; min-width: 0; }
210
+ }
207
211
  .foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; }
208
212
  .foot .meta { margin: 0 auto 0 0; }
209
213
  .foot a { color: var(--accent); font-weight: 600; text-decoration: none; }
@@ -340,6 +344,22 @@
340
344
  .done[open] + .x { align-self: start; margin-top: 1rem; }
341
345
  .needs .foot { justify-content: space-between; margin-top: 1rem; padding-top: 0.5rem; border-top: 1px solid var(--line); }
342
346
  .foot .dis { padding: 0 0.5rem; background: none; color: var(--muted); }
347
+ .design-list { display: grid; gap: 0.75rem; }
348
+ .design-card { display: grid; grid-template-columns: minmax(4rem, 7rem) minmax(0, 1fr); align-items: center; gap: 1rem; }
349
+ .design-card h3 a { color: inherit; text-decoration: none; }
350
+ .design-card .thumb { width: 100%; height: 8rem; object-fit: cover; }
351
+ .design-review { flex: 1; min-height: 0; overflow: auto; padding: 1.25rem; }
352
+ .design-preview-window { width: 100%; height: min(65vh, 844px); margin: 1rem auto; overflow: auto; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); }
353
+ .design-review-preview { display: block; width: 100%; height: auto; }
354
+ .design-preview-full { display: block; max-width: 56rem; margin: 0 auto; color: var(--accent); }
355
+ @media (min-width: 60rem) { .design-preview-window { max-width: 24.375rem; } }
356
+ .design-review label { display: grid; gap: 0.5rem; max-width: 56rem; margin: 1rem auto; color: var(--muted); }
357
+ .design-review textarea { min-height: 6rem; padding: 0.75rem; background: var(--surface); }
358
+ .design-review .foot { max-width: 56rem; margin: 1rem auto; }
359
+ .design-review .foot > * { flex: 1; }
360
+ .design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
361
+ .design-review > a { display: block; max-width: 56rem; margin: 1rem auto; color: var(--accent); }
362
+ .needs .foot .open-link { min-height: 2.75rem; padding: 0 1rem; background: var(--action); color: var(--on-accent); text-align: center; }
343
363
  /* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
344
364
  .gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
345
365
  .gone-asks .rows { padding: 0; } .gone-asks li { justify-content: space-between; gap: 0.75rem; min-height: 2.75rem; }
@@ -819,6 +839,7 @@
819
839
 
820
840
  /** A mock or view is served from inside its own folder, so the files beside it load by their relative names. */
821
841
  const mockUrl = (key, value, path) => `${state.mockBase}${key}~${value}/${encodeURIComponent(path.split('?')[0].split('/').pop())}`;
842
+ const designUrl = (d, path) => `${state.mockBase.replace('/mock/', '/design/')}${d.key}/${encodeURIComponent(path)}`;
822
843
  /** A diagram shows inline at card width; an interactive page runs in a sandboxed frame. A tap or the link opens either full size. */
823
844
  function rung(d) {
824
845
  if (!d.view) return '';
@@ -920,11 +941,12 @@
920
941
  ...[...old].map(([m, n]) => el('p', { class: 'meta' }, `${n} comment${n > 1 ? 's' : ''} on an earlier version of “${name(m)}” ${n > 1 ? 'are' : 'is'} hidden`)), err);
921
942
  }
922
943
 
923
- const AREAS = { waiting: 'Waiting on you', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
944
+ const AREAS = { waiting: 'Waiting on you', designs: 'Designs', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
924
945
  // The sidebar and the tab bar name the areas short enough for one line.
925
946
  const NAV = { ...AREAS, reports: 'Reports' }, TAB = { ...NAV, waiting: 'Waiting' };
926
- const EMPTY = { waiting: 'Nothing is waiting on you.', evidence: 'No evidence updates.', reports: 'No reports or explainers.', answered: 'Nothing answered yet.' };
947
+ const EMPTY = { waiting: 'Nothing is waiting on you.', designs: 'No designs yet.', evidence: 'No evidence updates.', reports: 'No reports or explainers.', answered: 'Nothing answered yet.' };
927
948
  const ICONS = { summary: 'M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
949
+ designs: 'M3 3h18v18H3zM3 9h18M9 21V9',
928
950
  waiting: 'M22 12h-6l-2 3h-4l-2-3H2M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z',
929
951
  evidence: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1zM9 12l2 2 4-4',
930
952
  reports: 'M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
@@ -950,7 +972,7 @@
950
972
  const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
951
973
  const pic = (d) => {
952
974
  const i = d.media?.findIndex((m) => !/\.(mp4|webm)$/i.test(m.path)), m = d.mocks?.find((x) => !/\.html?$/i.test(x.path));
953
- return i >= 0 ? `/api/media?key=${encodeURIComponent(d.key)}&i=${i}` : d.preview ? mockUrl(d.key, '', d.preview) : d.type === 'diagram' ? mockUrl(d.key, '', d.view)
975
+ return d.kind === 'design' && d.preview ? designUrl(d, d.preview) : i >= 0 ? `/api/media?key=${encodeURIComponent(d.key)}&i=${i}` : d.preview ? mockUrl(d.key, '', d.preview) : d.type === 'diagram' ? mockUrl(d.key, '', d.view)
954
976
  : m ? mockUrl(d.key, m.value, m.path) : d.evidenceCard?.media.find((x) => x.type.startsWith('image/'))?.url;
955
977
  };
956
978
  const thumb = (a, url) => { if (!url) return icon(a); const img = el('img', { class: 'thumb', src: src(url), alt: '' }); img.onerror = () => img.replaceWith(icon(a)); return img; };
@@ -959,7 +981,7 @@
959
981
  const answeredToday = a === 'answered' && items.filter((d) => new Date(state.answers[d.key].at).toDateString() === new Date().toDateString()).length;
960
982
  // A short count line that never cuts off on a phone. Waiting gives the oldest ask's time when its items carry one.
961
983
  const oldest = items.map((d) => d.filed).filter(Boolean).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
962
- const detail = { waiting: oldest ? `Oldest ${when(oldest)}` : `${items.length} waiting`, evidence: items[0]?.number ? `Newest: Update ${items[0].number}` : `${items.length} to see`,
984
+ const detail = { waiting: oldest ? `Oldest ${when(oldest)}` : `${items.length} waiting`, designs: `${items.length} to review`, evidence: items[0]?.number ? `Newest: Update ${items[0].number}` : `${items.length} to see`,
963
985
  reports: `${items.length} to read`, answered: `${answeredToday} today` }[a];
964
986
  const thumbs = a === 'evidence' ? items.map(pic).filter(Boolean).slice(0, 3).map((u) => el('img', { src: src(u), alt: '' })) : [];
965
987
  const marks = thumbs.length ? thumbs : a === 'waiting' ? [items.length ? icon(a) : '']
@@ -1184,23 +1206,69 @@
1184
1206
  return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
1185
1207
  }
1186
1208
 
1209
+ function designReview(d) {
1210
+ const saved = state.answers[d.key], screens = (d.journeys ?? []).flatMap((journey) => journey.screens.map((screen) => ({ journey, screen }))), savedNote = saved?.notes?.[0];
1211
+ const screenKey = (target) => target && `${target.journey.id}/${target.screen.id}`, screenFor = (key) => screens.find((target) => screenKey(target) === key);
1212
+ const note = el('textarea', { maxlength: '500', placeholder: 'What should change? (optional for approval)' });
1213
+ const pick = el('select', { 'aria-label': 'Pick a direction' }, el('option', { value: '' }, 'Choose a direction'));
1214
+ for (const option of d.options ?? []) pick.append(el('option', { value: option.value }, option.label));
1215
+ pick.value = saved?.pick ?? '';
1216
+ const screenSelect = screens.length > 1 ? el('select', { 'aria-label': 'Screen for note' }, ...screens.map((target) => el('option', { value: screenKey(target) }, `${target.journey.title} — ${target.screen.title}`))) : '';
1217
+ const selectedKey = savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]);
1218
+ if (screenSelect) screenSelect.value = screenFor(selectedKey) ? selectedKey : screenKey(screens[0]);
1219
+ const target = () => screenSelect ? screenFor(screenSelect.value) : screens[0];
1220
+ note.value = savedNote?.note ?? '';
1221
+ const previewPath = () => (target() && Object.values(target().screen.views)[0]) || d.preview;
1222
+ const previewElement = (path) => /\.html?$/i.test(path) ? el('iframe', { class: 'design-review-preview', src: src(designUrl(d, path)), title: `Design preview: ${d.title}`, sandbox: 'allow-scripts' }) : el('img', { class: 'design-review-preview', src: src(designUrl(d, path)), alt: `Design preview: ${d.title}` });
1223
+ const preview = previewPath() ? el('div', { class: 'design-preview-window' }, previewElement(previewPath())) : '';
1224
+ const fullPreview = previewPath() ? el('a', { class: 'design-preview-full', href: src(designUrl(d, previewPath())), target: '_blank', rel: 'noopener noreferrer' }, 'Open full preview') : '';
1225
+ if (screenSelect) screenSelect.onchange = () => {
1226
+ const path = previewPath();
1227
+ if (preview) preview.replaceChildren(previewElement(path));
1228
+ if (fullPreview) fullPreview.href = src(designUrl(d, path));
1229
+ };
1230
+ const status = el('p', { class: 'meta', role: 'status' }, saved ? saved.label : d.status === 'draft' ? 'Draft' : 'In review');
1231
+ const err = el('p', { class: 'err', role: 'alert' });
1232
+ const save = (verdict) => {
1233
+ if (d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
1234
+ const selected = target(), text = note.value.trim();
1235
+ return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, verdict,
1236
+ notes: text ? [{ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), note: text }] : [], ...(pick.value && { pick: pick.value }) }) }, render);
1237
+ };
1238
+ const approve = el('button', { class: 'open-link' }, 'Approve'), change = el('button', {}, 'Change');
1239
+ approve.disabled = change.disabled = state.readonly || !!state.answers_only;
1240
+ approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
1241
+ change.onclick = () => { if (!note.value.trim()) { err.textContent = 'Add a note describing what should change.'; note.focus(); return; } save('change').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; }); };
1242
+ return el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
1243
+ screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
1244
+ el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err,
1245
+ d.link ? el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer' }, 'Open original design') : '');
1246
+ }
1247
+
1187
1248
  /** The report a #reports/<key> link opens (or the plan a #waiting/<key> link opens), over the whole board: a bar with the way back and its title, then its page in a sandboxed frame.
1188
1249
  * The frame is made again only for another page, so a redraw never resets a demo halfway through. */
1189
1250
  const reader = document.getElementById('reader');
1190
1251
  function readPage(d, from = 'reports') {
1191
1252
  document.body.classList.toggle('reading', !!d);
1192
- for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
1253
+ document.body.classList.toggle('design-reading', !!d && d.kind === 'design');
1254
+ const inlineDesign = !!d && d.kind === 'design' && matchMedia('(min-width: 60rem)').matches;
1255
+ reader.setAttribute('role', inlineDesign ? 'region' : 'dialog');
1256
+ if (inlineDesign) reader.removeAttribute('aria-modal'); else reader.setAttribute('aria-modal', 'true');
1257
+ document.querySelector('main').inert = !!d;
1258
+ document.getElementById('nav').inert = !!d && !inlineDesign;
1259
+ document.getElementById('tabs').inert = !!d;
1193
1260
  reader.hidden = !d;
1194
- const url = d ? src(mockUrl(d.key, '', d.view)) : '';
1195
- const answer = d?.type === 'plan' ? JSON.stringify(state.answers[d.key] ?? null) : '';
1261
+ const url = d?.kind === 'design' ? `design:${d.key}` : d ? src(mockUrl(d.key, '', d.view)) : '';
1262
+ const answer = d?.type === 'plan' || d?.kind === 'design' ? JSON.stringify(state.answers[d.key] ?? null) : '';
1196
1263
  const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
1197
1264
  if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
1198
1265
  Object.assign(reader.dataset, { answer, plan });
1199
1266
  Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
1200
1267
  reader.replaceChildren(...(d ? [el('header', {}, el('a', { class: 'close', href: `#${from}` }, `‹ ${from === 'reports' ? 'Reports' : AREAS[from]}`), el('h1', { id: 'reader-title', tabindex: '-1', dir: 'auto' }, d.title)),
1201
- d.type === 'plan' ? planReview(d, url) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1268
+ d.type === 'plan' ? planReview(d, url) : d.kind === 'design' ? designReview(d) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1202
1269
  }
1203
1270
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && !reader.hidden) location.hash = `#${reader.dataset.from}`; });
1271
+ addEventListener('resize', () => { if (!reader.hidden) readPage(state.decisions.find((d) => d.key === reader.dataset.key), reader.dataset.from); });
1204
1272
 
1205
1273
  /** Reports in sections, one per `group` in the order each first shows, each head with the newest filed time; items with no group come last, under Other reports when there are groups. */
1206
1274
  function reportGroups(items) {
@@ -1213,6 +1281,18 @@
1213
1281
  });
1214
1282
  }
1215
1283
 
1284
+ function designGroups(items) {
1285
+ if (!items.length) return el('p', { class: 'empty' }, EMPTY.designs);
1286
+ const groups = [...new Set(items.map((d) => d.group || ''))];
1287
+ return groups.flatMap((group) => [group ? el('div', { class: 'sec-h' }, el('h2', {}, group)) : '',
1288
+ el('div', { class: 'design-list' }, ...items.filter((d) => (d.group || '') === group).map((d) => {
1289
+ const saved = state.answers[d.key], status = saved?.answer === 'approve' ? 'Approved' : saved?.answer === 'change' ? 'Changes requested' : d.status === 'draft' ? 'Draft' : 'In review';
1290
+ const href = `#designs/${d.key}`, image = pic(d);
1291
+ return el('article', { class: 'card design-card', id: `item-${d.key}` }, image ? el('a', { href, class: 'design-preview', 'aria-label': `Open design, ${d.title}` }, thumb('designs', image)) : '',
1292
+ el('div', {}, el('p', { class: 'eyebrow' }, status), el('h3', {}, el('a', { href }, d.title)), el('p', { class: 'meta' }, [d.filed ? day(d.filed) : '', saved?.pick ? `Pick: ${saved.pick}` : '', saved?.notes?.[0]?.note ?? ''].filter(Boolean).join(' · '))));
1293
+ }))]);
1294
+ }
1295
+
1216
1296
  /** The asks dismissed from Waiting on you, folded under one row; Restore puts one back on the board. */
1217
1297
  function dismissed(items) {
1218
1298
  if (!canDismiss() || !items.length) return '';
@@ -1273,7 +1353,7 @@
1273
1353
  return el('div', { class: 'tri' }, queue, el('div', { class: 'focus' }, el('p', { class: 'fhead' }, `${i + 1} of ${n}`),
1274
1354
  el('div', { class: 'pbar', 'aria-hidden': 'true' }, el('i', { style: `width: ${((i + 1) / n) * 100}%` })),
1275
1355
  d.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(d.filed)}`) : '', card,
1276
- skip || dis ? el('div', { class: 'quiet' }, skip || '', dis) : '', next ? el('div', { class: 'card upnext' }, el('p', { class: 'meta' }, 'Up next'), el('strong', { dir: 'auto' }, title(next))) : '',
1356
+ d.kind === 'design' || skip || dis ? el('div', { class: 'quiet' }, d.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${d.key}` }, 'Open designs') : skip || '', dis) : '', next ? el('div', { class: 'card upnext' }, el('p', { class: 'meta' }, 'Up next'), el('strong', { dir: 'auto' }, title(next))) : '',
1277
1357
  hint ? el('p', { class: 'meta hint' }, hint) : '', el('p', { class: 'meta keys' }, `Keys: ${keys}`), dismissed(hidden)));
1278
1358
  }
1279
1359
  // Triage keys at a desk: a number picks that answer (in the order shown), E skips, Z takes back the last tap.
@@ -1285,13 +1365,14 @@
1285
1365
  });
1286
1366
 
1287
1367
  function render() {
1288
- const by = { waiting: [], evidence: [], reports: [], answered: [], notes: [] };
1368
+ const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
1289
1369
  // The board file appends new items at the end, so each list reads newest first; answers sort by their time.
1290
1370
  // One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
1291
1371
  const gone = new Set(state.dismissed ?? []), open = state.decisions.filter((d) => !gone.has(d.key) && !isAnswered(d));
1292
1372
  const openKeys = new Set(open.map((d) => d.key));
1293
1373
  const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
1294
1374
  for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key)) by[area(d)].push(d);
1375
+ by.designs = [...state.decisions].reverse().filter((d) => d.kind === 'design' && !gone.has(d.key));
1295
1376
  // Waiting on you reads newest first unless the viewer chose oldest first. A sign-in blocks someone now, so sign-ins come first either way.
1296
1377
  let latest = -Infinity;
1297
1378
  const inFileOrder = by.waiting.slice().reverse().map((d, i) => { const t = Date.parse(d.filed); if (!Number.isNaN(t)) latest = t; return { d, t: latest, i }; });
@@ -1303,7 +1384,7 @@
1303
1384
  const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
1304
1385
  const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
1305
1386
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
1306
- const [, at, key] = /^#(waiting|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1387
+ const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1307
1388
  const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (at ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
1308
1389
  document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
1309
1390
  document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
@@ -1318,14 +1399,14 @@
1318
1399
  : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
1319
1400
  const first = by.waiting[0], more = by.waiting.length > 1 ? `Answer all ${by.waiting.length} in Triage →` : 'Open in Triage →';
1320
1401
  const needs = el('section', { class: 'card needs' }, el('h2', {}, 'Needs you first'), ...(first ? [first.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(first.filed)}`) : '', row(first, { x: false }),
1321
- el('div', { class: 'foot' }, xBtn(first, 'Dismiss'), el('a', { class: 'more', href: '#waiting' }, more))] : [el('p', { class: 'meta' }, 'Nothing is waiting on you.')]), dismissed(hidden));
1402
+ el('div', { class: 'foot' }, xBtn(first, 'Dismiss'), first.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${first.key}` }, 'Open designs') : el('a', { class: 'more', href: '#waiting' }, more))] : [el('p', { class: 'meta' }, 'Nothing is waiting on you.')]), dismissed(hidden));
1322
1403
  document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), triage(shown, hidden)]
1323
- : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
1404
+ : at === 'designs' ? [...designGroups(by.designs)] : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
1324
1405
  ...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes)] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'evidence' && by[at].length ? el('div', { class: 'sec-head' }, el('h2', {}, 'Updates')) : '', at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
1325
1406
  : el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [el('div', { class: 'tiles' }, ...Object.keys(AREAS).map((a) => tile(a, by[a]))),
1326
1407
  el('div', { class: 'pair' }, needs, today(by.answered)), ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()]),
1327
1408
  el('div', { class: 'areas' }, ...Object.keys(AREAS).map((a) => section(a, by[a])))]));
1328
- readPage(key ? state.decisions.find((d) => d.key === key && reads(d) && !gone.has(d.key) && (at === 'reports' || (d.type === 'plan' && area(d) === at))) : undefined, at);
1409
+ readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || d.type === 'plan' && area(d) === at)) : undefined, at);
1329
1410
  if (FROZEN) freeze();
1330
1411
  }
1331
1412
  function freeze() {
@@ -1341,7 +1422,7 @@
1341
1422
  if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
1342
1423
  if (location.hash === '#waiting') waitType = 'all';
1343
1424
  render();
1344
- if (!reader.hidden) return reader.querySelector('h1').focus();
1425
+ if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
1345
1426
  const item = (jump || back) && document.getElementById(`item-${jump || back}`);
1346
1427
  jump = null;
1347
1428
  item ? item.scrollIntoView() : scrollTo(0, 0);