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