sign-off-axi 0.4.3 → 0.4.5

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
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
10
10
  import { parseArgs } from 'node:util';
11
11
  import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
- import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
13
+ import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
14
14
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
15
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
16
16
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
@@ -484,7 +484,7 @@ function filerOf(values, session = false) {
484
484
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
485
485
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
486
486
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
487
- const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
487
+ const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
488
488
  /** The board's decisions with their latest answer; a missing board file is an empty board.
489
489
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
490
490
  function board(file, say = warn) {
@@ -515,7 +515,7 @@ function ready(file, on, root) {
515
515
  }
516
516
  function list(args) {
517
517
  const { file, values, on } = parse('list', args, TIERS);
518
- const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
518
+ const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
519
519
  if (values.json)
520
520
  return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
521
521
  const help = [
@@ -525,7 +525,7 @@ function list(args) {
525
525
  ];
526
526
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
527
527
  if (values.full) {
528
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? (d.dismissed ? 'dismissed' : 'waiting'), title: d.title, about: d.about ?? '', detail: d.detail ?? '',
528
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
529
529
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
530
530
  }
531
531
  return toon({
@@ -604,7 +604,9 @@ async function wait(args) {
604
604
  const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
605
605
  // With a filer, only its own items count, and an answer it has not taken yet is new however old it is.
606
606
  const mine = (rows) => (filer === undefined ? rows : rows.filter((d) => d.filer === filer));
607
- const before = new Set(board(file).flatMap((d) => (d.answer ? [d.key] : [])));
607
+ // A design with only screen notes still waits: it counts as unanswered here until its design verdict lands.
608
+ const open = (rows) => rows.map((d) => (awaitsVerdict(d.type, d.answer) ? { ...d, answer: undefined } : d));
609
+ const before = new Map(open(board(file)).flatMap((d) => (d.answer ? [[d.key, JSON.stringify(d.answer)]] : [])));
608
610
  // Rounds seen before waiting starts stay quiet; only a newly asked round wakes the loop.
609
611
  const seenRounds = new Map();
610
612
  for (const r of readRounds(file))
@@ -618,8 +620,8 @@ async function wait(args) {
618
620
  }
619
621
  catch { }
620
622
  for (let first = true;; first = false) {
621
- const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
622
- const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : !before.has(d.key))));
623
+ const rows = mine(open(board(file, first ? warn : () => { }))), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
624
+ const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : before.get(d.key) !== JSON.stringify(d.answer))));
623
625
  const counts = new Map(), asked = [];
624
626
  for (const r of readRounds(file, first ? warn : () => { })) {
625
627
  counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
@@ -779,27 +781,27 @@ function post(args, cmd = 'post') {
779
781
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
780
782
  media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
781
783
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
782
- const { file, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
784
+ const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
783
785
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
784
786
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
785
787
  if (values.kind !== undefined) {
786
788
  const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
787
789
  if (odd)
788
790
  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);
791
+ return postKind(file, named, on, values);
790
792
  }
791
793
  if (values.from !== undefined && given.length)
792
794
  throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
793
795
  if (values.replace)
794
- return replace(file, on, values.from, filerOf(values));
796
+ return replace(file, named, on, values.from, filerOf(values));
795
797
  const filer = filerOf(values, true);
796
798
  const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
797
799
  if (told && type !== 'decision')
798
800
  throw fail(`--${told} needs --type decision`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
799
801
  if (type === 'report')
800
- return postReport(file, on, values);
802
+ return postReport(file, named, on, values);
801
803
  if (type === 'signin')
802
- return postSignin(file, on, values, filer);
804
+ return postSignin(file, named, on, values, filer);
803
805
  const odd = SIGNIN_FIELDS.find((f) => values[f] !== undefined);
804
806
  if (odd)
805
807
  throw fail(`--${odd} needs --type signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
@@ -812,7 +814,7 @@ function post(args, cmd = 'post') {
812
814
  if (!['decision', 'design', 'evidence', 'diagram', 'interactive', 'explainer'].includes(type))
813
815
  throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
814
816
  if (type === 'evidence' || type === 'explainer')
815
- return postMedia(file, on, values, type, filer);
817
+ return postMedia(file, named, on, values, type, filer);
816
818
  if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
817
819
  throw fail('--media, --link and --chapter need --type evidence or explainer', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
818
820
  if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
@@ -821,7 +823,7 @@ function post(args, cmd = 'post') {
821
823
  if (view !== (values.view !== undefined))
822
824
  throw fail(view ? `--type ${type} needs --view <path>` : '--view needs --type diagram or interactive', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
823
825
  if (type === 'decision')
824
- return storeIncoming(file, on, buildIncoming(values, {}, filer, file), filer);
826
+ return storeIncoming(file, named, on, buildIncoming(values, {}, filer, file), filer);
825
827
  const root = resolve(dirname(file));
826
828
  if (view) {
827
829
  // The same real-path check the board serves with, so a post that passes always renders.
@@ -844,7 +846,7 @@ function post(args, cmd = 'post') {
844
846
  }
845
847
  if (!size.isFile() || size.size > MOCK_BYTES)
846
848
  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);
849
+ return storeIncoming(file, named, on, buildIncoming(values, { type, view: values.view }, filer, file), filer);
848
850
  }
849
851
  const mocks = values.mock?.map((m) => {
850
852
  const at = m.indexOf('=');
@@ -868,7 +870,7 @@ function post(args, cmd = 'post') {
868
870
  }
869
871
  return { value: m.slice(0, at), path };
870
872
  });
871
- return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
873
+ return storeIncoming(file, named, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
872
874
  }
873
875
  const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
874
876
  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 +885,7 @@ function expiresAt(value) {
883
885
  }
884
886
  /** 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
887
  * The link can hold a one-time code, so no output prints it. */
886
- function postSignin(file, on, values, filer) {
888
+ function postSignin(file, named, on, values, filer) {
887
889
  const odd = ['title', 'about', 'detail', 'option', 'recommend', 'default', 'close', 'tradeoff', 'mock', 'media', 'view', 'preview', 'chapter'].find((f) => values[f] !== undefined);
888
890
  if (odd)
889
891
  throw fail(`--${odd} does not apply to --type signin`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
@@ -921,11 +923,13 @@ function postSignin(file, on, values, filer) {
921
923
  items[at] = next;
922
924
  else
923
925
  items.push(next);
926
+ const before = waitingKeys(file);
924
927
  writeItems(file, items);
925
928
  // A new link is a new sign-in: the card waits again, also when it was done or dismissed.
926
929
  appendAnswer(file, d, CLEARED);
927
930
  if (had)
928
931
  appendDismissed(file, [d.key], false);
932
+ notifyEntered(file, named, before);
929
933
  }
930
934
  return toon({ board: tilde(file), posted: [{ key: d.key, status, service: d.title, ...(expires && { expires }) }] }, [
931
935
  ...(status === 'already done' ? [] : [`Run \`sign-off-axi open${on}\` so the user can sign in`]),
@@ -952,7 +956,7 @@ function signin(args) {
952
956
  }
953
957
  /** 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
958
  * 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) {
959
+ function replace(file, named, on, from, filer) {
956
960
  if (from === undefined)
957
961
  throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
958
962
  const source = resolve(from), fed = readItems(source);
@@ -972,7 +976,7 @@ function replace(file, on, from, filer) {
972
976
  // A key whose answer outlived it may not come back: the board and the answers are read here, under the lock the feed writes with.
973
977
  const saved = readAnswers(file);
974
978
  for (const i of fed.items)
975
- if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
979
+ if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key) && !awaitsVerdict(i.kind, saved[i.key])) {
976
980
  throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
977
981
  }
978
982
  const current = board(file);
@@ -980,7 +984,7 @@ function replace(file, on, from, filer) {
980
984
  const d = incoming.find((x) => x.key === had.key);
981
985
  if (filer && d && had.filer && had.filer !== filer)
982
986
  throw fail(`key ${d.key} on ${tilde(file)} was posted by another filer`, 'CONFLICT', 'Feed it under a new key');
983
- if (had.answer && d && question(d) !== question(had)) {
987
+ if (had.answer && !awaitsVerdict(had.type, had.answer) && d && question(d) !== question(had)) {
984
988
  throw fail(`key ${d.key} was answered and now holds a different question on ${tilde(file)}`, 'CONFLICT', 'Post the new question under a new key');
985
989
  }
986
990
  }
@@ -988,8 +992,8 @@ function replace(file, on, from, filer) {
988
992
  let items = feed, extra = fed.extra, resulting = incoming;
989
993
  if (filer) {
990
994
  // 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))];
995
+ const fedKeys = new Set(feed.map((i) => i.key));
996
+ items = [...feed, ...(old?.items ?? []).filter((i) => i?.filer !== filer && !fedKeys.has(i?.key))];
993
997
  extra = { ...old?.extra, ...fed.extra };
994
998
  // 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
999
  const check = `${file}.${process.pid}.check`;
@@ -1007,14 +1011,17 @@ function replace(file, on, from, filer) {
1007
1011
  checkMerges(incoming, resulting, current);
1008
1012
  // Written only when it changed, so a feeder that runs every few seconds leaves the file alone.
1009
1013
  const changed = JSON.stringify(old && { ...old.extra, schema: old.schema, items: old.items }) !== JSON.stringify({ ...extra, schema: 2, items });
1010
- if (changed)
1014
+ if (changed) {
1015
+ const before = waitingKeys(file);
1011
1016
  writeItems(file, items, extra);
1017
+ notifyEntered(file, named, before);
1018
+ }
1012
1019
  return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
1013
1020
  });
1014
1021
  }
1015
1022
  /** 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
1023
  * `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) {
1024
+ function postMedia(file, named, on, values, type, filer, message) {
1018
1025
  fits(values.title);
1019
1026
  const explainer = type === 'explainer';
1020
1027
  const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
@@ -1067,7 +1074,8 @@ function postMedia(file, on, values, type, filer, message) {
1067
1074
  }
1068
1075
  return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
1069
1076
  }
1070
- if (message && own && Object.hasOwn(readAnswers(file), own))
1077
+ const answers = readAnswers(file);
1078
+ if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
1071
1079
  throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1072
1080
  if (own && current.some((d) => d.key === own))
1073
1081
  throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
@@ -1115,14 +1123,16 @@ function postMedia(file, on, values, type, filer, message) {
1115
1123
  finally {
1116
1124
  rmSync(check, { force: true });
1117
1125
  }
1126
+ const before = waitingKeys(file);
1118
1127
  writeItems(file, items);
1128
+ notifyEntered(file, named, before);
1119
1129
  if (explainer)
1120
1130
  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
1131
  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
1132
  });
1123
1133
  }
1124
1134
  /** 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) {
1135
+ function postReport(file, named, on, values) {
1126
1136
  const odd = ['option', 'recommend', 'default', 'close', 'detail', 'mock', 'tradeoff', 'media', 'chapter'].find((f) => values[f] !== undefined);
1127
1137
  if (odd)
1128
1138
  throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
@@ -1134,11 +1144,11 @@ function postReport(file, on, values) {
1134
1144
  // Posting the same report again is a no-op, so it keeps the time it was first filed.
1135
1145
  const filed = (had?.kind === 'report' && had.filed) || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1136
1146
  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 });
1147
+ return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, ...(link !== undefined && { link }), filed });
1138
1148
  }
1139
1149
  /** 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
1150
  * `built` is a message post --type report made from its flags. */
1141
- async function postKind(file, on, values, built) {
1151
+ async function postKind(file, named, on, values, built) {
1142
1152
  const { kind, from } = values, example = 'sign-off-axi post --kind ask --from ask.json';
1143
1153
  const name = built ? '--type report' : from === '-' ? 'stdin' : from;
1144
1154
  let bytes, m = built;
@@ -1177,9 +1187,9 @@ async function postKind(file, on, values, built) {
1177
1187
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
1178
1188
  try {
1179
1189
  if (m.kind === 'evidence')
1180
- return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
1190
+ return await postMedia(file, named, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
1181
1191
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
1182
- return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
1192
+ return await storeIncoming(file, named, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
1183
1193
  }
1184
1194
  catch (e) {
1185
1195
  if (!(e instanceof AxiError && e.code === 'CONFLICT'))
@@ -1332,7 +1342,7 @@ function checkMerges(incoming, resulting, current) {
1332
1342
  }
1333
1343
  }
1334
1344
  /** 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) {
1345
+ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
1336
1346
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1337
1347
  return locked(`${file}.lock`, () => {
1338
1348
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
@@ -1347,7 +1357,7 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
1347
1357
  return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
1348
1358
  };
1349
1359
  // An answer outlives its item: a new question under that key would inherit it.
1350
- if (!had && Object.hasOwn(saved, d.key))
1360
+ if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
1351
1361
  throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
1352
1362
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1353
1363
  const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
@@ -1360,10 +1370,13 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
1360
1370
  fits(d.title, d.key);
1361
1371
  items.push({ filed: now, ...raw[i] });
1362
1372
  }
1363
- 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 };
1373
+ return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
1364
1374
  });
1365
- if (raised.some((r) => r.status === 'raised'))
1375
+ if (raised.some((r) => r.status === 'raised')) {
1376
+ const before = waitingKeys(file);
1366
1377
  writeItems(file, items);
1378
+ notifyEntered(file, named, before);
1379
+ }
1367
1380
  const key = raised.length === 1 ? ` --key ${raised[0].key}` : '';
1368
1381
  // Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
1369
1382
  const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered'));
@@ -1464,7 +1477,7 @@ async function running(file) {
1464
1477
  /** 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
1478
  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
1479
  const privateBind = (host) => isIP(host) > 0 && PRIVATE.test(host);
1467
- const loopback = (host) => /^127\./.test(host) || host === '::1';
1480
+ const loopback = (host) => isIP(host) > 0 && (/^127\./.test(host) || host === '::1');
1468
1481
  const urlHost = (host) => (isIP(host) === 6 ? `[${host}]` : host);
1469
1482
  /** 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
1483
  function setupOf(named) {
@@ -1580,14 +1593,26 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1580
1593
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1581
1594
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1582
1595
  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';
1596
+ 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
1597
  /** 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
1598
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1586
1599
  function readSetup(so) {
1587
- const config = {}, problems = [], hosts = [];
1588
- let onAnswer = [];
1600
+ const config = {}, problems = [], hosts = [], notify = {};
1601
+ let onAnswer = [], notifyBlock = false, inNotify = false;
1589
1602
  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);
1603
+ // The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
1604
+ if (inNotify && (!l.trim() || /^\s/.test(l))) {
1605
+ const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
1606
+ if (field)
1607
+ notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
1608
+ else if (text)
1609
+ problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
1610
+ return;
1611
+ }
1612
+ inNotify = false;
1613
+ const line = l.split('#')[0].trim(), kv = line.match(/^([A-Za-z]+)\s*:\s*(\S+)$/), key = line.match(/^(board|port|bind|notify)(\s|:|$)/i);
1614
+ if (/^notify\s*:$/i.test(line))
1615
+ return void (inNotify = notifyBlock = true);
1591
1616
  // hosts: private DNS names that reach the bind address (a mesh VPN name), let in besides it; commas or spaces between them.
1592
1617
  const names = /^hosts\s*:(.*)$/i.exec(line)?.[1].split(/[\s,]+/).filter(Boolean).map((h) => h.toLowerCase().replace(/\.$/, ''));
1593
1618
  for (const h of names ?? []) {
@@ -1615,7 +1640,50 @@ function readSetup(so) {
1615
1640
  const bind = config.bind ?? '127.0.0.1';
1616
1641
  if (!privateBind(bind))
1617
1642
  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 };
1643
+ const parsed = (u) => { try {
1644
+ return new URL(u ?? '');
1645
+ }
1646
+ catch {
1647
+ return undefined;
1648
+ } };
1649
+ const sendTo = parsed(notify.url), base = parsed(notify.link_base);
1650
+ const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
1651
+ && ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
1652
+ if (notifyBlock && !usable)
1653
+ 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');
1654
+ return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
1655
+ notify: notifyBlock && usable ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base } : undefined };
1656
+ }
1657
+ 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')})();`;
1658
+ /** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
1659
+ function waitingKeys(file) {
1660
+ try {
1661
+ return new Set(board(file, () => { }).filter(waits).map((d) => d.key));
1662
+ }
1663
+ catch {
1664
+ return undefined;
1665
+ }
1666
+ }
1667
+ /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command. */
1668
+ function notifyEntered(file, named, before) {
1669
+ try {
1670
+ const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1671
+ if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1672
+ return;
1673
+ for (const d of board(file, () => { }).filter((d) => waits(d) && !before.has(d.key)))
1674
+ sendNotice(so, notify, d);
1675
+ }
1676
+ catch { }
1677
+ }
1678
+ function sendNotice(so, notify, d) {
1679
+ const token = notify.token_env ? process.env[notify.token_env] : undefined;
1680
+ const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1681
+ const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1682
+ title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
1683
+ url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
1684
+ 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') } });
1685
+ child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
1686
+ child.unref();
1619
1687
  }
1620
1688
  /** --port, else the port in a config.yaml beside the board (init writes one), else 4410. */
1621
1689
  const boardPort = (named, given) => portOf(given ?? String(setupOf(named)?.port ?? 4410));
@@ -1768,6 +1836,8 @@ async function doctor(args) {
1768
1836
  check('config', `board ${basename(board)}, port ${port}`);
1769
1837
  else
1770
1838
  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}\``}`);
1839
+ if (setup.notify?.token_env && !process.env[setup.notify.token_env])
1840
+ 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
1841
  // A hosts: name is only of use when it leads to the bind address.
1772
1842
  const astray = [];
1773
1843
  for (const h of setup.hosts) {
@@ -2129,7 +2199,7 @@ async function route(req) {
2129
2199
  if (req.method === 'GET' && req.url?.startsWith('/design/')) {
2130
2200
  const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
2131
2201
  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)));
2202
+ const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
2133
2203
  const type = assetTypes[extname(path).toLowerCase()];
2134
2204
  try {
2135
2205
  if (!allowed || !type)
@@ -2292,7 +2362,7 @@ async function route(req) {
2292
2362
  appendRound(file, key, note.trim(), comments);
2293
2363
  return [200, state()];
2294
2364
  }
2295
- const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
2365
+ const { key, answer, note = '', notes, decisions, verdict, pick, requested } = JSON.parse(body) ?? {};
2296
2366
  if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
2297
2367
  return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
2298
2368
  const d = loadDecisions(file).find((x) => x.key === key);
@@ -2314,7 +2384,9 @@ async function route(req) {
2314
2384
  }
2315
2385
  if (d.kind === 'design') {
2316
2386
  try {
2317
- answered(appendDesignAnswer(file, d, verdict, notes, pick), d);
2387
+ const before = readAnswers(file)[d.key]?.answer, a = appendDesignAnswer(file, d, verdict, notes, pick, requested);
2388
+ if (a && !awaitsVerdict(d.type, a) && (verdict !== undefined || a.answer !== before))
2389
+ answered(a, d);
2318
2390
  }
2319
2391
  catch (e) {
2320
2392
  if (e instanceof InputError)
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
@@ -280,24 +280,41 @@ function saveAnswer(decisionsPath, d, option, note, comments = [], by) {
280
280
  appendLine(answersPath(decisionsPath), answer);
281
281
  return answer;
282
282
  }
283
- /** Saves a design answer with screen-specific notes and an optional listed direction pick. */
284
- export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick) {
283
+ /** A design answer that holds only screen notes while the design has no design-wide verdict: it still waits for one. */
284
+ export const SCREENS_ONLY = 'screens';
285
+ /** True while an answer leaves the item waiting: none yet, or only screen notes. */
286
+ export const awaitsVerdict = (type, a) => !a || (type === 'design' && a.answer === SCREENS_ONLY);
287
+ const DESIGN_LABEL = { approve: 'Approved', change: 'Changes requested', [SCREENS_ONLY]: 'Screens reviewed' };
288
+ /** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a change on a screen of an approved design sets it to change, and with none yet it waits as screens-only. */
289
+ export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, requested) {
285
290
  const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
286
- if (verdict !== 'approve' && verdict !== 'change')
287
- throw bad('verdict must be approve or change');
291
+ if (verdict !== undefined && verdict !== 'approve' && verdict !== 'change')
292
+ throw bad('verdict must be approve or change, or omitted to save a screen');
288
293
  if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
289
294
  throw bad('notes must be a list, with at least one note on change');
295
+ if (verdict === undefined && !notes.some(n => n?.journey !== undefined))
296
+ throw bad('a screen save names its screen');
297
+ if (requested === true && (verdict !== undefined || !notes.some(n => n?.journey !== undefined && (n.verdict ?? 'change') === 'change')))
298
+ throw bad('a requested change is a screen save with a change note on a screen');
290
299
  const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
291
- 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');
300
+ for (const n of notes) {
301
+ const hasJourney = n?.journey !== undefined, hasScreen = n?.screen !== undefined;
302
+ if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) ||
303
+ (n.verdict !== undefined && !['approve', 'change'].includes(n.verdict)) ||
304
+ (n.note !== undefined && (!isText(n.note) || [...n.note].length > NOTE_MAX)) || ((n.verdict ?? 'change') === 'change' && !n.note?.trim())) {
305
+ throw bad('each note needs both a known journey and screen, or neither for a design-level note; verdict must be approve or change, with text required on change (500 characters or fewer)');
306
+ }
307
+ }
308
+ if (new Set(notes.map(n => n.journey === undefined ? '' : `${n.journey}/${n.screen}`)).size !== notes.length)
309
+ throw bad('notes must name each screen once, or contain one design-level note');
296
310
  const options = d.options ?? [];
297
- if (options.length ? !options.some(o => o.value === pick) : pick !== undefined)
298
- throw bad('pick must be one listed option, or omitted when no options were offered');
299
- const answer = { key: d.key, answer: verdict, label: verdict === 'approve' ? 'Approved' : 'Changes requested', close: d.close ?? 'done', at: now(), notes, ...(pick !== undefined && { pick: pick }) };
311
+ if (pick !== undefined ? !options.some(o => o.value === pick) : verdict !== undefined && options.length > 0)
312
+ throw bad('pick must be one listed option, and a design verdict needs one when options were offered');
300
313
  const current = readAnswers(decisionsPath)[d.key];
314
+ const decided = current?.answer === 'approve' || current?.answer === 'change' ? current.answer : undefined;
315
+ const kept = (verdict ?? (decided === 'approve' && requested === true ? 'change' : decided ?? SCREENS_ONLY));
316
+ const keptPick = (pick ?? (options.some(o => o.value === current?.pick) ? current?.pick : undefined));
317
+ const answer = { key: d.key, answer: kept, label: DESIGN_LABEL[kept], close: d.close ?? 'done', at: now(), notes, ...(keptPick !== undefined && { pick: keptPick }) };
301
318
  if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
302
319
  return;
303
320
  appendLine(answersPath(decisionsPath), answer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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,43 @@
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
+ .design-viewer { position: fixed; inset: 0; width: 100%; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: var(--bg); color: var(--text); }
363
+ .design-viewer[open] { display: flex; flex-direction: column; }
364
+ .design-viewer header { display: flex; gap: 1rem; align-items: center; padding: 0.75rem max(1rem, env(safe-area-inset-right)) 0.75rem max(1rem, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); }
365
+ .design-viewer header > div { flex: 1; min-width: 0; max-height: 22dvh; overflow: auto; }
366
+ .design-viewer h2, .design-viewer p { margin: 0; }
367
+ .design-viewer h2 { font-size: 1.25rem; line-height: 1.2; }
368
+ .design-viewer .meta { font-size: 1rem; }
369
+ .design-viewer-panel button { padding: 0 0.75rem; font-size: 1rem; white-space: nowrap; }
370
+ .design-viewer button, .design-viewer select { min-height: 2.75rem; }
371
+ .design-viewer-body { display: grid; grid-template-rows: minmax(0, 1fr) auto; flex: 1; min-height: 0; }
372
+ .design-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--sunk); touch-action: pan-y; }
373
+ .design-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
374
+ .design-stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--surface); }
375
+ .design-viewer-panel { overflow: auto; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)); background: var(--surface); }
376
+ .design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
377
+ .design-viewer-panel .foot > * { flex: 1; }
378
+ .design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
379
+ .design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
380
+ .design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
381
+ @media (min-width: 60rem) { .design-viewer header h2 { font-size: 2.125rem; } .design-viewer-body { grid-template: minmax(0, 1fr) / minmax(0, 1fr) 22.5rem; } .design-viewer-panel { padding: 1.25rem; } }
382
+ @media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }
383
+ .needs .foot .open-link { min-height: 2.75rem; padding: 0 1rem; background: var(--action); color: var(--on-accent); text-align: center; }
343
384
  /* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
344
385
  .gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
345
386
  .gone-asks .rows { padding: 0; } .gone-asks li { justify-content: space-between; gap: 0.75rem; min-height: 2.75rem; }
@@ -819,6 +860,7 @@
819
860
 
820
861
  /** A mock or view is served from inside its own folder, so the files beside it load by their relative names. */
821
862
  const mockUrl = (key, value, path) => `${state.mockBase}${key}~${value}/${encodeURIComponent(path.split('?')[0].split('/').pop())}`;
863
+ const designUrl = (d, path) => `${state.mockBase.replace('/mock/', '/design/')}${d.key}/${encodeURIComponent(path)}`;
822
864
  /** 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
865
  function rung(d) {
824
866
  if (!d.view) return '';
@@ -920,11 +962,12 @@
920
962
  ...[...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
963
  }
922
964
 
923
- const AREAS = { waiting: 'Waiting on you', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
965
+ const AREAS = { waiting: 'Waiting on you', designs: 'Designs', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
924
966
  // The sidebar and the tab bar name the areas short enough for one line.
925
967
  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.' };
968
+ 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
969
  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',
970
+ designs: 'M3 3h18v18H3zM3 9h18M9 21V9',
928
971
  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
972
  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
973
  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',
@@ -933,7 +976,8 @@
933
976
  you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
934
977
  // The drawings are fixed strings from this page, never text from the board file.
935
978
  const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
936
- const isAnswered = (d) => Object.hasOwn(state.answers, d.key);
979
+ // A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
980
+ const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
937
981
  /** Each item lives in one area: answered items, then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks.
938
982
  * A plan has no options but waits for its send-back. */
939
983
  const area = (d) => {
@@ -950,7 +994,7 @@
950
994
  const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
951
995
  const pic = (d) => {
952
996
  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)
997
+ 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
998
  : m ? mockUrl(d.key, m.value, m.path) : d.evidenceCard?.media.find((x) => x.type.startsWith('image/'))?.url;
955
999
  };
956
1000
  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 +1003,7 @@
959
1003
  const answeredToday = a === 'answered' && items.filter((d) => new Date(state.answers[d.key].at).toDateString() === new Date().toDateString()).length;
960
1004
  // A short count line that never cuts off on a phone. Waiting gives the oldest ask's time when its items carry one.
961
1005
  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`,
1006
+ 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
1007
  reports: `${items.length} to read`, answered: `${answeredToday} today` }[a];
964
1008
  const thumbs = a === 'evidence' ? items.map(pic).filter(Boolean).slice(0, 3).map((u) => el('img', { src: src(u), alt: '' })) : [];
965
1009
  const marks = thumbs.length ? thumbs : a === 'waiting' ? [items.length ? icon(a) : '']
@@ -1184,23 +1228,150 @@
1184
1228
  return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
1185
1229
  }
1186
1230
 
1231
+ let designSession, boardStale = false;
1232
+ addEventListener('message', (e) => {
1233
+ const viewer = document.querySelector('.design-viewer[open]');
1234
+ if (viewer && e.source === viewer.querySelector('iframe')?.contentWindow && e.data?.['sign-off'] === 'viewer-nav') viewer.dispatchEvent(new KeyboardEvent('keydown', { key: e.data.key }));
1235
+ });
1236
+ function designReview(d) {
1237
+ const saved = state.answers[d.key], screens = (d.journeys ?? []).flatMap((journey) => journey.screens.map((screen) => ({ journey, screen }))), savedNote = saved?.notes?.[0];
1238
+ const screenKey = (target) => target && `${target.journey.id}/${target.screen.id}`, screenFor = (key) => screens.find((target) => screenKey(target) === key);
1239
+ const note = el('textarea', { maxlength: '500', placeholder: 'What should change? (optional for approval)' });
1240
+ const pick = el('select', { 'aria-label': 'Pick a direction' }, el('option', { value: '' }, 'Choose a direction'));
1241
+ for (const option of d.options ?? []) pick.append(el('option', { value: option.value }, option.label));
1242
+ pick.value = saved?.pick ?? '';
1243
+ 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}`))) : '';
1244
+ if (designSession?.key !== d.key) designSession = { key: d.key, open: false, selected: '', drafts: new Map() };
1245
+ const session = designSession;
1246
+ const selectedKey = session.selected || (savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]));
1247
+ if (screenSelect) screenSelect.value = screenFor(selectedKey) ? selectedKey : screenKey(screens[0]);
1248
+ const target = () => screenSelect ? screenFor(screenSelect.value) : screens[0];
1249
+ const currentNote = () => state.answers[d.key]?.notes?.find((n) => n.journey === target()?.journey.id && n.screen === target()?.screen.id);
1250
+ const screenLine = () => (currentNote() ? (currentNote().verdict === 'approve' ? 'Screen approved' : 'Changes requested') : 'Not reviewed yet');
1251
+ note.value = session.drafts.get(screenKey(target())) ?? currentNote()?.note ?? '';
1252
+ note.oninput = () => session.drafts.set(screenKey(target()), note.value);
1253
+ const previewPath = () => {
1254
+ const views = target()?.screen.views, form = innerWidth < 960 ? 'phone' : 'computer', theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1255
+ return views?.[`${form}_${theme}`] || views?.[`${form}_${theme === 'dark' ? 'light' : 'dark'}`] || (views && Object.values(views)[0]) || d.preview;
1256
+ };
1257
+ 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}` });
1258
+ const preview = previewPath() ? el('div', { class: 'design-preview-window' }, previewElement(previewPath())) : '';
1259
+ const fullPreview = previewPath() ? el('a', { class: 'design-preview-full', href: src(designUrl(d, previewPath())), target: '_blank', rel: 'noopener noreferrer' }, 'Open full preview') : '';
1260
+ if (screenSelect) screenSelect.onchange = () => {
1261
+ const path = previewPath();
1262
+ if (preview) preview.replaceChildren(previewElement(path));
1263
+ if (fullPreview) fullPreview.href = src(designUrl(d, path));
1264
+ session.selected = screenKey(target());
1265
+ note.value = session.drafts.get(session.selected) ?? currentNote()?.note ?? '';
1266
+ };
1267
+ const status = el('p', { class: 'meta', role: 'status' }, saved ? saved.label : d.status === 'draft' ? 'Draft' : 'In review');
1268
+ const err = el('p', { class: 'err', role: 'alert' });
1269
+ let saving = false;
1270
+ const save = (verdict) => {
1271
+ const screen = session.open;
1272
+ if (!screen && d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
1273
+ // A screen save (the viewer) sends no design-wide verdict; a note carries the verdict of the button that saved it, on a screen or at design level.
1274
+ const selected = target(), text = note.value.trim(), writes = (selected && screen) || text, live = new Set(screens.map(screenKey));
1275
+ const notes = (state.answers[d.key]?.notes ?? []).filter((n) => (!n.journey || live.has(`${n.journey}/${n.screen}`)) && (n.journey !== selected?.journey.id || n.screen !== selected?.screen.id));
1276
+ if (writes) notes.push({ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), verdict, note: text });
1277
+ saving = true; approve.disabled = change.disabled = true;
1278
+ return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
1279
+ notes, ...(pick.value && { pick: pick.value }) }) }, () => session.drafts.delete(screenKey(selected)))
1280
+ .finally(() => { saving = false; approve.disabled = change.disabled = state.readonly || !!state.answers_only; if (session.open) screenStatus.textContent = screenLine(); });
1281
+ };
1282
+ const approve = el('button', { class: 'open-link' }, 'Approve'), change = el('button', {}, 'Change');
1283
+ approve.disabled = change.disabled = state.readonly || !!state.answers_only;
1284
+ approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
1285
+ 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}`; }); };
1286
+ const controls = el('div', {}, d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
1287
+ el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err);
1288
+ const open = el('button', { class: 'open-link', 'aria-haspopup': 'dialog', 'data-design': d.key }, 'Review full screen');
1289
+ const root = el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
1290
+ screens.length ? open : '', screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', controls,
1291
+ d.link ? el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer' }, 'Open original design') : '');
1292
+ if (!screens.length) return root;
1293
+ const heading = el('h2', { id: 'design-screen-title', dir: 'auto' }), count = el('p', { class: 'meta', 'aria-live': 'polite' });
1294
+ const stage = el('div', { class: 'design-stage' }), screenStatus = el('p', { class: 'meta', role: 'status' });
1295
+ const previous = el('button', { 'aria-label': 'Previous screen' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next screen' }, 'Next ›');
1296
+ const close = el('button', { 'aria-label': 'Close viewer' }, 'Close');
1297
+ const viewer = el('dialog', { class: 'design-viewer', 'aria-labelledby': heading.id }, el('header', {}, el('div', {}, count, heading), close),
1298
+ el('div', { class: 'design-viewer-body' }, stage, el('section', { class: 'design-viewer-panel', 'aria-label': 'Screen review' },
1299
+ el('div', { class: 'foot' }, previous, next), screenStatus)));
1300
+ const panel = viewer.querySelector('.design-viewer-panel');
1301
+ const draw = () => {
1302
+ const i = screens.indexOf(target());
1303
+ heading.textContent = `${target().journey.title} — ${target().screen.title}`;
1304
+ count.textContent = `${i + 1} of ${screens.length}`;
1305
+ previous.disabled = i === 0; next.disabled = i === screens.length - 1;
1306
+ stage.replaceChildren(previewElement(previewPath())); stage.scrollTop = 0;
1307
+ screenStatus.textContent = screenLine();
1308
+ approve.textContent = 'Approve screen'; change.textContent = 'Request change'; err.textContent = '';
1309
+ };
1310
+ const step = (delta) => {
1311
+ const i = screens.indexOf(target()) + delta;
1312
+ if (i < 0 || i >= screens.length || saving) return;
1313
+ session.drafts.set(screenKey(target()), note.value);
1314
+ if (screenSelect) { screenSelect.value = screenKey(screens[i]); screenSelect.onchange(); }
1315
+ draw();
1316
+ };
1317
+ previous.onclick = () => step(-1); next.onclick = () => step(1);
1318
+ const key = (e) => {
1319
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); viewer.close(); }
1320
+ else if (!e.target.closest?.('textarea, input, select') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight'].includes(e.key)) {
1321
+ e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1);
1322
+ }
1323
+ };
1324
+ viewer.onkeydown = key;
1325
+ let touch;
1326
+ stage.addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
1327
+ stage.addEventListener('touchend', (e) => {
1328
+ const end = e.changedTouches[0];
1329
+ if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) step(end.clientX < touch.clientX ? 1 : -1);
1330
+ touch = null;
1331
+ });
1332
+ const controlsNext = controls.nextSibling;
1333
+ open.onclick = () => {
1334
+ session.open = true; panel.append(controls); root.append(viewer); draw(); viewer.showModal();
1335
+ };
1336
+ close.onclick = () => viewer.close();
1337
+ viewer.onclose = () => {
1338
+ session.open = false;
1339
+ if (boardStale) {
1340
+ boardStale = false;
1341
+ render();
1342
+ document.querySelector(`[data-design="${CSS.escape(d.key)}"]`)?.focus();
1343
+ return;
1344
+ }
1345
+ root.insertBefore(controls, controlsNext); approve.textContent = 'Approve'; change.textContent = 'Change';
1346
+ open.focus();
1347
+ };
1348
+ return root;
1349
+ }
1350
+
1187
1351
  /** 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
1352
  * The frame is made again only for another page, so a redraw never resets a demo halfway through. */
1189
1353
  const reader = document.getElementById('reader');
1190
1354
  function readPage(d, from = 'reports') {
1191
1355
  document.body.classList.toggle('reading', !!d);
1192
- for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
1356
+ document.body.classList.toggle('design-reading', !!d && d.kind === 'design');
1357
+ const inlineDesign = !!d && d.kind === 'design' && matchMedia('(min-width: 60rem)').matches;
1358
+ reader.setAttribute('role', inlineDesign ? 'region' : 'dialog');
1359
+ if (inlineDesign) reader.removeAttribute('aria-modal'); else reader.setAttribute('aria-modal', 'true');
1360
+ document.querySelector('main').inert = !!d;
1361
+ document.getElementById('nav').inert = !!d && !inlineDesign;
1362
+ document.getElementById('tabs').inert = !!d;
1193
1363
  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) : '';
1364
+ const url = d?.kind === 'design' ? `design:${d.key}` : d ? src(mockUrl(d.key, '', d.view)) : '';
1365
+ const answer = d?.type === 'plan' || d?.kind === 'design' ? JSON.stringify(state.answers[d.key] ?? null) : '';
1196
1366
  const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
1197
1367
  if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
1198
1368
  Object.assign(reader.dataset, { answer, plan });
1199
1369
  Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
1200
1370
  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' })] : []));
1371
+ d.type === 'plan' ? planReview(d, url) : d.kind === 'design' ? designReview(d) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1202
1372
  }
1203
1373
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && !reader.hidden) location.hash = `#${reader.dataset.from}`; });
1374
+ addEventListener('resize', () => { if (!reader.hidden) readPage(state.decisions.find((d) => d.key === reader.dataset.key), reader.dataset.from); });
1204
1375
 
1205
1376
  /** 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
1377
  function reportGroups(items) {
@@ -1213,6 +1384,18 @@
1213
1384
  });
1214
1385
  }
1215
1386
 
1387
+ function designGroups(items) {
1388
+ if (!items.length) return el('p', { class: 'empty' }, EMPTY.designs);
1389
+ const groups = [...new Set(items.map((d) => d.group || ''))];
1390
+ return groups.flatMap((group) => [group ? el('div', { class: 'sec-h' }, el('h2', {}, group)) : '',
1391
+ el('div', { class: 'design-list' }, ...items.filter((d) => (d.group || '') === group).map((d) => {
1392
+ const saved = state.answers[d.key], status = saved?.answer === 'approve' ? 'Approved' : saved?.answer === 'change' ? 'Changes requested' : d.status === 'draft' ? 'Draft' : 'In review';
1393
+ const href = `#designs/${d.key}`, image = pic(d);
1394
+ 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)) : '',
1395
+ 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(' · '))));
1396
+ }))]);
1397
+ }
1398
+
1216
1399
  /** The asks dismissed from Waiting on you, folded under one row; Restore puts one back on the board. */
1217
1400
  function dismissed(items) {
1218
1401
  if (!canDismiss() || !items.length) return '';
@@ -1273,7 +1456,7 @@
1273
1456
  return el('div', { class: 'tri' }, queue, el('div', { class: 'focus' }, el('p', { class: 'fhead' }, `${i + 1} of ${n}`),
1274
1457
  el('div', { class: 'pbar', 'aria-hidden': 'true' }, el('i', { style: `width: ${((i + 1) / n) * 100}%` })),
1275
1458
  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))) : '',
1459
+ d.kind === 'design' || skip || dis ? el('div', { class: 'quiet' }, d.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${d.key}` }, 'Open designs') : skip || '', dis) : '', next ? el('div', { class: 'card upnext' }, el('p', { class: 'meta' }, 'Up next'), el('strong', { dir: 'auto' }, title(next))) : '',
1277
1460
  hint ? el('p', { class: 'meta hint' }, hint) : '', el('p', { class: 'meta keys' }, `Keys: ${keys}`), dismissed(hidden)));
1278
1461
  }
1279
1462
  // Triage keys at a desk: a number picks that answer (in the order shown), E skips, Z takes back the last tap.
@@ -1285,13 +1468,15 @@
1285
1468
  });
1286
1469
 
1287
1470
  function render() {
1288
- const by = { waiting: [], evidence: [], reports: [], answered: [], notes: [] };
1471
+ if (designSession?.open) return (boardStale = true);
1472
+ const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
1289
1473
  // The board file appends new items at the end, so each list reads newest first; answers sort by their time.
1290
1474
  // One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
1291
1475
  const gone = new Set(state.dismissed ?? []), open = state.decisions.filter((d) => !gone.has(d.key) && !isAnswered(d));
1292
1476
  const openKeys = new Set(open.map((d) => d.key));
1293
1477
  const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
1294
1478
  for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key)) by[area(d)].push(d);
1479
+ by.designs = [...state.decisions].reverse().filter((d) => d.kind === 'design' && !gone.has(d.key));
1295
1480
  // 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
1481
  let latest = -Infinity;
1297
1482
  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 +1488,7 @@
1303
1488
  const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
1304
1489
  const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
1305
1490
  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) ?? [];
1491
+ const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1307
1492
  const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (at ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
1308
1493
  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
1494
  document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
@@ -1318,14 +1503,14 @@
1318
1503
  : at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
1319
1504
  const first = by.waiting[0], more = by.waiting.length > 1 ? `Answer all ${by.waiting.length} in Triage →` : 'Open in Triage →';
1320
1505
  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));
1506
+ 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
1507
  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]))) : '',
1508
+ : 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
1509
  ...(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
1510
  : 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
1511
  el('div', { class: 'pair' }, needs, today(by.answered)), ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()]),
1327
1512
  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);
1513
+ 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
1514
  if (FROZEN) freeze();
1330
1515
  }
1331
1516
  function freeze() {
@@ -1341,7 +1526,7 @@
1341
1526
  if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
1342
1527
  if (location.hash === '#waiting') waitType = 'all';
1343
1528
  render();
1344
- if (!reader.hidden) return reader.querySelector('h1').focus();
1529
+ if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
1345
1530
  const item = (jump || back) && document.getElementById(`item-${jump || back}`);
1346
1531
  jump = null;
1347
1532
  item ? item.scrollIntoView() : scrollTo(0, 0);
package/web/point.js CHANGED
@@ -17,7 +17,17 @@
17
17
  };
18
18
  window.signOffFind = find;
19
19
  if (parent === window) return;
20
- let on = false;
20
+ let on = false, touch;
21
+ const navigate = (key) => parent.postMessage({ 'sign-off': 'viewer-nav', key }, '*');
22
+ addEventListener('keydown', (e) => {
23
+ if (!e.target.closest?.('textarea, input, select, [contenteditable]') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight', 'Escape'].includes(e.key)) navigate(e.key);
24
+ });
25
+ addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
26
+ addEventListener('touchend', (e) => {
27
+ const end = e.changedTouches[0];
28
+ if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) navigate(end.clientX < touch.clientX ? 'ArrowRight' : 'ArrowLeft');
29
+ touch = null;
30
+ }, { passive: true });
21
31
  const ref = (e) => {
22
32
  if (e === document.documentElement) return 'html';
23
33
  const path = [];