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 +3 -1
- package/dist/cli.js +115 -43
- package/dist/contract.js +1 -1
- package/dist/store.js +29 -12
- package/package.json +1 -1
- package/web/index.html +203 -18
- package/web/point.js +11 -1
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.
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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) :
|
|
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
|
|
992
|
-
items = [...feed, ...(old?.items ?? []).filter((i) => i?.filer !== filer && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
/**
|
|
284
|
-
export
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 (
|
|
298
|
-
throw bad('pick must be one listed option,
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 = [];
|