sign-off-axi 0.4.2 → 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 +169 -42
- package/dist/contract.js +52 -1
- package/dist/store.js +42 -10
- package/fixtures/contract/README.md +2 -2
- package/fixtures/contract/design.v1/design/inbox.png +0 -0
- package/fixtures/contract/design.v1/design/preview.png +0 -0
- package/fixtures/contract/design.v1/design/welcome-dark.png +0 -0
- package/fixtures/contract/design.v1/design/welcome.html +1 -0
- package/fixtures/contract/design.v1/design/welcome.png +0 -0
- package/fixtures/contract/design.v1/invalid-journeys-missing.json +1 -0
- package/fixtures/contract/design.v1/invalid-preview-escape.json +1 -0
- package/fixtures/contract/design.v1/invalid-unknown-field.json +1 -0
- package/fixtures/contract/design.v1/valid-basic.json +1 -0
- package/fixtures/contract/design.v1/valid-choice.json +1 -0
- package/package.json +1 -1
- package/web/index.html +142 -32
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, 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, 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';
|
|
@@ -86,7 +86,7 @@ examples:
|
|
|
86
86
|
`,
|
|
87
87
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
88
88
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
89
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and
|
|
89
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding, plan and design at schema 1, and report at schema 2.
|
|
90
90
|
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
91
91
|
A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
|
|
92
92
|
A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
|
|
@@ -231,6 +231,8 @@ examples:
|
|
|
231
231
|
sign-off-axi post --kind gate --from gate.json --filer done-done
|
|
232
232
|
sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
|
|
233
233
|
sign-off-axi post --kind plan --from plan.json --filer agent-41
|
|
234
|
+
sign-off-axi post --kind design --from design.json --filer agent-41
|
|
235
|
+
A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
|
|
234
236
|
sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
|
|
235
237
|
sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
|
|
236
238
|
sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
|
|
@@ -322,7 +324,7 @@ function fits(title, key) {
|
|
|
322
324
|
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
|
|
323
325
|
const MANY = ['option', 'outcome', 'merge'];
|
|
324
326
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
325
|
-
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan'];
|
|
327
|
+
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
326
328
|
/** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
|
|
327
329
|
const asSent = ({ filed: _filed, ...rest }) => rest;
|
|
328
330
|
/** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
|
|
@@ -367,12 +369,38 @@ function contractItems(items, from, file, replace = false) {
|
|
|
367
369
|
checkMessage(check);
|
|
368
370
|
if (check.kind === 'report' || check.kind === 'plan')
|
|
369
371
|
reportPage(check, dirname(file));
|
|
372
|
+
if (check.kind === 'design')
|
|
373
|
+
designFiles(check, dirname(file));
|
|
370
374
|
}
|
|
371
375
|
catch (e) {
|
|
372
376
|
throw e instanceof ContractError ? fail(`${from}: items[${i}]: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
373
377
|
}
|
|
374
378
|
});
|
|
375
379
|
}
|
|
380
|
+
function designFiles(m, base) {
|
|
381
|
+
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap((s) => Object.values(s.views)))];
|
|
382
|
+
for (const path of paths) {
|
|
383
|
+
let actual;
|
|
384
|
+
try {
|
|
385
|
+
actual = realpathSync(resolve(base, path));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
throw new ContractError(`design path ${JSON.stringify(path)} is not there`, `Put ${path} in the board folder or fix its path`);
|
|
389
|
+
}
|
|
390
|
+
const rel = relative(realpathSync(base), actual);
|
|
391
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
392
|
+
throw new ContractError(`design path ${JSON.stringify(path)} leads outside the board folder`, 'Copy every design view into the board folder');
|
|
393
|
+
const info = statSync(actual);
|
|
394
|
+
if (!info.isFile() || info.size > 5_000_000)
|
|
395
|
+
throw new ContractError(`design path ${JSON.stringify(path)} must be a file of 5 MB or less`, 'Shrink the design view');
|
|
396
|
+
try {
|
|
397
|
+
accessSync(actual, constants.R_OK);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
376
404
|
const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
|
|
377
405
|
/** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
|
|
378
406
|
function parse(cmd, args, flags) {
|
|
@@ -456,7 +484,7 @@ function filerOf(values, session = false) {
|
|
|
456
484
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
457
485
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
458
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). */
|
|
459
|
-
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan');
|
|
487
|
+
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
460
488
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
461
489
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
462
490
|
function board(file, say = warn) {
|
|
@@ -512,7 +540,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
512
540
|
const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
|
|
513
541
|
if (values.json) {
|
|
514
542
|
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
515
|
-
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions }) => [key, { answer, label, close, at, note, comments, notes, decisions, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
543
|
+
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
516
544
|
for (const r of rounds)
|
|
517
545
|
if (!out[r.key])
|
|
518
546
|
out[r.key] = { rounds: asked(r.key) };
|
|
@@ -520,7 +548,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
520
548
|
}
|
|
521
549
|
const notes = done.some((a) => a.note), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
|
|
522
550
|
const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
|
|
523
|
-
...sections.map((
|
|
551
|
+
...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
|
|
524
552
|
...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
|
|
525
553
|
]);
|
|
526
554
|
return toon({
|
|
@@ -555,7 +583,7 @@ function eventLines(file, rows, answers, rounds) {
|
|
|
555
583
|
const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
|
|
556
584
|
const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
|
|
557
585
|
const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
|
|
558
|
-
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...tail(key, comments) } })),
|
|
586
|
+
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...tail(key, comments) } })),
|
|
559
587
|
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
560
588
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
561
589
|
}
|
|
@@ -751,27 +779,27 @@ function post(args, cmd = 'post') {
|
|
|
751
779
|
const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
|
|
752
780
|
media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
753
781
|
...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
|
|
754
|
-
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,
|
|
755
783
|
...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
|
|
756
784
|
const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
757
785
|
if (values.kind !== undefined) {
|
|
758
786
|
const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
|
|
759
787
|
if (odd)
|
|
760
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');
|
|
761
|
-
return postKind(file, on, values);
|
|
789
|
+
return postKind(file, named, on, values);
|
|
762
790
|
}
|
|
763
791
|
if (values.from !== undefined && given.length)
|
|
764
792
|
throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
|
|
765
793
|
if (values.replace)
|
|
766
|
-
return replace(file, on, values.from, filerOf(values));
|
|
794
|
+
return replace(file, named, on, values.from, filerOf(values));
|
|
767
795
|
const filer = filerOf(values, true);
|
|
768
796
|
const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
|
|
769
797
|
if (told && type !== 'decision')
|
|
770
798
|
throw fail(`--${told} needs --type decision`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
771
799
|
if (type === 'report')
|
|
772
|
-
return postReport(file, on, values);
|
|
800
|
+
return postReport(file, named, on, values);
|
|
773
801
|
if (type === 'signin')
|
|
774
|
-
return postSignin(file, on, values, filer);
|
|
802
|
+
return postSignin(file, named, on, values, filer);
|
|
775
803
|
const odd = SIGNIN_FIELDS.find((f) => values[f] !== undefined);
|
|
776
804
|
if (odd)
|
|
777
805
|
throw fail(`--${odd} needs --type signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
@@ -784,7 +812,7 @@ function post(args, cmd = 'post') {
|
|
|
784
812
|
if (!['decision', 'design', 'evidence', 'diagram', 'interactive', 'explainer'].includes(type))
|
|
785
813
|
throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
786
814
|
if (type === 'evidence' || type === 'explainer')
|
|
787
|
-
return postMedia(file, on, values, type, filer);
|
|
815
|
+
return postMedia(file, named, on, values, type, filer);
|
|
788
816
|
if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
|
|
789
817
|
throw fail('--media, --link and --chapter need --type evidence or explainer', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
790
818
|
if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
|
|
@@ -793,7 +821,7 @@ function post(args, cmd = 'post') {
|
|
|
793
821
|
if (view !== (values.view !== undefined))
|
|
794
822
|
throw fail(view ? `--type ${type} needs --view <path>` : '--view needs --type diagram or interactive', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
795
823
|
if (type === 'decision')
|
|
796
|
-
return storeIncoming(file, on, buildIncoming(values, {}, filer, file), filer);
|
|
824
|
+
return storeIncoming(file, named, on, buildIncoming(values, {}, filer, file), filer);
|
|
797
825
|
const root = resolve(dirname(file));
|
|
798
826
|
if (view) {
|
|
799
827
|
// The same real-path check the board serves with, so a post that passes always renders.
|
|
@@ -816,7 +844,7 @@ function post(args, cmd = 'post') {
|
|
|
816
844
|
}
|
|
817
845
|
if (!size.isFile() || size.size > MOCK_BYTES)
|
|
818
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');
|
|
819
|
-
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);
|
|
820
848
|
}
|
|
821
849
|
const mocks = values.mock?.map((m) => {
|
|
822
850
|
const at = m.indexOf('=');
|
|
@@ -840,7 +868,7 @@ function post(args, cmd = 'post') {
|
|
|
840
868
|
}
|
|
841
869
|
return { value: m.slice(0, at), path };
|
|
842
870
|
});
|
|
843
|
-
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);
|
|
844
872
|
}
|
|
845
873
|
const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
|
|
846
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';
|
|
@@ -855,7 +883,7 @@ function expiresAt(value) {
|
|
|
855
883
|
}
|
|
856
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.
|
|
857
885
|
* The link can hold a one-time code, so no output prints it. */
|
|
858
|
-
function postSignin(file, on, values, filer) {
|
|
886
|
+
function postSignin(file, named, on, values, filer) {
|
|
859
887
|
const odd = ['title', 'about', 'detail', 'option', 'recommend', 'default', 'close', 'tradeoff', 'mock', 'media', 'view', 'preview', 'chapter'].find((f) => values[f] !== undefined);
|
|
860
888
|
if (odd)
|
|
861
889
|
throw fail(`--${odd} does not apply to --type signin`, 'VALIDATION_ERROR', SIGNIN_EXAMPLE);
|
|
@@ -893,11 +921,13 @@ function postSignin(file, on, values, filer) {
|
|
|
893
921
|
items[at] = next;
|
|
894
922
|
else
|
|
895
923
|
items.push(next);
|
|
924
|
+
const before = waitingKeys(file);
|
|
896
925
|
writeItems(file, items);
|
|
897
926
|
// A new link is a new sign-in: the card waits again, also when it was done or dismissed.
|
|
898
927
|
appendAnswer(file, d, CLEARED);
|
|
899
928
|
if (had)
|
|
900
929
|
appendDismissed(file, [d.key], false);
|
|
930
|
+
notifyEntered(file, named, before);
|
|
901
931
|
}
|
|
902
932
|
return toon({ board: tilde(file), posted: [{ key: d.key, status, service: d.title, ...(expires && { expires }) }] }, [
|
|
903
933
|
...(status === 'already done' ? [] : [`Run \`sign-off-axi open${on}\` so the user can sign in`]),
|
|
@@ -924,7 +954,7 @@ function signin(args) {
|
|
|
924
954
|
}
|
|
925
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.
|
|
926
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. */
|
|
927
|
-
function replace(file, on, from, filer) {
|
|
957
|
+
function replace(file, named, on, from, filer) {
|
|
928
958
|
if (from === undefined)
|
|
929
959
|
throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
|
|
930
960
|
const source = resolve(from), fed = readItems(source);
|
|
@@ -960,8 +990,8 @@ function replace(file, on, from, filer) {
|
|
|
960
990
|
let items = feed, extra = fed.extra, resulting = incoming;
|
|
961
991
|
if (filer) {
|
|
962
992
|
// The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
|
|
963
|
-
const
|
|
964
|
-
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))];
|
|
965
995
|
extra = { ...old?.extra, ...fed.extra };
|
|
966
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.
|
|
967
997
|
const check = `${file}.${process.pid}.check`;
|
|
@@ -979,14 +1009,17 @@ function replace(file, on, from, filer) {
|
|
|
979
1009
|
checkMerges(incoming, resulting, current);
|
|
980
1010
|
// Written only when it changed, so a feeder that runs every few seconds leaves the file alone.
|
|
981
1011
|
const changed = JSON.stringify(old && { ...old.extra, schema: old.schema, items: old.items }) !== JSON.stringify({ ...extra, schema: 2, items });
|
|
982
|
-
if (changed)
|
|
1012
|
+
if (changed) {
|
|
1013
|
+
const before = waitingKeys(file);
|
|
983
1014
|
writeItems(file, items, extra);
|
|
1015
|
+
notifyEntered(file, named, before);
|
|
1016
|
+
}
|
|
984
1017
|
return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
|
|
985
1018
|
});
|
|
986
1019
|
}
|
|
987
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.
|
|
988
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`. */
|
|
989
|
-
function postMedia(file, on, values, type, filer, message) {
|
|
1022
|
+
function postMedia(file, named, on, values, type, filer, message) {
|
|
990
1023
|
fits(values.title);
|
|
991
1024
|
const explainer = type === 'explainer';
|
|
992
1025
|
const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
|
|
@@ -1087,14 +1120,16 @@ function postMedia(file, on, values, type, filer, message) {
|
|
|
1087
1120
|
finally {
|
|
1088
1121
|
rmSync(check, { force: true });
|
|
1089
1122
|
}
|
|
1123
|
+
const before = waitingKeys(file);
|
|
1090
1124
|
writeItems(file, items);
|
|
1125
|
+
notifyEntered(file, named, before);
|
|
1091
1126
|
if (explainer)
|
|
1092
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)]);
|
|
1093
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)]);
|
|
1094
1129
|
});
|
|
1095
1130
|
}
|
|
1096
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. */
|
|
1097
|
-
function postReport(file, on, values) {
|
|
1132
|
+
function postReport(file, named, on, values) {
|
|
1098
1133
|
const odd = ['option', 'recommend', 'default', 'close', 'detail', 'mock', 'tradeoff', 'media', 'chapter'].find((f) => values[f] !== undefined);
|
|
1099
1134
|
if (odd)
|
|
1100
1135
|
throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
@@ -1106,11 +1141,11 @@ function postReport(file, on, values) {
|
|
|
1106
1141
|
// Posting the same report again is a no-op, so it keeps the time it was first filed.
|
|
1107
1142
|
const filed = (had?.kind === 'report' && had.filed) || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
1108
1143
|
const { title, about, view, preview, link } = values;
|
|
1109
|
-
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 });
|
|
1110
1145
|
}
|
|
1111
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.
|
|
1112
1147
|
* `built` is a message post --type report made from its flags. */
|
|
1113
|
-
async function postKind(file, on, values, built) {
|
|
1148
|
+
async function postKind(file, named, on, values, built) {
|
|
1114
1149
|
const { kind, from } = values, example = 'sign-off-axi post --kind ask --from ask.json';
|
|
1115
1150
|
const name = built ? '--type report' : from === '-' ? 'stdin' : from;
|
|
1116
1151
|
let bytes, m = built;
|
|
@@ -1137,6 +1172,8 @@ async function postKind(file, on, values, built) {
|
|
|
1137
1172
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1138
1173
|
if (m.kind === 'report' || m.kind === 'plan')
|
|
1139
1174
|
reportPage(m, dirname(file));
|
|
1175
|
+
if (m.kind === 'design')
|
|
1176
|
+
designFiles(m, dirname(file));
|
|
1140
1177
|
}
|
|
1141
1178
|
catch (e) {
|
|
1142
1179
|
throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
@@ -1147,9 +1184,9 @@ async function postKind(file, on, values, built) {
|
|
|
1147
1184
|
const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
|
|
1148
1185
|
try {
|
|
1149
1186
|
if (m.kind === 'evidence')
|
|
1150
|
-
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 }) });
|
|
1151
1188
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
|
|
1152
|
-
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);
|
|
1153
1190
|
}
|
|
1154
1191
|
catch (e) {
|
|
1155
1192
|
if (!(e instanceof AxiError && e.code === 'CONFLICT'))
|
|
@@ -1225,11 +1262,13 @@ const outcomesOf = (values) => {
|
|
|
1225
1262
|
/** Whoever lists the choices names the one they would pick: the board puts it first and marks it. A plain yes/no may leave it out,
|
|
1226
1263
|
* and a program feeding a whole board with --replace is not held to it. */
|
|
1227
1264
|
function recommended(items, flags) {
|
|
1228
|
-
const d = items.find((i) => i?.options?.length && i.recommend_value === undefined), values = d?.options.map((o) => o.value).join(', ');
|
|
1265
|
+
const d = items.find((i) => i?.options?.length && (flags || i.type !== 'design' && i.kind !== 'design' ? i.recommend_value === undefined : i.recommend === undefined)), values = d?.options.map((o) => o.value).join(', ');
|
|
1229
1266
|
if (d && flags)
|
|
1230
1267
|
throw fail('--recommend is required when you list options', 'VALIDATION_ERROR', `Add --recommend <value>, the option you would pick: one of ${values}`);
|
|
1231
|
-
if (d)
|
|
1232
|
-
|
|
1268
|
+
if (d) {
|
|
1269
|
+
const field = !flags && (d.type === 'design' || d.kind === 'design') ? 'recommend' : 'recommend_value';
|
|
1270
|
+
throw fail(`items[${items.indexOf(d)}] (key ${d.key}) lists options but no ${field}`, 'VALIDATION_ERROR', `Add "${field}": "<value>", the option you would pick: one of ${values}`);
|
|
1271
|
+
}
|
|
1233
1272
|
}
|
|
1234
1273
|
/** Flags are validated by the same loader as a file: write them as a one-decision file first. Items are checked against the board they go to, so its answers_only or readonly holds. */
|
|
1235
1274
|
function buildIncoming(values, extra, filer, file) {
|
|
@@ -1300,7 +1339,7 @@ function checkMerges(incoming, resulting, current) {
|
|
|
1300
1339
|
}
|
|
1301
1340
|
}
|
|
1302
1341
|
/** exact: a contract post, where a key takes only the same message again, filer and filed included (contract section 4). */
|
|
1303
|
-
function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
|
|
1342
|
+
function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
|
|
1304
1343
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
1305
1344
|
return locked(`${file}.lock`, () => {
|
|
1306
1345
|
const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
|
|
@@ -1330,8 +1369,11 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
|
|
|
1330
1369
|
}
|
|
1331
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 };
|
|
1332
1371
|
});
|
|
1333
|
-
if (raised.some((r) => r.status === 'raised'))
|
|
1372
|
+
if (raised.some((r) => r.status === 'raised')) {
|
|
1373
|
+
const before = waitingKeys(file);
|
|
1334
1374
|
writeItems(file, items);
|
|
1375
|
+
notifyEntered(file, named, before);
|
|
1376
|
+
}
|
|
1335
1377
|
const key = raised.length === 1 ? ` --key ${raised[0].key}` : '';
|
|
1336
1378
|
// Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
|
|
1337
1379
|
const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered'));
|
|
@@ -1432,7 +1474,7 @@ async function running(file) {
|
|
|
1432
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. */
|
|
1433
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;
|
|
1434
1476
|
const privateBind = (host) => isIP(host) > 0 && PRIVATE.test(host);
|
|
1435
|
-
const loopback = (host) => /^127\./.test(host) || host === '::1';
|
|
1477
|
+
const loopback = (host) => isIP(host) > 0 && (/^127\./.test(host) || host === '::1');
|
|
1436
1478
|
const urlHost = (host) => (isIP(host) === 6 ? `[${host}]` : host);
|
|
1437
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. */
|
|
1438
1480
|
function setupOf(named) {
|
|
@@ -1548,14 +1590,26 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
|
|
|
1548
1590
|
.some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
|
|
1549
1591
|
const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
|
|
1550
1592
|
const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
|
|
1551
|
-
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';
|
|
1552
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
|
|
1553
1595
|
* (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
|
|
1554
1596
|
function readSetup(so) {
|
|
1555
|
-
const config = {}, problems = [], hosts = [];
|
|
1556
|
-
let onAnswer = [];
|
|
1597
|
+
const config = {}, problems = [], hosts = [], notify = {};
|
|
1598
|
+
let onAnswer = [], notifyBlock = false, inNotify = false;
|
|
1557
1599
|
readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
|
|
1558
|
-
|
|
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);
|
|
1559
1613
|
// hosts: private DNS names that reach the bind address (a mesh VPN name), let in besides it; commas or spaces between them.
|
|
1560
1614
|
const names = /^hosts\s*:(.*)$/i.exec(line)?.[1].split(/[\s,]+/).filter(Boolean).map((h) => h.toLowerCase().replace(/\.$/, ''));
|
|
1561
1615
|
for (const h of names ?? []) {
|
|
@@ -1583,7 +1637,50 @@ function readSetup(so) {
|
|
|
1583
1637
|
const bind = config.bind ?? '127.0.0.1';
|
|
1584
1638
|
if (!privateBind(bind))
|
|
1585
1639
|
problems.push(`bind ${bind} is not 127.0.0.1 or a private address`);
|
|
1586
|
-
|
|
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();
|
|
1587
1684
|
}
|
|
1588
1685
|
/** --port, else the port in a config.yaml beside the board (init writes one), else 4410. */
|
|
1589
1686
|
const boardPort = (named, given) => portOf(given ?? String(setupOf(named)?.port ?? 4410));
|
|
@@ -1736,6 +1833,8 @@ async function doctor(args) {
|
|
|
1736
1833
|
check('config', `board ${basename(board)}, port ${port}`);
|
|
1737
1834
|
else
|
|
1738
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`);
|
|
1739
1838
|
// A hosts: name is only of use when it leads to the bind address.
|
|
1740
1839
|
const astray = [];
|
|
1741
1840
|
for (const h of setup.hosts) {
|
|
@@ -1989,7 +2088,7 @@ const state = () => {
|
|
|
1989
2088
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
1990
2089
|
// Who filed an item stays in the file: the page only learns that its filer took the answer.
|
|
1991
2090
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
1992
|
-
decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
2091
|
+
decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
1993
2092
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
1994
2093
|
readonly: readOnly(),
|
|
1995
2094
|
answers_only: answersOnly(),
|
|
@@ -2094,6 +2193,23 @@ function evidenceCard(link) {
|
|
|
2094
2193
|
}
|
|
2095
2194
|
const MESSAGE_MAX = 2000;
|
|
2096
2195
|
async function route(req) {
|
|
2196
|
+
if (req.method === 'GET' && req.url?.startsWith('/design/')) {
|
|
2197
|
+
const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
|
|
2198
|
+
const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
|
|
2199
|
+
const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
|
|
2200
|
+
const type = assetTypes[extname(path).toLowerCase()];
|
|
2201
|
+
try {
|
|
2202
|
+
if (!allowed || !type)
|
|
2203
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2204
|
+
const actual = under(dirname(file), path);
|
|
2205
|
+
if (statSync(actual).size > MOCK_BYTES)
|
|
2206
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2207
|
+
return [200, { media: readFileSync(actual), type, point: type === 'text/html' }];
|
|
2208
|
+
}
|
|
2209
|
+
catch {
|
|
2210
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2097
2213
|
if (req.method === 'GET' && req.url?.startsWith('/mock/')) {
|
|
2098
2214
|
// /mock/<pass>/<key>~<value>/<file>: that option's design mock, or with no value the item's own view (a diagram or interactive page),
|
|
2099
2215
|
// and the files beside it in its own folder, so a mock page's relative css, scripts and images load.
|
|
@@ -2243,7 +2359,7 @@ async function route(req) {
|
|
|
2243
2359
|
appendRound(file, key, note.trim(), comments);
|
|
2244
2360
|
return [200, state()];
|
|
2245
2361
|
}
|
|
2246
|
-
const { key, answer, note = '', notes, decisions } = JSON.parse(body) ?? {};
|
|
2362
|
+
const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
|
|
2247
2363
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
2248
2364
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
2249
2365
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
@@ -2263,10 +2379,21 @@ async function route(req) {
|
|
|
2263
2379
|
answered(appendAnswer(file, { key, close: 'done' }, answer === REPLY.value ? REPLY : { value: answer, label: answer }, note.trim()));
|
|
2264
2380
|
return [200, state()];
|
|
2265
2381
|
}
|
|
2382
|
+
if (d.kind === 'design') {
|
|
2383
|
+
try {
|
|
2384
|
+
answered(appendDesignAnswer(file, d, verdict, notes, pick), d);
|
|
2385
|
+
}
|
|
2386
|
+
catch (e) {
|
|
2387
|
+
if (e instanceof InputError)
|
|
2388
|
+
return [400, { error: e.message }];
|
|
2389
|
+
throw e;
|
|
2390
|
+
}
|
|
2391
|
+
return [200, state()];
|
|
2392
|
+
}
|
|
2266
2393
|
// A plan's answer is its one send-back: every section's verdict and note, and a pick for each decision.
|
|
2267
2394
|
if (d.type === 'plan') {
|
|
2268
2395
|
try {
|
|
2269
|
-
answered(appendSendBack(file, d, notes, decisions), d);
|
|
2396
|
+
answered(appendSendBack(file, d, notes, decisions, note), d);
|
|
2270
2397
|
}
|
|
2271
2398
|
catch (e) {
|
|
2272
2399
|
if (e instanceof InputError)
|
|
@@ -2392,7 +2519,7 @@ const server = createServer(async (req, res) => {
|
|
|
2392
2519
|
// Beyond loopback every request carries the token: once in the link, then in a cookie the link sets.
|
|
2393
2520
|
// The file is read on every request, so deleting or replacing it shuts old links out at once.
|
|
2394
2521
|
const token = tokenOf(file), cookie = `sign-off-${bound}`, given = req.url?.startsWith('/?') ? new URL(req.url, 'http://x').searchParams.get('token') : null;
|
|
2395
|
-
const pass = /^\/mock\/([^/?]*)\//.exec(req.url ?? '')?.[1], want = Buffer.from(pass === undefined ? token : mockPass(token));
|
|
2522
|
+
const pass = /^\/(?:mock|design)\/([^/?]*)\//.exec(req.url ?? '')?.[1], want = Buffer.from(pass === undefined ? token : mockPass(token));
|
|
2396
2523
|
const sent = Buffer.from(pass ?? given ?? req.headers.cookie?.split(/;\s*/).find((c) => c.startsWith(`${cookie}=`))?.slice(cookie.length + 1) ?? '');
|
|
2397
2524
|
if (!/^[\w-]{32,}$/.test(token) || sent.length !== want.length || !timingSafeEqual(sent, want)) {
|
|
2398
2525
|
return res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }).end('This board needs its access link: run sign-off-axi open on the computer that serves it and open the link it prints.');
|
package/dist/contract.js
CHANGED
|
@@ -11,7 +11,7 @@ export class ContractError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
/** The kinds, with the schema number of each. */
|
|
14
|
-
export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, plan: 1 };
|
|
14
|
+
export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, plan: 1, design: 1 };
|
|
15
15
|
export const KINDS = Object.keys(SCHEMAS);
|
|
16
16
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
|
|
17
17
|
const FIELDS = {
|
|
@@ -21,6 +21,7 @@ const FIELDS = {
|
|
|
21
21
|
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
22
22
|
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
23
23
|
plan: [...COMMON, 'sections', 'decisions', 'steps', 'view'],
|
|
24
|
+
design: [...COMMON, 'group', 'filed', 'status', 'preview', 'journeys', 'options', 'recommend'],
|
|
24
25
|
};
|
|
25
26
|
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
26
27
|
const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
|
|
@@ -256,6 +257,56 @@ export function checkMessage(m, want, flags = false) {
|
|
|
256
257
|
throw bad('preview', `${show(m.preview)} must sit in the folder of ${show(m.view)}`, `Put the picture beside the page, such as "${dirname(m.view)}/preview.svg"`);
|
|
257
258
|
return { kind: m.kind, schema: m.schema };
|
|
258
259
|
}
|
|
260
|
+
if (m.kind === 'design') {
|
|
261
|
+
text('group', m.group, 40, 'the project name, in 40 characters or fewer');
|
|
262
|
+
const at = typeof m.filed === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(m.filed) ? new Date(m.filed) : undefined;
|
|
263
|
+
if (!at || Number.isNaN(at.getTime()) || at.toISOString().replace('.000', '') !== m.filed)
|
|
264
|
+
throw bad('filed', 'must be a real UTC time with seconds', 'Set "filed" to YYYY-MM-DDTHH:MM:SSZ');
|
|
265
|
+
if (!['draft', 'in_review'].includes(m.status))
|
|
266
|
+
throw bad('status', 'must be draft or in_review', 'Set "status" to "draft" or "in_review"');
|
|
267
|
+
page('preview', /\.(png|jpe?g|webp)$/i, '.png, .jpg, .jpeg or .webp', '"design/preview.png"', 'a design preview');
|
|
268
|
+
const journeys = list('journeys', 0, 40, ['id', 'title', 'screens'], '{"id":"signup","title":"Sign up","screens":[...]}');
|
|
269
|
+
const ids = [];
|
|
270
|
+
journeys.forEach((j, n) => {
|
|
271
|
+
if (typeof j.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(j.id))
|
|
272
|
+
throw bad(`journeys[${n}].id`, 'must be a short lowercase id', 'Set it to a slug such as "signup"');
|
|
273
|
+
if (ids.includes(j.id))
|
|
274
|
+
throw bad(`journeys[${n}].id`, `repeats ${j.id}`, 'Give each journey its own id');
|
|
275
|
+
ids.push(j.id);
|
|
276
|
+
text(`journeys[${n}].title`, j.title, 120, 'the journey title');
|
|
277
|
+
if (!Array.isArray(j.screens) || !j.screens.length || j.screens.length > 80)
|
|
278
|
+
throw bad(`journeys[${n}].screens`, 'must list 1 to 80 screens', 'Add at least one screen with a view');
|
|
279
|
+
const screenIds = [];
|
|
280
|
+
j.screens.forEach((s, k) => {
|
|
281
|
+
const p = `journeys[${n}].screens[${k}]`;
|
|
282
|
+
if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(x => !['id', 'title', 'views'].includes(x)))
|
|
283
|
+
throw bad(p, 'must have only id, title and views', 'Use {"id":"welcome","title":"Welcome","views":{...}}');
|
|
284
|
+
if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id))
|
|
285
|
+
throw bad(`${p}.id`, 'must be a short lowercase id', 'Set it to a slug such as "welcome"');
|
|
286
|
+
if (screenIds.includes(s.id))
|
|
287
|
+
throw bad(`${p}.id`, `repeats ${s.id}`, 'Give each screen its own id');
|
|
288
|
+
screenIds.push(s.id);
|
|
289
|
+
text(`${p}.title`, s.title, 120, 'the screen title');
|
|
290
|
+
const views = s.views, variants = ['phone_light', 'phone_dark', 'computer_light', 'computer_dark'];
|
|
291
|
+
if (!views || typeof views !== 'object' || Array.isArray(views) || Object.keys(views).some(v => !variants.includes(v)) || !Object.keys(views).length)
|
|
292
|
+
throw bad(`${p}.views`, 'must map at least one supported view to a path', 'Add a phone_light, phone_dark, computer_light or computer_dark path');
|
|
293
|
+
for (const [variant, path] of Object.entries(views)) {
|
|
294
|
+
text(`${p}.views.${variant}`, path, 256, 'a board-relative image or HTML path');
|
|
295
|
+
if (/^([\\/]|[A-Za-z]:)/.test(path) || path.split(/[\\/]/).includes('..') || /[?#\\]/.test(path) || !/\.(png|jpe?g|webp|html)$/i.test(path))
|
|
296
|
+
throw bad(`${p}.views.${variant}`, 'must be a relative .png, .jpg, .jpeg, .webp or .html path without ..', 'Put the view under the board folder');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
if (m.options !== undefined) {
|
|
301
|
+
if (!Array.isArray(m.options) || m.options.length < 2 || m.options.length > 6)
|
|
302
|
+
throw bad('options', 'must list 2 to 6 options', 'Set options to 2-6 {"value":"...","label":"..."} choices');
|
|
303
|
+
checkOptions('options', m.options);
|
|
304
|
+
recommend('recommend', m, m.options);
|
|
305
|
+
}
|
|
306
|
+
else if (m.recommend !== undefined)
|
|
307
|
+
throw bad('recommend', 'requires options', 'Add options or remove recommend');
|
|
308
|
+
return { kind: m.kind, schema: m.schema };
|
|
309
|
+
}
|
|
259
310
|
// A plan is sections to approve or change, decisions with a recommended pick, steps to tap through, and where it goes as a diagram.
|
|
260
311
|
if (m.kind === 'plan') {
|
|
261
312
|
const ids = list('sections', 1, 8, ['id', 'heading', 'body'], '{"id":"goals","heading":"Goals and non-goals","body":"<what it does and does not do>"}').map((o, n) => {
|
package/dist/store.js
CHANGED
|
@@ -155,7 +155,7 @@ export function loadDecisions(path, board) {
|
|
|
155
155
|
}
|
|
156
156
|
// A ladder rung: a diagram image, or an interactive page the board frames in a sandbox.
|
|
157
157
|
// A report is a page and its picture; one stored before reports had pages still loads, as a note with neither.
|
|
158
|
-
const report = d.kind === 'report', plan = d.kind === 'plan';
|
|
158
|
+
const report = d.kind === 'report', plan = d.kind === 'plan', design = d.kind === 'design';
|
|
159
159
|
if (d.type === 'plan' && !plan)
|
|
160
160
|
throw bad('type', 'plan needs "kind": "plan": post it with sign-off-axi post --kind plan');
|
|
161
161
|
const views = report ? (d.view !== undefined || d.preview !== undefined ? VIEWS.interactive : undefined) : plan ? VIEWS.interactive : Object.hasOwn(VIEWS, d.type ?? '') ? VIEWS[d.type] : undefined;
|
|
@@ -170,9 +170,9 @@ export function loadDecisions(path, board) {
|
|
|
170
170
|
if (d[f] !== undefined)
|
|
171
171
|
throw bad(f, `must be left out: a report takes no answer${f === 'options' ? '' : ' and shows only under Reports'}`);
|
|
172
172
|
// A plan takes its answer from its sections and decisions, sent back at once, so it has no options of its own.
|
|
173
|
-
const options = d.options ?? (report || signin || plan ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
|
|
173
|
+
const options = d.options ?? (report || signin || plan || design ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
|
|
174
174
|
// An empty list is an item to look at only, unless it takes a reply: only a board another program feeds, a report or a sign-in shows those.
|
|
175
|
-
if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report && !signin && !plan) || options.length > 6
|
|
175
|
+
if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report && !signin && !plan && !design) || options.length > 6
|
|
176
176
|
|| !options.every((o) => isKey(o?.value) && isText(o.label) && o.label.trim() && (o.outcome === undefined || isText(o.outcome)))) {
|
|
177
177
|
throw bad('options', 'must be 2 to 6 items (or empty on a readonly or answers_only board), each a value of 1-128 of A-Z a-z 0-9 . _ - and a label');
|
|
178
178
|
}
|
|
@@ -218,6 +218,7 @@ export function loadDecisions(path, board) {
|
|
|
218
218
|
// So is a finding's proposal, with the counts and records behind it.
|
|
219
219
|
...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
|
|
220
220
|
...(plan && { sections: d.sections, decisions: d.decisions }),
|
|
221
|
+
...(design && { kind: 'design', type: 'design', group: d.group, filed: d.filed, status: d.status, preview: d.preview, journeys: d.journeys, ...(d.options && { options: d.options, recommend_value: d.recommend }) }),
|
|
221
222
|
...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
|
|
222
223
|
};
|
|
223
224
|
});
|
|
@@ -279,8 +280,36 @@ function saveAnswer(decisionsPath, d, option, note, comments = [], by) {
|
|
|
279
280
|
appendLine(answersPath(decisionsPath), answer);
|
|
280
281
|
return answer;
|
|
281
282
|
}
|
|
282
|
-
/** Saves a
|
|
283
|
-
export function
|
|
283
|
+
/** Saves a design answer with screen-specific notes and an optional listed direction pick. */
|
|
284
|
+
export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick) {
|
|
285
|
+
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
|
|
286
|
+
if (verdict !== 'approve' && verdict !== 'change')
|
|
287
|
+
throw bad('verdict must be approve or change');
|
|
288
|
+
if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
|
|
289
|
+
throw bad('notes must be a list, with at least one note on change');
|
|
290
|
+
const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
|
|
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');
|
|
299
|
+
const options = d.options ?? [];
|
|
300
|
+
if (options.length ? !options.some(o => o.value === pick) : pick !== undefined)
|
|
301
|
+
throw bad('pick must be one listed option, or omitted when no options were offered');
|
|
302
|
+
const answer = { key: d.key, answer: verdict, label: verdict === 'approve' ? 'Approved' : 'Changes requested', close: d.close ?? 'done', at: now(), notes, ...(pick !== undefined && { pick: pick }) };
|
|
303
|
+
const current = readAnswers(decisionsPath)[d.key];
|
|
304
|
+
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
|
|
305
|
+
return;
|
|
306
|
+
appendLine(answersPath(decisionsPath), answer);
|
|
307
|
+
return answer;
|
|
308
|
+
}
|
|
309
|
+
/** Saves a plan's one send-back as its answer: each section approved or changed (a change says what to change, an approval may carry a comment),
|
|
310
|
+
* one answer per decision (a listed option value, or the person's own words when none fits), and an optional whole-plan comment.
|
|
311
|
+
* An identical send-back again saves nothing. */
|
|
312
|
+
export function appendSendBack(decisionsPath, d, notes, picks = {}, comment = '') {
|
|
284
313
|
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`), ids = d.sections.map((s) => s.id), keys = d.decisions.map((q) => q.key);
|
|
285
314
|
if (!Array.isArray(notes) || notes.length !== ids.length || !ids.every((id) => notes.filter((n) => n?.section === id).length === 1))
|
|
286
315
|
throw bad(`notes must list each section once: ${ids.join(', ')}`);
|
|
@@ -293,13 +322,16 @@ export function appendSendBack(decisionsPath, d, notes, picks = {}) {
|
|
|
293
322
|
throw bad(`section ${section} needs a note on what to change`);
|
|
294
323
|
}
|
|
295
324
|
const p = picks;
|
|
296
|
-
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).length !== keys.length || !d.decisions.every((q) => q.
|
|
297
|
-
throw bad(`decisions must
|
|
325
|
+
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).length !== keys.length || !d.decisions.every((q) => isText(p[q.key]) && p[q.key].trim() && [...p[q.key].trim()].length <= NOTE_MAX)) {
|
|
326
|
+
throw bad(`decisions must answer each of: ${keys.join(', ') || 'none'}, with an option or your own words of ${NOTE_MAX} characters or fewer`);
|
|
298
327
|
}
|
|
299
|
-
|
|
300
|
-
|
|
328
|
+
if (comment !== '' && comment !== undefined && (!isText(comment) || [...comment.trim()].length > NOTE_MAX))
|
|
329
|
+
throw bad(`comment must be text of ${NOTE_MAX} characters or fewer`);
|
|
330
|
+
const note = typeof comment === 'string' ? comment.trim() : '';
|
|
331
|
+
const answer = { key: d.key, answer: 'sent', label: 'Sent back', close: d.close, at: now(), ...(note && { note }),
|
|
332
|
+
notes: ids.map((id) => notes.find((n) => n.section === id)).map(({ section, verdict, note }) => ({ section, verdict, ...(note?.trim() && { note: note.trim() }) })), decisions: Object.fromEntries(keys.map((k) => [k, p[k].trim()])) };
|
|
301
333
|
const current = readAnswers(decisionsPath)[d.key];
|
|
302
|
-
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.decisions]) === JSON.stringify([answer.notes, answer.decisions]))
|
|
334
|
+
if (current?.answer === answer.answer && JSON.stringify([current.note ?? '', current.notes, current.decisions]) === JSON.stringify([answer.note ?? '', answer.notes, answer.decisions]))
|
|
303
335
|
return;
|
|
304
336
|
appendLine(answersPath(decisionsPath), answer);
|
|
305
337
|
return answer;
|
|
@@ -9,8 +9,8 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
|
|
|
9
9
|
fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
- `<kind>` is `ask`, `report`, `evidence`, `gate`, `finding` or `
|
|
13
|
-
- Each file holds one message. An evidence fixture that names `media` ships those files next to it, and a report fixture its page and picture,
|
|
12
|
+
- `<kind>` is `ask`, `report`, `evidence`, `gate`, `finding`, `plan` or `design`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v2/`, `evidence.v1/`, `gate.v1/`, `finding.v1/`, `plan.v1/` and `design.v1/`. This is the one list of shipped folders; other docs point here.
|
|
13
|
+
- Each file holds one message. An evidence fixture that names `media` ships those files next to it, and a report fixture its page and picture, a plan fixture its page, under `page/`, and a design fixture its views, under `design/`.
|
|
14
14
|
- Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
|
|
15
15
|
- An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
|
|
16
16
|
- Demo data uses the name Umer. No real names, paths or tokens.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta name="viewport" content="width=device-width"><style>@media(prefers-color-scheme:dark){body{background:#111;color:#fff}}</style></head><body><button>Continue</button></body></html>
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"../preview.png","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png","unknown":"x","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","filer":"agent-41","about":"Review onboarding and messaging.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png","computer_dark":"design/welcome.html"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-choice","title":"Choose a chat direction","about":"Compare two chat layouts.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"draft","preview":"design/preview.png","options":[{"value":"simple","label":"Simple"},{"value":"rich","label":"Rich"}],"recommend":"simple","journeys":[{"id":"messages","title":"Messages","screens":[{"id":"inbox","title":"Inbox","views":{"phone_light":"design/inbox.png"}}]}]}
|
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);
|
|
@@ -172,6 +172,8 @@
|
|
|
172
172
|
.plan-section label { display: grid; gap: 0.5rem; margin-top: 1rem; color: var(--muted); }
|
|
173
173
|
.plan-section label[hidden] { display: none; }
|
|
174
174
|
.plan-section textarea { width: 100%; min-height: 7rem; padding: 0.75rem; background: var(--sunk); }
|
|
175
|
+
.plan-decision label { display: grid; gap: 0.5rem; margin-top: 0.75rem; color: var(--muted); }
|
|
176
|
+
.plan-decision textarea { width: 100%; min-height: 4rem; padding: 0.75rem; background: var(--sunk); }
|
|
175
177
|
.plan-decision .choices { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.625rem; }
|
|
176
178
|
.plan-decision .choices button { text-align: left; border-radius: 0.875rem; padding: 0.875rem; gap: 0.25rem; }
|
|
177
179
|
.plan-decision .choices button .tag { margin: 0; text-transform: none; letter-spacing: normal; color: var(--accent); }
|
|
@@ -202,6 +204,10 @@
|
|
|
202
204
|
.plan-section, .plan-decision { padding: 1.5rem; }
|
|
203
205
|
}
|
|
204
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
|
+
}
|
|
205
211
|
.foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; }
|
|
206
212
|
.foot .meta { margin: 0 auto 0 0; }
|
|
207
213
|
.foot a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
|
@@ -338,6 +344,22 @@
|
|
|
338
344
|
.done[open] + .x { align-self: start; margin-top: 1rem; }
|
|
339
345
|
.needs .foot { justify-content: space-between; margin-top: 1rem; padding-top: 0.5rem; border-top: 1px solid var(--line); }
|
|
340
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; }
|
|
341
363
|
/* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
|
|
342
364
|
.gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
|
|
343
365
|
.gone-asks .rows { padding: 0; } .gone-asks li { justify-content: space-between; gap: 0.75rem; min-height: 2.75rem; }
|
|
@@ -604,6 +626,12 @@
|
|
|
604
626
|
}
|
|
605
627
|
|
|
606
628
|
function row(d, { x = true } = {}) {
|
|
629
|
+
if (d.kind === 'design') {
|
|
630
|
+
const original = /^https?:/.test(d.link ?? '') ? el('p', { class: 'meta' }, el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer',
|
|
631
|
+
'aria-label': `Open original, ${d.title}` }, 'Open original')) : '';
|
|
632
|
+
return el('article', { class: 'row design', id: `item-${d.key}` },
|
|
633
|
+
el('div', {}, el('h3', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about)), original);
|
|
634
|
+
}
|
|
607
635
|
const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` });
|
|
608
636
|
const saved = Object.hasOwn(state.answers, d.key) ? state.answers[d.key] : undefined;
|
|
609
637
|
// A read-only board, or an item with nothing to answer, shows no answer, note or comment controls.
|
|
@@ -811,6 +839,7 @@
|
|
|
811
839
|
|
|
812
840
|
/** A mock or view is served from inside its own folder, so the files beside it load by their relative names. */
|
|
813
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)}`;
|
|
814
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. */
|
|
815
844
|
function rung(d) {
|
|
816
845
|
if (!d.view) return '';
|
|
@@ -912,11 +941,12 @@
|
|
|
912
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);
|
|
913
942
|
}
|
|
914
943
|
|
|
915
|
-
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' };
|
|
916
945
|
// The sidebar and the tab bar name the areas short enough for one line.
|
|
917
946
|
const NAV = { ...AREAS, reports: 'Reports' }, TAB = { ...NAV, waiting: 'Waiting' };
|
|
918
|
-
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.' };
|
|
919
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',
|
|
920
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',
|
|
921
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',
|
|
922
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',
|
|
@@ -930,7 +960,7 @@
|
|
|
930
960
|
* A plan has no options but waits for its send-back. */
|
|
931
961
|
const area = (d) => {
|
|
932
962
|
const a = isAnswered(d) ? 'answered' : d.area ?? (d.type === 'evidence' ? 'evidence'
|
|
933
|
-
: state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan') ? 'reports' : 'waiting');
|
|
963
|
+
: state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
|
|
934
964
|
// Reports lists only what explains: a page to tap through, or a narrated video. Text alone (a receipt, a status line) is a note, folded under it.
|
|
935
965
|
return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
|
|
936
966
|
};
|
|
@@ -942,7 +972,7 @@
|
|
|
942
972
|
const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
|
|
943
973
|
const pic = (d) => {
|
|
944
974
|
const i = d.media?.findIndex((m) => !/\.(mp4|webm)$/i.test(m.path)), m = d.mocks?.find((x) => !/\.html?$/i.test(x.path));
|
|
945
|
-
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)
|
|
946
976
|
: m ? mockUrl(d.key, m.value, m.path) : d.evidenceCard?.media.find((x) => x.type.startsWith('image/'))?.url;
|
|
947
977
|
};
|
|
948
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; };
|
|
@@ -951,7 +981,7 @@
|
|
|
951
981
|
const answeredToday = a === 'answered' && items.filter((d) => new Date(state.answers[d.key].at).toDateString() === new Date().toDateString()).length;
|
|
952
982
|
// A short count line that never cuts off on a phone. Waiting gives the oldest ask's time when its items carry one.
|
|
953
983
|
const oldest = items.map((d) => d.filed).filter(Boolean).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
|
|
954
|
-
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`,
|
|
955
985
|
reports: `${items.length} to read`, answered: `${answeredToday} today` }[a];
|
|
956
986
|
const thumbs = a === 'evidence' ? items.map(pic).filter(Boolean).slice(0, 3).map((u) => el('img', { src: src(u), alt: '' })) : [];
|
|
957
987
|
const marks = thumbs.length ? thumbs : a === 'waiting' ? [items.length ? icon(a) : '']
|
|
@@ -1072,18 +1102,20 @@
|
|
|
1072
1102
|
let draft;
|
|
1073
1103
|
try { draft = JSON.parse(drafts.get(draftKey) ?? '{}'); } catch { draft = {}; }
|
|
1074
1104
|
const notes = new Map((saved?.notes ?? draft.notes ?? []).filter((n) => d.sections.some((s) => s.id === n.section)).map((n) => [n.section, n]));
|
|
1075
|
-
const picks = new Map(Object.entries(saved?.decisions ?? draft.decisions ?? {}).filter(([key, value]) => d.decisions.some((q) => q.key === key &&
|
|
1076
|
-
|
|
1105
|
+
const picks = new Map(Object.entries(saved?.decisions ?? draft.decisions ?? {}).filter(([key, value]) => d.decisions.some((q) => q.key === key) && typeof value === 'string' && value.trim()));
|
|
1106
|
+
let comment = saved?.note ?? (typeof draft.comment === 'string' ? draft.comment : '');
|
|
1107
|
+
const answered = (q) => typeof picks.get(q.key) === 'string' && !!picks.get(q.key).trim();
|
|
1108
|
+
const remember = () => { drafts.set(draftKey, JSON.stringify({ notes: [...notes.values()], decisions: Object.fromEntries(picks), ...(comment.trim() && { comment }) })); keep(); };
|
|
1077
1109
|
const main = el('div', { class: 'plan-main' }), panel = el('aside', { class: 'plan-send' });
|
|
1078
1110
|
const status = el('p', { role: 'status' }), err = el('p', { class: 'err', role: 'alert' }), progress = el('div', { class: 'plan-progress' });
|
|
1079
1111
|
const send = el('button', { class: 'open-link' }, 'Send back'), next = el('button', { class: 'plan-next' }, 'Go to next');
|
|
1080
1112
|
const complete = (s) => { const n = notes.get(s.id); return n?.verdict === 'approve' || (n?.verdict === 'change' && n.note?.trim()); };
|
|
1081
1113
|
next.onclick = () => {
|
|
1082
|
-
const section = d.sections.find((s) => !complete(s)), decision = d.decisions.find((q) => !
|
|
1114
|
+
const section = d.sections.find((s) => !complete(s)), decision = d.decisions.find((q) => !answered(q));
|
|
1083
1115
|
document.getElementById(section ? `plan-section-${section.id}` : `plan-decision-${decision?.key}`)?.scrollIntoView({ block: 'start' });
|
|
1084
1116
|
};
|
|
1085
1117
|
const update = () => {
|
|
1086
|
-
const left = d.sections.filter((s) => !complete(s)).length, decisions = d.decisions.filter((q) => !
|
|
1118
|
+
const left = d.sections.filter((s) => !complete(s)).length, decisions = d.decisions.filter((q) => !answered(q)).length;
|
|
1087
1119
|
status.textContent = saved ? 'Sent back. Your answers are saved.' : left || decisions ? `${left} section${left === 1 ? '' : 's'} and ${decisions} decision${decisions === 1 ? '' : 's'} left` : 'Ready. Every part has an answer.';
|
|
1088
1120
|
send.disabled = ro || !!left || !!decisions;
|
|
1089
1121
|
next.hidden = !left && !decisions;
|
|
@@ -1094,12 +1126,13 @@
|
|
|
1094
1126
|
};
|
|
1095
1127
|
if (saved) {
|
|
1096
1128
|
main.append(el('div', { class: 'plan-sent', role: 'status' }, icon('answered'), el('h2', {}, 'Sent back'), el('p', {}, 'Your section notes and decisions are saved for the agent.'),
|
|
1097
|
-
el('a', { class: 'back', href: '#answered' }, 'See it in Answered')
|
|
1129
|
+
el('a', { class: 'back', href: '#answered' }, 'See it in Answered'),
|
|
1130
|
+
saved.note ? el('p', { dir: 'auto' }, `Your whole-plan comment: ${saved.note}`) : ''));
|
|
1098
1131
|
}
|
|
1099
1132
|
main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
|
|
1100
1133
|
if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
|
|
1101
1134
|
el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length} sections to approve or change, and ${d.decisions.length} decisions to make. Nothing is built until you send back.`));
|
|
1102
|
-
main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note.'));
|
|
1135
|
+
main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note, an approval can carry a comment, and a decision takes an option or your own words.'));
|
|
1103
1136
|
d.sections.forEach((s, i) => {
|
|
1104
1137
|
const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
|
|
1105
1138
|
const approve = el('button', { class: 'approve', 'aria-label': `Approve, ${s.heading}` }, 'Approve');
|
|
@@ -1107,7 +1140,8 @@
|
|
|
1107
1140
|
const field = el('textarea', { id: `plan-note-${s.id}`, maxlength: '500', placeholder: 'Write your note for the agent' });
|
|
1108
1141
|
field.value = notes.get(s.id)?.note ?? '';
|
|
1109
1142
|
field.disabled = ro;
|
|
1110
|
-
const
|
|
1143
|
+
const caption = el('span', {}, '');
|
|
1144
|
+
const note = el('label', { for: field.id }, caption, field);
|
|
1111
1145
|
const draw = () => {
|
|
1112
1146
|
const verdict = notes.get(s.id)?.verdict;
|
|
1113
1147
|
card.dataset.verdict = verdict ?? '';
|
|
@@ -1115,27 +1149,38 @@
|
|
|
1115
1149
|
approve.textContent = verdict === 'approve' ? '✓ Approved' : 'Approve';
|
|
1116
1150
|
change.setAttribute('aria-pressed', verdict === 'change');
|
|
1117
1151
|
approve.disabled = change.disabled = ro;
|
|
1118
|
-
|
|
1152
|
+
caption.textContent = verdict === 'approve' ? `Comment for the agent on ${s.heading.toLowerCase()} (optional)` : `What should change in ${s.heading.toLowerCase()}?`;
|
|
1153
|
+
note.hidden = !verdict && !field.value;
|
|
1119
1154
|
update();
|
|
1120
1155
|
};
|
|
1121
1156
|
[approve, change].forEach((b, n) => b.onclick = () => {
|
|
1122
|
-
notes.set(s.id, { section: s.id, verdict: n ? 'change' : 'approve', ...(
|
|
1157
|
+
notes.set(s.id, { section: s.id, verdict: n ? 'change' : 'approve', ...(field.value.trim() && { note: field.value }) });
|
|
1123
1158
|
remember(); draw(); if (n) field.focus();
|
|
1124
1159
|
});
|
|
1125
|
-
field.oninput = () => { notes.set(s.id, { section: s.id, verdict: 'change', note: field.value }); remember();
|
|
1160
|
+
field.oninput = () => { notes.set(s.id, { section: s.id, verdict: notes.get(s.id)?.verdict ?? 'change', note: field.value }); remember(); draw(); };
|
|
1126
1161
|
card.append(el('h3', { dir: 'auto' }, `${i + 1}. ${s.heading}`), el('p', { dir: 'auto' }, s.body), el('div', { class: 'plan-verdicts', role: 'group', 'aria-label': s.heading }, approve, change), note);
|
|
1127
1162
|
main.append(card); draw();
|
|
1128
1163
|
});
|
|
1129
1164
|
if (d.decisions.length) main.append(el('h2', {}, 'Decisions for you'));
|
|
1130
1165
|
d.decisions.forEach((q) => {
|
|
1131
1166
|
const choices = el('div', { class: 'choices', role: 'group', 'aria-label': q.question });
|
|
1167
|
+
const other = el('textarea', { id: `plan-other-${q.key}`, maxlength: '500', placeholder: 'Other: write your own answer' });
|
|
1168
|
+
other.value = q.options.some((o) => o.value === picks.get(q.key)) ? '' : (picks.get(q.key) ?? '');
|
|
1169
|
+
other.disabled = ro;
|
|
1170
|
+
const press = () => [...choices.children].forEach((b, i) => b.setAttribute('aria-pressed', q.options[i].value === picks.get(q.key)));
|
|
1171
|
+
other.oninput = () => {
|
|
1172
|
+
if (other.value.trim()) picks.set(q.key, other.value);
|
|
1173
|
+
else if (!q.options.some((o) => o.value === picks.get(q.key))) picks.delete(q.key);
|
|
1174
|
+
remember(); press(); update();
|
|
1175
|
+
};
|
|
1132
1176
|
q.options.forEach((o) => {
|
|
1133
1177
|
const b = el('button', { 'aria-pressed': picks.get(q.key) === o.value, ...(ro && { disabled: '' }) }, el('strong', {}, o.label),
|
|
1134
1178
|
o.value === q.recommend ? el('span', { class: 'tag' }, 'Recommended') : '', el('span', { class: 'out', dir: 'auto' }, o.outcome));
|
|
1135
|
-
b.onclick = () => { picks.set(q.key, o.value);
|
|
1179
|
+
b.onclick = () => { picks.set(q.key, o.value); other.value = ''; remember(); press(); update(); };
|
|
1136
1180
|
choices.append(b);
|
|
1137
1181
|
});
|
|
1138
|
-
main.append(el('section', { class: 'plan-decision', id: `plan-decision-${q.key}` }, el('h3', { dir: 'auto' }, q.question), el('p', { class: 'meta', dir: 'auto' }, q.why), choices
|
|
1182
|
+
main.append(el('section', { class: 'plan-decision', id: `plan-decision-${q.key}` }, el('h3', { dir: 'auto' }, q.question), el('p', { class: 'meta', dir: 'auto' }, q.why), choices,
|
|
1183
|
+
el('label', { for: other.id }, 'Other (optional)', other)));
|
|
1139
1184
|
});
|
|
1140
1185
|
if (d.steps?.length && !saved) {
|
|
1141
1186
|
let step = 0;
|
|
@@ -1144,10 +1189,16 @@
|
|
|
1144
1189
|
prev.onclick = () => { step--; draw(); }; next.onclick = () => { step++; draw(); }; draw();
|
|
1145
1190
|
main.append(el('section', { class: 'plan-explain' }, el('h3', {}, 'How it works'), text, el('div', { class: 'foot' }, prev, next), el('p', { class: 'meta' }, 'The direction page above explains these steps.')));
|
|
1146
1191
|
}
|
|
1192
|
+
const commentBox = el('textarea', { id: 'plan-comment', maxlength: '500', placeholder: 'Comment for the agent on the whole plan' });
|
|
1193
|
+
commentBox.value = comment;
|
|
1194
|
+
commentBox.disabled = ro;
|
|
1195
|
+
commentBox.oninput = () => { comment = commentBox.value; remember(); };
|
|
1196
|
+
if (!saved || saved.note) main.append(el('section', { class: 'plan-section', id: 'plan-comment-section', 'aria-label': 'Whole-plan comment' },
|
|
1197
|
+
el('h3', {}, 'Whole-plan comment'), el('label', { for: commentBox.id }, 'Comment for the agent on the whole plan (optional)', commentBox)));
|
|
1147
1198
|
send.onclick = () => {
|
|
1148
1199
|
send.disabled = true; err.textContent = '';
|
|
1149
|
-
call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key,
|
|
1150
|
-
notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries(picks) }) }, () => {
|
|
1200
|
+
call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note: comment,
|
|
1201
|
+
notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries([...picks].map(([k, v]) => [k, v.trim()])) }) }, () => {
|
|
1151
1202
|
drafts.delete(draftKey); keep(); location.hash = `#answered/${d.key}`;
|
|
1152
1203
|
}).catch((e) => { err.textContent = `Couldn't send back: ${e.message}`; update(); });
|
|
1153
1204
|
};
|
|
@@ -1155,23 +1206,69 @@
|
|
|
1155
1206
|
return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
|
|
1156
1207
|
}
|
|
1157
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
|
+
|
|
1158
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.
|
|
1159
1249
|
* The frame is made again only for another page, so a redraw never resets a demo halfway through. */
|
|
1160
1250
|
const reader = document.getElementById('reader');
|
|
1161
1251
|
function readPage(d, from = 'reports') {
|
|
1162
1252
|
document.body.classList.toggle('reading', !!d);
|
|
1163
|
-
|
|
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;
|
|
1164
1260
|
reader.hidden = !d;
|
|
1165
|
-
const url = d ? src(mockUrl(d.key, '', d.view)) : '';
|
|
1166
|
-
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) : '';
|
|
1167
1263
|
const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
|
|
1168
1264
|
if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
|
|
1169
1265
|
Object.assign(reader.dataset, { answer, plan });
|
|
1170
1266
|
Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
|
|
1171
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)),
|
|
1172
|
-
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' })] : []));
|
|
1173
1269
|
}
|
|
1174
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); });
|
|
1175
1272
|
|
|
1176
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. */
|
|
1177
1274
|
function reportGroups(items) {
|
|
@@ -1184,6 +1281,18 @@
|
|
|
1184
1281
|
});
|
|
1185
1282
|
}
|
|
1186
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
|
+
|
|
1187
1296
|
/** The asks dismissed from Waiting on you, folded under one row; Restore puts one back on the board. */
|
|
1188
1297
|
function dismissed(items) {
|
|
1189
1298
|
if (!canDismiss() || !items.length) return '';
|
|
@@ -1244,7 +1353,7 @@
|
|
|
1244
1353
|
return el('div', { class: 'tri' }, queue, el('div', { class: 'focus' }, el('p', { class: 'fhead' }, `${i + 1} of ${n}`),
|
|
1245
1354
|
el('div', { class: 'pbar', 'aria-hidden': 'true' }, el('i', { style: `width: ${((i + 1) / n) * 100}%` })),
|
|
1246
1355
|
d.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(d.filed)}`) : '', card,
|
|
1247
|
-
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))) : '',
|
|
1248
1357
|
hint ? el('p', { class: 'meta hint' }, hint) : '', el('p', { class: 'meta keys' }, `Keys: ${keys}`), dismissed(hidden)));
|
|
1249
1358
|
}
|
|
1250
1359
|
// Triage keys at a desk: a number picks that answer (in the order shown), E skips, Z takes back the last tap.
|
|
@@ -1256,13 +1365,14 @@
|
|
|
1256
1365
|
});
|
|
1257
1366
|
|
|
1258
1367
|
function render() {
|
|
1259
|
-
const by = { waiting: [], evidence: [], reports: [], answered: [], notes: [] };
|
|
1368
|
+
const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
|
|
1260
1369
|
// The board file appends new items at the end, so each list reads newest first; answers sort by their time.
|
|
1261
1370
|
// One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
|
|
1262
1371
|
const gone = new Set(state.dismissed ?? []), open = state.decisions.filter((d) => !gone.has(d.key) && !isAnswered(d));
|
|
1263
1372
|
const openKeys = new Set(open.map((d) => d.key));
|
|
1264
1373
|
const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
|
|
1265
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));
|
|
1266
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.
|
|
1267
1377
|
let latest = -Infinity;
|
|
1268
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 }; });
|
|
@@ -1274,7 +1384,7 @@
|
|
|
1274
1384
|
const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
|
|
1275
1385
|
const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
|
|
1276
1386
|
by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
|
|
1277
|
-
const [, at, key] = /^#(waiting|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1387
|
+
const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1278
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)));
|
|
1279
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)));
|
|
1280
1390
|
document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
|
|
@@ -1289,14 +1399,14 @@
|
|
|
1289
1399
|
: at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
|
|
1290
1400
|
const first = by.waiting[0], more = by.waiting.length > 1 ? `Answer all ${by.waiting.length} in Triage →` : 'Open in Triage →';
|
|
1291
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 }),
|
|
1292
|
-
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));
|
|
1293
1403
|
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), triage(shown, hidden)]
|
|
1294
|
-
: 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]))) : '',
|
|
1295
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))
|
|
1296
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]))),
|
|
1297
1407
|
el('div', { class: 'pair' }, needs, today(by.answered)), ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()]),
|
|
1298
1408
|
el('div', { class: 'areas' }, ...Object.keys(AREAS).map((a) => section(a, by[a])))]));
|
|
1299
|
-
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);
|
|
1300
1410
|
if (FROZEN) freeze();
|
|
1301
1411
|
}
|
|
1302
1412
|
function freeze() {
|
|
@@ -1312,7 +1422,7 @@
|
|
|
1312
1422
|
if (jump && location.hash === '#waiting') [cur, jump] = [jump, null];
|
|
1313
1423
|
if (location.hash === '#waiting') waitType = 'all';
|
|
1314
1424
|
render();
|
|
1315
|
-
if (!reader.hidden) return reader.querySelector('h1').focus();
|
|
1425
|
+
if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
|
|
1316
1426
|
const item = (jump || back) && document.getElementById(`item-${jump || back}`);
|
|
1317
1427
|
jump = null;
|
|
1318
1428
|
item ? item.scrollIntoView() : scrollTo(0, 0);
|