sign-off-axi 0.4.4 → 0.4.6
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/dist/cli.js +47 -16
- package/dist/evidence-text.js +182 -0
- package/dist/store.js +23 -9
- package/fixtures/contract/evidence.v1/shot.png +0 -0
- package/package.json +1 -1
- package/web/index.html +348 -111
- package/web/point.js +11 -1
package/dist/cli.js
CHANGED
|
@@ -10,8 +10,9 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
import { encode } from '@toon-format/toon';
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
|
-
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
13
|
+
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
14
14
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
|
+
import { faults } from './evidence-text.js';
|
|
15
16
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
17
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
17
18
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
@@ -206,7 +207,7 @@ An empty items list removes that filer's items.
|
|
|
206
207
|
--type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
|
|
207
208
|
an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
|
|
208
209
|
The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
|
|
209
|
-
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link.
|
|
210
|
+
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
|
|
210
211
|
The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
|
|
211
212
|
--type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
|
|
212
213
|
and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --chapter m:ss=<title> (in order) are buttons that jump the video there. Answers work as for a decision.
|
|
@@ -484,7 +485,7 @@ function filerOf(values, session = false) {
|
|
|
484
485
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
485
486
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
486
487
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
487
|
-
const waits = (d) => !d.dismissed &&
|
|
488
|
+
const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
488
489
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
489
490
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
490
491
|
function board(file, say = warn) {
|
|
@@ -515,7 +516,7 @@ function ready(file, on, root) {
|
|
|
515
516
|
}
|
|
516
517
|
function list(args) {
|
|
517
518
|
const { file, values, on } = parse('list', args, TIERS);
|
|
518
|
-
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
|
|
519
|
+
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
|
|
519
520
|
if (values.json)
|
|
520
521
|
return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
|
|
521
522
|
const help = [
|
|
@@ -525,7 +526,7 @@ function list(args) {
|
|
|
525
526
|
];
|
|
526
527
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
527
528
|
if (values.full) {
|
|
528
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer
|
|
529
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
529
530
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
530
531
|
}
|
|
531
532
|
return toon({
|
|
@@ -604,7 +605,9 @@ async function wait(args) {
|
|
|
604
605
|
const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
|
|
605
606
|
// With a filer, only its own items count, and an answer it has not taken yet is new however old it is.
|
|
606
607
|
const mine = (rows) => (filer === undefined ? rows : rows.filter((d) => d.filer === filer));
|
|
607
|
-
|
|
608
|
+
// A design with only screen notes still waits: it counts as unanswered here until its design verdict lands.
|
|
609
|
+
const open = (rows) => rows.map((d) => (awaitsVerdict(d.type, d.answer) ? { ...d, answer: undefined } : d));
|
|
610
|
+
const before = new Map(open(board(file)).flatMap((d) => (d.answer ? [[d.key, JSON.stringify(d.answer)]] : [])));
|
|
608
611
|
// Rounds seen before waiting starts stay quiet; only a newly asked round wakes the loop.
|
|
609
612
|
const seenRounds = new Map();
|
|
610
613
|
for (const r of readRounds(file))
|
|
@@ -618,8 +621,8 @@ async function wait(args) {
|
|
|
618
621
|
}
|
|
619
622
|
catch { }
|
|
620
623
|
for (let first = true;; first = false) {
|
|
621
|
-
const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
|
|
622
|
-
const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) :
|
|
624
|
+
const rows = mine(open(board(file, first ? warn : () => { }))), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
|
|
625
|
+
const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : before.get(d.key) !== JSON.stringify(d.answer))));
|
|
623
626
|
const counts = new Map(), asked = [];
|
|
624
627
|
for (const r of readRounds(file, first ? warn : () => { })) {
|
|
625
628
|
counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
|
|
@@ -974,7 +977,7 @@ function replace(file, named, on, from, filer) {
|
|
|
974
977
|
// A key whose answer outlived it may not come back: the board and the answers are read here, under the lock the feed writes with.
|
|
975
978
|
const saved = readAnswers(file);
|
|
976
979
|
for (const i of fed.items)
|
|
977
|
-
if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
|
|
980
|
+
if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key) && !awaitsVerdict(i.kind, saved[i.key])) {
|
|
978
981
|
throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
979
982
|
}
|
|
980
983
|
const current = board(file);
|
|
@@ -982,7 +985,7 @@ function replace(file, named, on, from, filer) {
|
|
|
982
985
|
const d = incoming.find((x) => x.key === had.key);
|
|
983
986
|
if (filer && d && had.filer && had.filer !== filer)
|
|
984
987
|
throw fail(`key ${d.key} on ${tilde(file)} was posted by another filer`, 'CONFLICT', 'Feed it under a new key');
|
|
985
|
-
if (had.answer && d && question(d) !== question(had)) {
|
|
988
|
+
if (had.answer && !awaitsVerdict(had.type, had.answer) && d && question(d) !== question(had)) {
|
|
986
989
|
throw fail(`key ${d.key} was answered and now holds a different question on ${tilde(file)}`, 'CONFLICT', 'Post the new question under a new key');
|
|
987
990
|
}
|
|
988
991
|
}
|
|
@@ -1019,6 +1022,11 @@ function replace(file, named, on, from, filer) {
|
|
|
1019
1022
|
}
|
|
1020
1023
|
/** 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.
|
|
1021
1024
|
* `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`. */
|
|
1025
|
+
const UNFIT = {
|
|
1026
|
+
text: (path) => `--media ${path} is mostly text: a picture of a log, receipt or summary is not evidence`,
|
|
1027
|
+
'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
|
|
1028
|
+
placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
|
|
1029
|
+
};
|
|
1022
1030
|
function postMedia(file, named, on, values, type, filer, message) {
|
|
1023
1031
|
fits(values.title);
|
|
1024
1032
|
const explainer = type === 'explainer';
|
|
@@ -1058,6 +1066,10 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1058
1066
|
throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
|
|
1059
1067
|
return path;
|
|
1060
1068
|
});
|
|
1069
|
+
// A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
|
|
1070
|
+
const [unfit] = explainer ? [] : sources.flatMap((src) => /\.(png|jpe?g|webp)$/i.test(src) ? faults(src).map((f) => ({ src, f })) : []);
|
|
1071
|
+
if (unfit)
|
|
1072
|
+
throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
|
|
1061
1073
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
1062
1074
|
return locked(`${file}.lock`, () => {
|
|
1063
1075
|
const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
|
|
@@ -1072,7 +1084,8 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1072
1084
|
}
|
|
1073
1085
|
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
|
|
1074
1086
|
}
|
|
1075
|
-
|
|
1087
|
+
const answers = readAnswers(file);
|
|
1088
|
+
if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
|
|
1076
1089
|
throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
1077
1090
|
if (own && current.some((d) => d.key === own))
|
|
1078
1091
|
throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
|
|
@@ -1354,7 +1367,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1354
1367
|
return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
1355
1368
|
};
|
|
1356
1369
|
// An answer outlives its item: a new question under that key would inherit it.
|
|
1357
|
-
if (!had && Object.hasOwn(saved, d.key))
|
|
1370
|
+
if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
|
|
1358
1371
|
throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
|
|
1359
1372
|
// The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
|
|
1360
1373
|
const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
@@ -1367,7 +1380,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1367
1380
|
fits(d.title, d.key);
|
|
1368
1381
|
items.push({ filed: now, ...raw[i] });
|
|
1369
1382
|
}
|
|
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 };
|
|
1383
|
+
return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
|
|
1371
1384
|
});
|
|
1372
1385
|
if (raised.some((r) => r.status === 'raised')) {
|
|
1373
1386
|
const before = waitingKeys(file);
|
|
@@ -2078,6 +2091,22 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
|
2078
2091
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2079
2092
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2080
2093
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
2094
|
+
/** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
|
|
2095
|
+
* stored hash, so each picture is measured once. */
|
|
2096
|
+
const unfitMedia = new Map();
|
|
2097
|
+
const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => {
|
|
2098
|
+
if (!unfitMedia.has(m.sha256)) {
|
|
2099
|
+
let found;
|
|
2100
|
+
try {
|
|
2101
|
+
found = faults(under(dirname(file), m.path)).some((f) => f !== 'text');
|
|
2102
|
+
}
|
|
2103
|
+
catch {
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
2106
|
+
unfitMedia.set(m.sha256, found);
|
|
2107
|
+
}
|
|
2108
|
+
return unfitMedia.get(m.sha256);
|
|
2109
|
+
});
|
|
2081
2110
|
const state = () => {
|
|
2082
2111
|
reap();
|
|
2083
2112
|
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
@@ -2088,7 +2117,7 @@ const state = () => {
|
|
|
2088
2117
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
2089
2118
|
// Who filed an item stays in the file: the page only learns that its filer took the answer.
|
|
2090
2119
|
// 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.
|
|
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,
|
|
2120
|
+
decisions: loadDecisions(file).filter((d) => !fakeEvidence(d)).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,
|
|
2092
2121
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2093
2122
|
readonly: readOnly(),
|
|
2094
2123
|
answers_only: answersOnly(),
|
|
@@ -2359,7 +2388,7 @@ async function route(req) {
|
|
|
2359
2388
|
appendRound(file, key, note.trim(), comments);
|
|
2360
2389
|
return [200, state()];
|
|
2361
2390
|
}
|
|
2362
|
-
const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
|
|
2391
|
+
const { key, answer, note = '', notes, decisions, verdict, pick, requested } = JSON.parse(body) ?? {};
|
|
2363
2392
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
2364
2393
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
2365
2394
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
@@ -2381,7 +2410,9 @@ async function route(req) {
|
|
|
2381
2410
|
}
|
|
2382
2411
|
if (d.kind === 'design') {
|
|
2383
2412
|
try {
|
|
2384
|
-
|
|
2413
|
+
const before = readAnswers(file)[d.key]?.answer, a = appendDesignAnswer(file, d, verdict, notes, pick, requested);
|
|
2414
|
+
if (a && !awaitsVerdict(d.type, a) && (verdict !== undefined || a.answer !== before))
|
|
2415
|
+
answered(a, d);
|
|
2385
2416
|
}
|
|
2386
2417
|
catch (e) {
|
|
2387
2418
|
if (e instanceof InputError)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { inflateSync } from 'node:zlib';
|
|
4
|
+
/** A picture of text (a log, receipt or summary) is a few colours: paper, ink and their anti-aliased blends, with no large flat block of a
|
|
5
|
+
* third colour. A real capture of the product has colour, images and icons off that line, or a grey panel covering a good part of the
|
|
6
|
+
* picture. Measured on the calibration set in the PR: text renders, lossy ones too, have at most 0.01% of pixels off the line and a flat
|
|
7
|
+
* third colour of 1.7% at most; real captures have 1.5% or more off the line and a flat third colour of 8.9% or more. */
|
|
8
|
+
const OFF_LINE_MAX = 0.005;
|
|
9
|
+
/** The largest flat area in a third colour (any colour but the paper or the ink), as a share of the picture. */
|
|
10
|
+
const SOLID_MAX = 0.04;
|
|
11
|
+
/** A saturated colour is at least this far between its brightest and dimmest channel; a colour band must cover this share of the picture.
|
|
12
|
+
* A colour-bar card has at least five such bands, together most of the picture; the real captures measured have one at most. */
|
|
13
|
+
const SATURATED = 96;
|
|
14
|
+
const BAND_MIN = 0.05;
|
|
15
|
+
const BARS_MIN = 5;
|
|
16
|
+
const BARS_SHARE_MIN = 0.6;
|
|
17
|
+
/** A placeholder is one flat colour over this share of the picture; the real captures measured top out at 75%, the text renders are text. */
|
|
18
|
+
const PLACEHOLDER_MIN = 0.9;
|
|
19
|
+
/** A picture with no ink at all (a flat colour) is not text, so it passes. */
|
|
20
|
+
const INK_MIN = 0.005;
|
|
21
|
+
/** How close (0-255 RGB distance) a pixel must be to the paper or to the paper-ink line to count as text. */
|
|
22
|
+
const NEAR = 24;
|
|
23
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
24
|
+
/** Samples per pixel for each PNG colour type: grey, RGB, palette, grey with alpha, RGB with alpha. */
|
|
25
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
26
|
+
/** Adam7 interlace passes: x start, y start, x step, y step. A non-interlaced PNG is one pass over every pixel. */
|
|
27
|
+
const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
|
|
28
|
+
const WHOLE = [[0, 0, 1, 1]];
|
|
29
|
+
/** The share of pixels that are ink (a colour off the paper), of pixels off the paper-ink line, and of the largest flat area in any other colour,
|
|
30
|
+
* or undefined when the file cannot be decoded. */
|
|
31
|
+
export function palette(file) {
|
|
32
|
+
const rgb = decode(file);
|
|
33
|
+
if (!rgb)
|
|
34
|
+
return undefined;
|
|
35
|
+
const total = rgb.length / 3, bins = new Uint32Array(32768);
|
|
36
|
+
const centre = (b) => [(b >> 10) * 8 + 4, ((b >> 5) & 31) * 8 + 4, (b & 31) * 8 + 4];
|
|
37
|
+
for (let i = 0; i < rgb.length; i += 3)
|
|
38
|
+
bins[(rgb[i] >> 3) << 10 | (rgb[i + 1] >> 3) << 5 | (rgb[i + 2] >> 3)]++;
|
|
39
|
+
const paperBin = bins.indexOf(Math.max(...bins)), paper = centre(paperBin);
|
|
40
|
+
let inkBin = -1;
|
|
41
|
+
for (let b = 0; b < bins.length; b++) {
|
|
42
|
+
if (bins[b] && dist(centre(b), paper) > 128 && (inkBin < 0 || bins[b] > bins[inkBin]))
|
|
43
|
+
inkBin = b;
|
|
44
|
+
}
|
|
45
|
+
let solid = 0;
|
|
46
|
+
bins.forEach((count, b) => {
|
|
47
|
+
if (b !== paperBin && b !== inkBin && count / total > solid)
|
|
48
|
+
solid = count / total;
|
|
49
|
+
});
|
|
50
|
+
// Saturated flat colours: a colour-bar test card has several, each a band of the picture.
|
|
51
|
+
let bars = 0, barShare = 0;
|
|
52
|
+
bins.forEach((count, b) => {
|
|
53
|
+
const c = centre(b), share = count / total;
|
|
54
|
+
if (Math.max(...c) - Math.min(...c) >= SATURATED && share >= BAND_MIN)
|
|
55
|
+
bars++, (barShare += share);
|
|
56
|
+
});
|
|
57
|
+
const top = bins[paperBin] / total;
|
|
58
|
+
if (inkBin < 0)
|
|
59
|
+
return { ink: 0, off: 0, solid: 0, top, bars, barShare };
|
|
60
|
+
const [pr, pg, pb] = paper, [ar, ag, ab] = centre(inkBin).map((c, i) => c - paper[i]), len2 = ar * ar + ag * ag + ab * ab;
|
|
61
|
+
let ink = 0, off = 0;
|
|
62
|
+
for (let i = 0; i < rgb.length; i += 3) {
|
|
63
|
+
const dr = rgb[i] - pr, dg = rgb[i + 1] - pg, db = rgb[i + 2] - pb;
|
|
64
|
+
if (dr * dr + dg * dg + db * db <= NEAR * NEAR)
|
|
65
|
+
continue;
|
|
66
|
+
const t = Math.min(1, Math.max(0, (dr * ar + dg * ag + db * ab) / len2));
|
|
67
|
+
const er = dr - t * ar, eg = dg - t * ag, eb = db - t * ab;
|
|
68
|
+
if (er * er + eg * eg + eb * eb <= NEAR * NEAR)
|
|
69
|
+
ink++;
|
|
70
|
+
else
|
|
71
|
+
off++;
|
|
72
|
+
}
|
|
73
|
+
return { ink: ink / total, off: off / total, solid, top, bars, barShare };
|
|
74
|
+
}
|
|
75
|
+
/** Why a still is not a picture of the product: text (a log, receipt or summary), colour bars (a test card) or a placeholder (a flat card
|
|
76
|
+
* standing in for a picture). A file that cannot be decoded has no faults: it is not refused. */
|
|
77
|
+
export function faults(file) {
|
|
78
|
+
const share = palette(file);
|
|
79
|
+
if (!share)
|
|
80
|
+
return [];
|
|
81
|
+
const text = share.ink >= INK_MIN && share.off <= OFF_LINE_MAX && share.solid <= SOLID_MAX;
|
|
82
|
+
return [
|
|
83
|
+
...(text ? ['text'] : []),
|
|
84
|
+
...(share.bars >= BARS_MIN && share.barShare >= BARS_SHARE_MIN ? ['colour bars'] : []),
|
|
85
|
+
...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
89
|
+
/** The picture as packed 8-bit RGB. PNG is decoded here; JPEG, WebP and any PNG this decoder refuses go through ImageMagick, when it is installed. */
|
|
90
|
+
function decode(file) {
|
|
91
|
+
const bytes = readFileSync(file);
|
|
92
|
+
if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
93
|
+
const own = pngRgb(bytes);
|
|
94
|
+
if (own)
|
|
95
|
+
return own;
|
|
96
|
+
}
|
|
97
|
+
const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000 });
|
|
98
|
+
return out.status === 0 && out.stdout.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out.stdout) : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
|
|
101
|
+
function pngRgb(buf) {
|
|
102
|
+
try {
|
|
103
|
+
let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
|
|
104
|
+
const idat = [];
|
|
105
|
+
for (let at = 8; at + 8 <= buf.length;) {
|
|
106
|
+
const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
|
|
107
|
+
if (name === 'IHDR')
|
|
108
|
+
[width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
|
|
109
|
+
else if (name === 'PLTE')
|
|
110
|
+
palette = data;
|
|
111
|
+
else if (name === 'IDAT')
|
|
112
|
+
idat.push(data);
|
|
113
|
+
else if (name === 'IEND')
|
|
114
|
+
break;
|
|
115
|
+
at += 12 + len;
|
|
116
|
+
}
|
|
117
|
+
const channels = CHANNELS[type];
|
|
118
|
+
if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
|
|
119
|
+
return undefined;
|
|
120
|
+
const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
|
|
121
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
122
|
+
const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
|
|
123
|
+
let at = 0;
|
|
124
|
+
for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
|
|
125
|
+
const pw = Math.ceil((width - x0) / dx), ph = Math.ceil((height - y0) / dy);
|
|
126
|
+
if (pw <= 0 || ph <= 0)
|
|
127
|
+
continue;
|
|
128
|
+
const rowBytes = Math.ceil(pw * bits / 8), px = Buffer.alloc(rowBytes * ph);
|
|
129
|
+
for (let y = 0; y < ph; y++, at += rowBytes + 1) {
|
|
130
|
+
const filter = raw[at];
|
|
131
|
+
for (let i = 0; i < rowBytes; i++) {
|
|
132
|
+
const a = i >= bpp ? px[y * rowBytes + i - bpp] : 0, b = y ? px[(y - 1) * rowBytes + i] : 0;
|
|
133
|
+
let predict = 0;
|
|
134
|
+
if (filter === 1)
|
|
135
|
+
predict = a;
|
|
136
|
+
else if (filter === 2)
|
|
137
|
+
predict = b;
|
|
138
|
+
else if (filter === 3)
|
|
139
|
+
predict = (a + b) >> 1;
|
|
140
|
+
else if (filter === 4) {
|
|
141
|
+
const c = i >= bpp && y ? px[(y - 1) * rowBytes + i - bpp] : 0, p = a + b - c;
|
|
142
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
143
|
+
predict = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
144
|
+
}
|
|
145
|
+
px[y * rowBytes + i] = (raw[at + 1 + i] + predict) & 0xff;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// The sample at index `n` of a row: a whole byte, a packed value of 1, 2 or 4 bits, or the high byte of 16 bits.
|
|
149
|
+
const sample = (y, n) => {
|
|
150
|
+
if (depth === 16)
|
|
151
|
+
return px[y * rowBytes + n * 2];
|
|
152
|
+
if (depth === 8)
|
|
153
|
+
return px[y * rowBytes + n];
|
|
154
|
+
const shift = 8 - depth - ((n * depth) & 7);
|
|
155
|
+
return (px[y * rowBytes + ((n * depth) >> 3)] >> shift) & ((1 << depth) - 1);
|
|
156
|
+
};
|
|
157
|
+
for (let y = 0; y < ph; y++) {
|
|
158
|
+
for (let x = 0; x < pw; x++) {
|
|
159
|
+
const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * 3;
|
|
160
|
+
if (type === 3) {
|
|
161
|
+
const k = sample(y, n) * 3;
|
|
162
|
+
rgb[o] = palette[k] ?? 0;
|
|
163
|
+
rgb[o + 1] = palette[k + 1] ?? 0;
|
|
164
|
+
rgb[o + 2] = palette[k + 2] ?? 0;
|
|
165
|
+
}
|
|
166
|
+
else if (channels <= 2) {
|
|
167
|
+
rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
rgb[o] = to8(sample(y, n));
|
|
171
|
+
rgb[o + 1] = to8(sample(y, n + 1));
|
|
172
|
+
rgb[o + 2] = to8(sample(y, n + 2));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return rgb;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -280,27 +280,41 @@ function saveAnswer(decisionsPath, d, option, note, comments = [], by) {
|
|
|
280
280
|
appendLine(answersPath(decisionsPath), answer);
|
|
281
281
|
return answer;
|
|
282
282
|
}
|
|
283
|
-
/**
|
|
284
|
-
export
|
|
283
|
+
/** A design answer that holds only screen notes while the design has no design-wide verdict: it still waits for one. */
|
|
284
|
+
export const SCREENS_ONLY = 'screens';
|
|
285
|
+
/** True while an answer leaves the item waiting: none yet, or only screen notes. */
|
|
286
|
+
export const awaitsVerdict = (type, a) => !a || (type === 'design' && a.answer === SCREENS_ONLY);
|
|
287
|
+
const DESIGN_LABEL = { approve: 'Approved', change: 'Changes requested', [SCREENS_ONLY]: 'Screens reviewed' };
|
|
288
|
+
/** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a change on a screen of an approved design sets it to change, and with none yet it waits as screens-only. */
|
|
289
|
+
export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, requested) {
|
|
285
290
|
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
|
|
286
|
-
if (verdict !== 'approve' && verdict !== 'change')
|
|
287
|
-
throw bad('verdict must be approve or change');
|
|
291
|
+
if (verdict !== undefined && verdict !== 'approve' && verdict !== 'change')
|
|
292
|
+
throw bad('verdict must be approve or change, or omitted to save a screen');
|
|
288
293
|
if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
|
|
289
294
|
throw bad('notes must be a list, with at least one note on change');
|
|
295
|
+
if (verdict === undefined && !notes.some(n => n?.journey !== undefined))
|
|
296
|
+
throw bad('a screen save names its screen');
|
|
297
|
+
if (requested === true && (verdict !== undefined || !notes.some(n => n?.journey !== undefined && (n.verdict ?? 'change') === 'change')))
|
|
298
|
+
throw bad('a requested change is a screen save with a change note on a screen');
|
|
290
299
|
const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
|
|
291
300
|
for (const n of notes) {
|
|
292
301
|
const hasJourney = n?.journey !== undefined, hasScreen = n?.screen !== undefined;
|
|
293
|
-
if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) ||
|
|
294
|
-
|
|
302
|
+
if (!n || hasJourney !== hasScreen || (hasJourney && !screens.has(`${n.journey}/${n.screen}`)) ||
|
|
303
|
+
(n.verdict !== undefined && !['approve', 'change'].includes(n.verdict)) ||
|
|
304
|
+
(n.note !== undefined && (!isText(n.note) || [...n.note].length > NOTE_MAX)) || ((n.verdict ?? 'change') === 'change' && !n.note?.trim())) {
|
|
305
|
+
throw bad('each note needs both a known journey and screen, or neither for a design-level note; verdict must be approve or change, with text required on change (500 characters or fewer)');
|
|
295
306
|
}
|
|
296
307
|
}
|
|
297
308
|
if (new Set(notes.map(n => n.journey === undefined ? '' : `${n.journey}/${n.screen}`)).size !== notes.length)
|
|
298
309
|
throw bad('notes must name each screen once, or contain one design-level note');
|
|
299
310
|
const options = d.options ?? [];
|
|
300
|
-
if (
|
|
301
|
-
throw bad('pick must be one listed option,
|
|
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 }) };
|
|
311
|
+
if (pick !== undefined ? !options.some(o => o.value === pick) : verdict !== undefined && options.length > 0)
|
|
312
|
+
throw bad('pick must be one listed option, and a design verdict needs one when options were offered');
|
|
303
313
|
const current = readAnswers(decisionsPath)[d.key];
|
|
314
|
+
const decided = current?.answer === 'approve' || current?.answer === 'change' ? current.answer : undefined;
|
|
315
|
+
const kept = (verdict ?? (decided === 'approve' && requested === true ? 'change' : decided ?? SCREENS_ONLY));
|
|
316
|
+
const keptPick = (pick ?? (options.some(o => o.value === current?.pick) ? current?.pick : undefined));
|
|
317
|
+
const answer = { key: d.key, answer: kept, label: DESIGN_LABEL[kept], close: d.close ?? 'done', at: now(), notes, ...(keptPick !== undefined && { pick: keptPick }) };
|
|
304
318
|
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
|
|
305
319
|
return;
|
|
306
320
|
appendLine(answersPath(decisionsPath), answer);
|
|
Binary file
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -67,34 +67,25 @@
|
|
|
67
67
|
.i-waiting .vis .icon svg { width: 2.25rem; height: 2.25rem; }
|
|
68
68
|
.i-reports .mark { width: 0.375rem; height: 1.25rem; border-radius: 2px; background: var(--area); }
|
|
69
69
|
.pair, .areas { display: grid; gap: 1rem; margin-top: 1rem; }
|
|
70
|
-
/*
|
|
71
|
-
.needs .row
|
|
72
|
-
.needs .row .choices
|
|
70
|
+
/* Needs you first keeps the existing answer controls at a readable width. */
|
|
71
|
+
.needs .row { padding: 0; border: 0; background: none; box-shadow: none; grid-template-columns: minmax(0, 1fr); }
|
|
72
|
+
.needs .row .choices { display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
73
73
|
.needs .row .choices, .needs .rung { max-width: 28rem; }
|
|
74
|
-
.needs .media
|
|
75
|
-
.needs .media >
|
|
76
|
-
.needs .mocks
|
|
77
|
-
.focus .mocks { max-width: 24rem; }
|
|
78
|
-
.focus .shot { aspect-ratio: 3 / 4; }
|
|
74
|
+
.needs .media { flex-wrap: nowrap; }
|
|
75
|
+
.needs .media > * { flex: 1 1 0; min-width: 0; }
|
|
76
|
+
.needs .mocks { grid-auto-columns: minmax(0, 1fr); max-width: 16rem; margin: 0; }
|
|
79
77
|
.needs .shot { aspect-ratio: 1; }
|
|
80
|
-
|
|
81
|
-
.fhead { margin: 0; color: var(--muted); font-size: 0.875rem; font-weight: 650; }
|
|
82
|
-
.pbar { height: 4px; margin: 0.5rem 0 1rem; border-radius: 4px; background: var(--line); overflow: hidden; }
|
|
83
|
-
.pbar i { display: block; height: 100%; background: var(--c-waiting); }
|
|
84
|
-
.focus .since { margin: -0.375rem 0 1rem; font-weight: 600; } .needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
|
|
85
|
-
.focus .row h3 { font-size: 1.75rem; line-height: 1.2; letter-spacing: -0.01em; }
|
|
86
|
-
.focus .choices button, .needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
|
|
87
|
-
@media (min-width: 60rem) { .focus .choices button, .needs .choices .rec { min-height: 2.75rem; } }
|
|
78
|
+
.needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
|
|
88
79
|
/* On Summary the recommended answer is the large button, and the others are pills under it. */
|
|
89
80
|
.needs .choices button { font-weight: 600; } .needs .choices button:not(.rec) { justify-self: start; border-radius: 999px; }
|
|
90
81
|
.needs .choices button[aria-pressed="true"] { padding-right: 2.75rem; }
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
.needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
|
|
83
|
+
@media (min-width: 60rem) { .needs .choices .rec { min-height: 2.75rem; } }
|
|
84
|
+
@media (max-width: 59.999rem) {
|
|
85
|
+
body:has(.decide) { padding-bottom: calc(9rem + env(safe-area-inset-bottom)); }
|
|
86
|
+
.top:has(+ .decide) .back { display: none; }
|
|
87
|
+
}
|
|
88
|
+
|
|
98
89
|
/* Reports: a card per report, its steps as numbered nodes on a line with only the marks that matter under each; Open lists them in place. */
|
|
99
90
|
.reports { display: grid; gap: 0.75rem; }
|
|
100
91
|
.report { display: grid; gap: 0.75rem; scroll-margin-top: 4.5rem; }
|
|
@@ -244,8 +235,72 @@
|
|
|
244
235
|
.readonly .rung iframe { height: 80vh; }
|
|
245
236
|
.row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.875rem; padding: 1rem; scroll-margin-top: 4.5rem;
|
|
246
237
|
background: var(--surface); border-radius: 0.875rem; }
|
|
247
|
-
/* Waiting
|
|
248
|
-
.
|
|
238
|
+
/* Waiting and Decide use separate board cards; the R1 answer renderer remains the source of answer writes. */
|
|
239
|
+
.waiting-lede { margin: 0; color: var(--muted); }
|
|
240
|
+
.plan-hero { display: grid; overflow: hidden; margin: 0 0 2rem; border-radius: 1.125rem; background: var(--surface); }
|
|
241
|
+
.plan-preview { width: 100%; height: 12rem; min-width: 0; overflow: hidden; background: var(--sunk); }
|
|
242
|
+
.plan-preview .rung { height: 100%; }
|
|
243
|
+
.plan-preview .rung .frame, .plan-preview .rung iframe { height: 100%; min-height: 0; border: 0; border-radius: 0; }
|
|
244
|
+
.plan-preview .rung > .meta { display: none; }
|
|
245
|
+
.plan-copy { display: grid; align-content: center; gap: .5rem; padding: 1.25rem; }
|
|
246
|
+
.plan-copy .eyebrow, .ask-card .kind { margin: 0; color: var(--warn); font-size: .875rem; font-weight: 600; }
|
|
247
|
+
.plan-copy h2 { margin: 0; font-size: 1.75rem; }
|
|
248
|
+
.plan-copy .about { margin: 0; color: var(--muted); -webkit-line-clamp: 1; }
|
|
249
|
+
.plan-meta { display: flex; flex-wrap: wrap; gap: .35rem 1rem; color: var(--muted); font-size: .875rem; }
|
|
250
|
+
.plan-meta span { white-space: nowrap; }
|
|
251
|
+
.open-link.plan-open { display: inline-flex; width: fit-content; min-height: 2.75rem; padding: 0 1.25rem; font-size: 1rem; }
|
|
252
|
+
.ask-group { list-style: none; margin: .5rem 0 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
|
|
253
|
+
.ask-group > li { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 4.125rem; padding: .5rem .5rem .5rem 0; }
|
|
254
|
+
.ask-group > li + li { border-top: .5px solid var(--line); }
|
|
255
|
+
.ask-card { display: flex; flex: 1; min-width: 0; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
|
|
256
|
+
.ask-card .icon { width: 2rem; height: 2rem; border-radius: .5rem; background: var(--action); }
|
|
257
|
+
.ask-card .icon svg { width: 1.125rem; height: 1.125rem; stroke: white; }
|
|
258
|
+
.ask-card .copy { display: grid; flex: 1; min-width: 0; gap: .125rem; }
|
|
259
|
+
.ask-card .title { font-size: 1.0625rem; font-weight: 550; line-height: 1.3; }
|
|
260
|
+
.ask-card .meta { margin: 0; color: var(--muted); font-size: .9375rem; }
|
|
261
|
+
.ask-group .x { margin: 0; }
|
|
262
|
+
.decide { max-width: 44rem; margin: 0 auto; padding-bottom: 1rem; }
|
|
263
|
+
.decide-preview { margin: .5rem 0 .75rem; padding: 1rem; min-height: 8rem; display: grid; place-items: center; overflow: hidden; border-radius: 1.125rem; background: var(--surface); }
|
|
264
|
+
.decide-preview > img { display: block; max-width: 100%; max-height: 15rem; object-fit: contain; border-radius: .75rem; }
|
|
265
|
+
.decide-preview .rung { width: 100%; }
|
|
266
|
+
.decide-preview .rung .frame, .decide-preview .rung iframe { max-height: 15rem; }
|
|
267
|
+
.decide-preview .mocks { --tall: min(32svh, 16rem); }
|
|
268
|
+
.decide-preview .shot { aspect-ratio: 236 / 273; }
|
|
269
|
+
.decide-preview .mock figcaption .label { flex: 1 1 auto; min-width: 0; font-size: .75rem; }
|
|
270
|
+
.decide-preview .mock figcaption .badge { padding: .1rem .25rem; font-size: .6875rem; white-space: nowrap; }
|
|
271
|
+
@media (max-width: 59.999rem) {
|
|
272
|
+
.top:has(+ .decide) { padding: .25rem 0 0; }
|
|
273
|
+
.decide-preview { margin: .25rem 0 .5rem; padding: .75rem; }
|
|
274
|
+
.decide-preview .mocks { grid-auto-columns: min(85%, 8rem); --tall: min(20svh, 10rem); }
|
|
275
|
+
.decide-preview .shot { aspect-ratio: 1; }
|
|
276
|
+
.decide .lead h2 { margin: .25rem 0; }
|
|
277
|
+
.decide .pick-label { margin: .5rem 0; }
|
|
278
|
+
.decide .silent-line { margin: .5rem 0; }
|
|
279
|
+
.decide .pick-option { min-height: 3.125rem; padding: .5rem 1rem .5rem 0; }
|
|
280
|
+
body:has(.decide) #toast { bottom: calc(9.5rem + env(safe-area-inset-bottom)); }
|
|
281
|
+
}
|
|
282
|
+
.decide h2 { margin: .5rem 0 .5rem; font-size: 1.75rem; line-height: 1.15; letter-spacing: -.01em; }
|
|
283
|
+
.decide .why { margin: 0; color: var(--muted); font-size: 1.0625rem; font-weight: 400; }
|
|
284
|
+
.pick-label { margin: 1rem 0 .5rem; color: var(--muted); font-size: .875rem; letter-spacing: .02em; }
|
|
285
|
+
.pick-list { list-style: none; margin: 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
|
|
286
|
+
.pick-list li + li { border-top: .5px solid var(--line); }
|
|
287
|
+
.pick-option { display: flex; width: 100%; min-height: 2.75rem; align-items: center; gap: .75rem; padding: .5rem 1rem .5rem 0; border: 0; border-radius: 0; background: transparent; text-align: left; }
|
|
288
|
+
.pick-option .words { display: grid; flex: 1; min-width: 0; gap: .125rem; }
|
|
289
|
+
.pick-option .label-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem; }
|
|
290
|
+
.pick-option .label { font-size: 1.25rem; line-height: 1.3; }
|
|
291
|
+
.pick-option .reason { color: var(--muted); font-size: 1rem; line-height: 1.35; }
|
|
292
|
+
.pick-option .recommend { color: var(--accent); font-size: .9375rem; }
|
|
293
|
+
.pick-option .check { width: 1.5rem; color: var(--accent); font-size: 1.5rem; visibility: hidden; }
|
|
294
|
+
.pick-option[aria-pressed="true"] { color: var(--text); background: transparent; }
|
|
295
|
+
.pick-option[aria-pressed="true"]::before { content: none; }
|
|
296
|
+
.pick-option[aria-pressed="true"] .check { visibility: visible; }
|
|
297
|
+
.silent-line { margin: 1.25rem 0; color: var(--muted); }
|
|
298
|
+
.decide-bar { position: fixed; z-index: 1; left: 0; right: 0; bottom: calc(4.125rem + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: .5rem;
|
|
299
|
+
padding: .75rem max(1.25rem, env(safe-area-inset-right)) .75rem max(1.25rem, env(safe-area-inset-left)); background: var(--bg); }
|
|
300
|
+
.decide-bar .answer { flex: 1; background: var(--action); color: var(--on-accent); }
|
|
301
|
+
.decide-bar .later { display: inline-flex; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0 0.75rem; color: var(--accent); background: transparent; }
|
|
302
|
+
.decide-bar .skip { color: var(--muted); background: transparent; }
|
|
303
|
+
.ask-group + .gone-asks { margin-top: 1rem; }
|
|
249
304
|
.in-answered .row { background: transparent; box-shadow: none; }
|
|
250
305
|
.in-answered .row h3 { color: var(--muted); font-weight: 500; }
|
|
251
306
|
/* A sign-in: one big Open for its link, off once the link expired. */
|
|
@@ -359,6 +414,27 @@
|
|
|
359
414
|
.design-review .foot > * { flex: 1; }
|
|
360
415
|
.design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
|
|
361
416
|
.design-review > a { display: block; max-width: 56rem; margin: 1rem auto; color: var(--accent); }
|
|
417
|
+
.design-viewer { position: fixed; inset: 0; width: 100%; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: var(--bg); color: var(--text); }
|
|
418
|
+
.design-viewer[open] { display: flex; flex-direction: column; }
|
|
419
|
+
.design-viewer header { display: flex; gap: 1rem; align-items: center; padding: 0.75rem max(1rem, env(safe-area-inset-right)) 0.75rem max(1rem, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); }
|
|
420
|
+
.design-viewer header > div { flex: 1; min-width: 0; max-height: 22dvh; overflow: auto; }
|
|
421
|
+
.design-viewer h2, .design-viewer p { margin: 0; }
|
|
422
|
+
.design-viewer h2 { font-size: 1.25rem; line-height: 1.2; }
|
|
423
|
+
.design-viewer .meta { font-size: 1rem; }
|
|
424
|
+
.design-viewer-panel button { padding: 0 0.75rem; font-size: 1rem; white-space: nowrap; }
|
|
425
|
+
.design-viewer button, .design-viewer select { min-height: 2.75rem; }
|
|
426
|
+
.design-viewer-body { display: grid; grid-template-rows: minmax(0, 1fr) auto; flex: 1; min-height: 0; }
|
|
427
|
+
.design-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--sunk); touch-action: pan-y; }
|
|
428
|
+
.design-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
|
429
|
+
.design-stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--surface); }
|
|
430
|
+
.design-viewer-panel { overflow: auto; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)); background: var(--surface); }
|
|
431
|
+
.design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
|
|
432
|
+
.design-viewer-panel .foot > * { flex: 1; }
|
|
433
|
+
.design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
|
|
434
|
+
.design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
|
|
435
|
+
.design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
|
|
436
|
+
@media (min-width: 60rem) { .design-viewer header h2 { font-size: 2.125rem; } .design-viewer-body { grid-template: minmax(0, 1fr) / minmax(0, 1fr) 22.5rem; } .design-viewer-panel { padding: 1.25rem; } }
|
|
437
|
+
@media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }
|
|
362
438
|
.needs .foot .open-link { min-height: 2.75rem; padding: 0 1rem; background: var(--action); color: var(--on-accent); text-align: center; }
|
|
363
439
|
/* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
|
|
364
440
|
.gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
|
|
@@ -416,14 +492,18 @@
|
|
|
416
492
|
button:focus-visible, summary:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
417
493
|
.shot:focus-visible { outline-offset: -2px; }
|
|
418
494
|
.empty { margin: 0; padding: 1.5rem 1rem; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: 0.875rem; }
|
|
495
|
+
@media (max-width: 44.999rem) {
|
|
496
|
+
.plan-preview { display: none; }
|
|
497
|
+
.plan-copy .about { display: block; -webkit-line-clamp: unset; -webkit-box-orient: initial; overflow: visible; }
|
|
498
|
+
}
|
|
419
499
|
@media (min-width: 45rem) {
|
|
420
500
|
main { padding: 0 2rem; }
|
|
421
501
|
.row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
|
|
422
|
-
.in-waiting .row { padding-left: calc(1.25rem - 3px); }
|
|
423
502
|
.row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
|
|
424
503
|
/* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
|
|
425
504
|
.mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
|
|
426
505
|
.rung iframe { height: min(32rem, 75vh); }
|
|
506
|
+
.plan-hero { grid-template-columns: 46% minmax(0, 1fr); min-height: 20rem; }
|
|
427
507
|
/* A picture card stacks like a design card: the picture, then its buttons at their own width. */
|
|
428
508
|
.row:has(> .rung) { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
|
|
429
509
|
.row:has(> .rung) .choices { justify-content: start; }
|
|
@@ -456,12 +536,11 @@
|
|
|
456
536
|
.tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
457
537
|
.pair { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
|
458
538
|
.areas { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
459
|
-
.
|
|
460
|
-
.
|
|
461
|
-
.
|
|
462
|
-
.
|
|
463
|
-
.
|
|
464
|
-
.hint, .upnext { display: none; }
|
|
539
|
+
.plan-preview { height: 20rem; }
|
|
540
|
+
.plan-preview .rung .frame, .plan-preview .rung iframe { min-height: 0; }
|
|
541
|
+
.plan-copy { padding: 2rem; }
|
|
542
|
+
.plan-copy h2, .decide h2 { font-size: 2.5rem; line-height: 1.1; }
|
|
543
|
+
.decide-bar { position: static; margin: 1rem 0 0; padding: .75rem 0; background: transparent; }
|
|
465
544
|
.reports { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
466
545
|
/* Answered sets each row as its own card, two across; an opened row takes the whole width. */
|
|
467
546
|
.rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
|
|
@@ -469,7 +548,6 @@
|
|
|
469
548
|
.rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
|
|
470
549
|
/* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
|
|
471
550
|
.list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
|
|
472
|
-
.keys { display: block; }
|
|
473
551
|
}
|
|
474
552
|
</style>
|
|
475
553
|
</head>
|
|
@@ -511,20 +589,31 @@
|
|
|
511
589
|
|
|
512
590
|
const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
|
|
513
591
|
|
|
514
|
-
function
|
|
515
|
-
|
|
592
|
+
function lock(...btns) {
|
|
593
|
+
const controls = [...btns, ...(btns[0].closest('.decide, .row')?.querySelectorAll('button') ?? [])].filter((b) => !b.disabled);
|
|
594
|
+
controls.forEach((b) => (b.disabled = true));
|
|
595
|
+
return () => controls.forEach((b) => (b.disabled = false));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function save(d, o, buttons, err, after) {
|
|
599
|
+
const unlock = lock(...buttons);
|
|
516
600
|
// An answers-only board takes no comments, and words sent as the answer are the answer itself (a reply item's also go as a comment).
|
|
517
601
|
const typed = state.answers_only || (o.value === 'text' && !d.reply) ? '' : (drafts.get(d.key) ?? '').trim();
|
|
518
602
|
const note = drafts.get(d.key) ?? noteOf(d), before = answerOf(d.key);
|
|
519
603
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
604
|
+
try {
|
|
605
|
+
await call('/api/answers', {
|
|
606
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
|
|
607
|
+
}, () => { forget(d.key); if (!typed) drop(); });
|
|
608
|
+
} catch (e) {
|
|
525
609
|
shown(err).textContent = `Couldn't save: ${e.message}`;
|
|
526
|
-
|
|
527
|
-
}
|
|
610
|
+
return unlock();
|
|
611
|
+
}
|
|
612
|
+
offerUndo(d, before);
|
|
613
|
+
after?.();
|
|
614
|
+
if (typed) call('/api/messages', {
|
|
615
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
|
|
616
|
+
}, drop).catch(() => say('Could not save your comment', 8000));
|
|
528
617
|
}
|
|
529
618
|
|
|
530
619
|
const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
|
|
@@ -550,11 +639,12 @@
|
|
|
550
639
|
undoBtn.setAttribute('aria-label', `Undo, ${headline(d)}`);
|
|
551
640
|
say(`Saved: ${now.answer === 'text' ? 'your reply' : now.label}`, 5000, () => {
|
|
552
641
|
undoBtn.disabled = true;
|
|
553
|
-
cur = d.key; // Triage shows the ask again.
|
|
554
642
|
call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
555
643
|
body: JSON.stringify({ key: d.key, answer: before?.answer ?? '', note: before?.note ?? '' }) })
|
|
556
|
-
.then(() =>
|
|
557
|
-
(
|
|
644
|
+
.then(() => {
|
|
645
|
+
if (!before) location.hash = askHref(d);
|
|
646
|
+
say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000);
|
|
647
|
+
}, (e) => say(`Couldn't undo: ${e.message}`, 8000));
|
|
558
648
|
});
|
|
559
649
|
// The tap redrew its card and took the focus with it: put it on Undo so the keyboard can reach it in time.
|
|
560
650
|
if (document.activeElement === document.body || !document.activeElement) undoBtn.focus();
|
|
@@ -565,11 +655,11 @@
|
|
|
565
655
|
|
|
566
656
|
/** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
|
|
567
657
|
function send(btn, err, body, draft, text) {
|
|
568
|
-
|
|
658
|
+
const unlock = lock(btn);
|
|
569
659
|
call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
|
|
570
660
|
clearIfSaved(draft, drafts.get(draft))).catch((e) => {
|
|
571
661
|
shown(err).textContent = `Couldn't send: ${e.message}`;
|
|
572
|
-
|
|
662
|
+
unlock();
|
|
573
663
|
});
|
|
574
664
|
}
|
|
575
665
|
|
|
@@ -680,17 +770,20 @@
|
|
|
680
770
|
ro ? '' : other(d, saved, err), err, tech(d));
|
|
681
771
|
}
|
|
682
772
|
|
|
773
|
+
/** A count with its unit in the form it deserves: 1 section, 2 sections. */
|
|
774
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
683
775
|
/** A plan: its one-line answer, how many sections and decisions it asks about, and one Open plan for its page over the board. */
|
|
684
776
|
function plan(d, lead) {
|
|
685
|
-
const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
686
777
|
return el('article', { class: 'row plan', id: `item-${d.key}` }, el('div', {}, lead,
|
|
687
|
-
el('p', { class: 'meta' }, `${
|
|
778
|
+
el('p', { class: 'meta' }, `${plural(d.sections.length, 'section')} · ${plural(d.decisions.length, 'decision')}`)),
|
|
688
779
|
el('a', { class: 'open-link', href: `#${area(d)}/${d.key}`, 'aria-label': `Open plan, ${d.title}` }, 'Open plan'));
|
|
689
780
|
}
|
|
781
|
+
/** A span of minutes: whole minutes under the hour, hours and the rest over it. */
|
|
782
|
+
const mins = (m) => (m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`);
|
|
690
783
|
/** The time left on a sign-in link, in whole minutes. */
|
|
691
784
|
const expiry = (at) => {
|
|
692
785
|
const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000);
|
|
693
|
-
return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${
|
|
786
|
+
return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${mins(m)}`;
|
|
694
787
|
};
|
|
695
788
|
/** A sign-in: the service, what it unlocks, who waits, the time left and one Open for its link. Its poster closes it with signin done. */
|
|
696
789
|
function signin(d, lead, saved, err) {
|
|
@@ -751,24 +844,24 @@
|
|
|
751
844
|
/** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
|
|
752
845
|
* Sent on its own the words are the answer (`text`); typed before a button is picked they go with that answer as its note.
|
|
753
846
|
* On a normal board they can also ask for another round (not on an evidence update) or go as a comment. */
|
|
754
|
-
function other(d, saved, err) {
|
|
847
|
+
function other(d, saved, err, after) {
|
|
755
848
|
const box = el('textarea', { id: `note-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' });
|
|
756
849
|
box.value = drafts.get(d.key) ?? (saved?.answer === 'text' ? '' : noteOf(d));
|
|
757
850
|
box.oninput = () => { drafts.set(d.key, box.value); keep(); };
|
|
758
851
|
// An option valued text would be the same answer, so that item takes words only as a note.
|
|
759
852
|
const words = !d.options.some((o) => o.value === 'text'), talk = !state.answers_only, round = talk && d.type !== 'evidence';
|
|
760
853
|
const sendBtn = el('button', { 'aria-label': `Send reply, ${headline(d)}` }, 'Send reply');
|
|
761
|
-
sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err) : (shown(err).textContent = 'Write the reply first.'));
|
|
854
|
+
sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err, after) : (shown(err).textContent = 'Write the reply first.'));
|
|
762
855
|
const roundBtn = el('button', { class: 'quick', 'aria-label': `Another round, ${headline(d)}` }, 'Another round');
|
|
763
856
|
roundBtn.onclick = () => {
|
|
764
|
-
|
|
857
|
+
const unlock = lock(roundBtn);
|
|
765
858
|
const note = (drafts.get(d.key) ?? noteOf(d)).trim();
|
|
766
859
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
767
860
|
call('/api/rounds', {
|
|
768
861
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
|
|
769
862
|
}, () => { forget(d.key); drop(); }).catch((e) => {
|
|
770
863
|
shown(err).textContent = `Couldn't ask: ${e.message}`;
|
|
771
|
-
|
|
864
|
+
unlock();
|
|
772
865
|
});
|
|
773
866
|
};
|
|
774
867
|
const commentBtn = el('button', { 'aria-label': `Send as a comment, ${headline(d)}` }, 'Send as a comment');
|
|
@@ -955,7 +1048,8 @@
|
|
|
955
1048
|
you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
|
|
956
1049
|
// The drawings are fixed strings from this page, never text from the board file.
|
|
957
1050
|
const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
|
|
958
|
-
|
|
1051
|
+
// A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
|
|
1052
|
+
const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
|
|
959
1053
|
/** Each item lives in one area: answered items, then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks.
|
|
960
1054
|
* A plan has no options but waits for its send-back. */
|
|
961
1055
|
const area = (d) => {
|
|
@@ -967,6 +1061,13 @@
|
|
|
967
1061
|
/** A page to open: an .html view, framed in a sandbox. */
|
|
968
1062
|
const reads = (d) => /\.html$/i.test(d.view ?? '');
|
|
969
1063
|
const hhmm = (at) => new Date(at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
1064
|
+
/** Where an item opens: its own area, and inside Waiting on you a plan or a design has its own screen; every other ask opens on Decide. */
|
|
1065
|
+
const askHref = (d) => {
|
|
1066
|
+
const a = area(d);
|
|
1067
|
+
if (a !== 'waiting') return `#${a === 'notes' ? 'reports' : a}`;
|
|
1068
|
+
return d.type === 'plan' ? `#waiting/${d.key}` : d.kind === 'design' ? `#designs/${d.key}` : `#decide/${d.key}`;
|
|
1069
|
+
};
|
|
1070
|
+
const left = (at) => { const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000); return m <= 0 ? 'deadline passed' : `${mins(m)} left`; };
|
|
970
1071
|
const isToday = (at) => new Date(at).toDateString() === new Date().toDateString();
|
|
971
1072
|
const day = (at) => new Date(at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
972
1073
|
const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
|
|
@@ -1023,7 +1124,7 @@
|
|
|
1023
1124
|
/** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
|
|
1024
1125
|
function section(a, items) {
|
|
1025
1126
|
const SHOWN = 4, line = (d) => {
|
|
1026
|
-
const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : `#${a}` }, ...compact(d, a));
|
|
1127
|
+
const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : a === 'waiting' ? askHref(d) : `#${a}` }, ...compact(d, a));
|
|
1027
1128
|
link.onclick = () => (jump = d.key);
|
|
1028
1129
|
return el('li', {}, link, xBtn(d));
|
|
1029
1130
|
};
|
|
@@ -1131,7 +1232,7 @@
|
|
|
1131
1232
|
}
|
|
1132
1233
|
main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
|
|
1133
1234
|
if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
|
|
1134
|
-
el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length}
|
|
1235
|
+
el('p', { class: 'plan-summary' }, icon('plan'), `${plural(d.sections.length, 'section')} to approve or change, and ${plural(d.decisions.length, 'decision')} to make. Nothing is built until you send back.`));
|
|
1135
1236
|
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.'));
|
|
1136
1237
|
d.sections.forEach((s, i) => {
|
|
1137
1238
|
const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
|
|
@@ -1206,6 +1307,11 @@
|
|
|
1206
1307
|
return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
|
|
1207
1308
|
}
|
|
1208
1309
|
|
|
1310
|
+
let designSession, boardStale = false;
|
|
1311
|
+
addEventListener('message', (e) => {
|
|
1312
|
+
const viewer = document.querySelector('.design-viewer[open]');
|
|
1313
|
+
if (viewer && e.source === viewer.querySelector('iframe')?.contentWindow && e.data?.['sign-off'] === 'viewer-nav') viewer.dispatchEvent(new KeyboardEvent('keydown', { key: e.data.key }));
|
|
1314
|
+
});
|
|
1209
1315
|
function designReview(d) {
|
|
1210
1316
|
const saved = state.answers[d.key], screens = (d.journeys ?? []).flatMap((journey) => journey.screens.map((screen) => ({ journey, screen }))), savedNote = saved?.notes?.[0];
|
|
1211
1317
|
const screenKey = (target) => target && `${target.journey.id}/${target.screen.id}`, screenFor = (key) => screens.find((target) => screenKey(target) === key);
|
|
@@ -1214,11 +1320,19 @@
|
|
|
1214
1320
|
for (const option of d.options ?? []) pick.append(el('option', { value: option.value }, option.label));
|
|
1215
1321
|
pick.value = saved?.pick ?? '';
|
|
1216
1322
|
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
|
-
|
|
1323
|
+
if (designSession?.key !== d.key) designSession = { key: d.key, open: false, selected: '', drafts: new Map() };
|
|
1324
|
+
const session = designSession;
|
|
1325
|
+
const selectedKey = session.selected || (savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]));
|
|
1218
1326
|
if (screenSelect) screenSelect.value = screenFor(selectedKey) ? selectedKey : screenKey(screens[0]);
|
|
1219
1327
|
const target = () => screenSelect ? screenFor(screenSelect.value) : screens[0];
|
|
1220
|
-
|
|
1221
|
-
const
|
|
1328
|
+
const currentNote = () => state.answers[d.key]?.notes?.find((n) => n.journey === target()?.journey.id && n.screen === target()?.screen.id);
|
|
1329
|
+
const screenLine = () => (currentNote() ? (currentNote().verdict === 'approve' ? 'Screen approved' : 'Changes requested') : 'Not reviewed yet');
|
|
1330
|
+
note.value = session.drafts.get(screenKey(target())) ?? currentNote()?.note ?? '';
|
|
1331
|
+
note.oninput = () => session.drafts.set(screenKey(target()), note.value);
|
|
1332
|
+
const previewPath = () => {
|
|
1333
|
+
const views = target()?.screen.views, form = innerWidth < 960 ? 'phone' : 'computer', theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
1334
|
+
return views?.[`${form}_${theme}`] || views?.[`${form}_${theme === 'dark' ? 'light' : 'dark'}`] || (views && Object.values(views)[0]) || d.preview;
|
|
1335
|
+
};
|
|
1222
1336
|
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
1337
|
const preview = previewPath() ? el('div', { class: 'design-preview-window' }, previewElement(previewPath())) : '';
|
|
1224
1338
|
const fullPreview = previewPath() ? el('a', { class: 'design-preview-full', href: src(designUrl(d, previewPath())), target: '_blank', rel: 'noopener noreferrer' }, 'Open full preview') : '';
|
|
@@ -1226,23 +1340,91 @@
|
|
|
1226
1340
|
const path = previewPath();
|
|
1227
1341
|
if (preview) preview.replaceChildren(previewElement(path));
|
|
1228
1342
|
if (fullPreview) fullPreview.href = src(designUrl(d, path));
|
|
1343
|
+
session.selected = screenKey(target());
|
|
1344
|
+
note.value = session.drafts.get(session.selected) ?? currentNote()?.note ?? '';
|
|
1229
1345
|
};
|
|
1230
1346
|
const status = el('p', { class: 'meta', role: 'status' }, saved ? saved.label : d.status === 'draft' ? 'Draft' : 'In review');
|
|
1231
1347
|
const err = el('p', { class: 'err', role: 'alert' });
|
|
1348
|
+
let saving = false;
|
|
1232
1349
|
const save = (verdict) => {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1350
|
+
const screen = session.open;
|
|
1351
|
+
if (!screen && d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
|
|
1352
|
+
// A screen save (the viewer) sends no design-wide verdict; a note carries the verdict of the button that saved it, on a screen or at design level.
|
|
1353
|
+
const selected = target(), text = note.value.trim(), writes = (selected && screen) || text, live = new Set(screens.map(screenKey));
|
|
1354
|
+
const notes = (state.answers[d.key]?.notes ?? []).filter((n) => (!n.journey || live.has(`${n.journey}/${n.screen}`)) && (n.journey !== selected?.journey.id || n.screen !== selected?.screen.id));
|
|
1355
|
+
if (writes) notes.push({ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), verdict, note: text });
|
|
1356
|
+
saving = true; approve.disabled = change.disabled = true;
|
|
1357
|
+
return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
|
|
1358
|
+
notes, ...(pick.value && { pick: pick.value }) }) }, () => session.drafts.delete(screenKey(selected)))
|
|
1359
|
+
.finally(() => { saving = false; approve.disabled = change.disabled = state.readonly || !!state.answers_only; if (session.open) screenStatus.textContent = screenLine(); });
|
|
1237
1360
|
};
|
|
1238
1361
|
const approve = el('button', { class: 'open-link' }, 'Approve'), change = el('button', {}, 'Change');
|
|
1239
1362
|
approve.disabled = change.disabled = state.readonly || !!state.answers_only;
|
|
1240
1363
|
approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
|
|
1241
1364
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1365
|
+
const controls = el('div', {}, d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
|
|
1366
|
+
el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err);
|
|
1367
|
+
const open = el('button', { class: 'open-link', 'aria-haspopup': 'dialog', 'data-design': d.key }, 'Review full screen');
|
|
1368
|
+
const root = el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
|
|
1369
|
+
screens.length ? open : '', screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', controls,
|
|
1245
1370
|
d.link ? el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer' }, 'Open original design') : '');
|
|
1371
|
+
if (!screens.length) return root;
|
|
1372
|
+
const heading = el('h2', { id: 'design-screen-title', dir: 'auto' }), count = el('p', { class: 'meta', 'aria-live': 'polite' });
|
|
1373
|
+
const stage = el('div', { class: 'design-stage' }), screenStatus = el('p', { class: 'meta', role: 'status' });
|
|
1374
|
+
const previous = el('button', { 'aria-label': 'Previous screen' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next screen' }, 'Next ›');
|
|
1375
|
+
const close = el('button', { 'aria-label': 'Close viewer' }, 'Close');
|
|
1376
|
+
const viewer = el('dialog', { class: 'design-viewer', 'aria-labelledby': heading.id }, el('header', {}, el('div', {}, count, heading), close),
|
|
1377
|
+
el('div', { class: 'design-viewer-body' }, stage, el('section', { class: 'design-viewer-panel', 'aria-label': 'Screen review' },
|
|
1378
|
+
el('div', { class: 'foot' }, previous, next), screenStatus)));
|
|
1379
|
+
const panel = viewer.querySelector('.design-viewer-panel');
|
|
1380
|
+
const draw = () => {
|
|
1381
|
+
const i = screens.indexOf(target());
|
|
1382
|
+
heading.textContent = `${target().journey.title} — ${target().screen.title}`;
|
|
1383
|
+
count.textContent = `${i + 1} of ${screens.length}`;
|
|
1384
|
+
previous.disabled = i === 0; next.disabled = i === screens.length - 1;
|
|
1385
|
+
stage.replaceChildren(previewElement(previewPath())); stage.scrollTop = 0;
|
|
1386
|
+
screenStatus.textContent = screenLine();
|
|
1387
|
+
approve.textContent = 'Approve screen'; change.textContent = 'Request change'; err.textContent = '';
|
|
1388
|
+
};
|
|
1389
|
+
const step = (delta) => {
|
|
1390
|
+
const i = screens.indexOf(target()) + delta;
|
|
1391
|
+
if (i < 0 || i >= screens.length || saving) return;
|
|
1392
|
+
session.drafts.set(screenKey(target()), note.value);
|
|
1393
|
+
if (screenSelect) { screenSelect.value = screenKey(screens[i]); screenSelect.onchange(); }
|
|
1394
|
+
draw();
|
|
1395
|
+
};
|
|
1396
|
+
previous.onclick = () => step(-1); next.onclick = () => step(1);
|
|
1397
|
+
const key = (e) => {
|
|
1398
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); viewer.close(); }
|
|
1399
|
+
else if (!e.target.closest?.('textarea, input, select') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
1400
|
+
e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1);
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
viewer.onkeydown = key;
|
|
1404
|
+
let touch;
|
|
1405
|
+
stage.addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
|
|
1406
|
+
stage.addEventListener('touchend', (e) => {
|
|
1407
|
+
const end = e.changedTouches[0];
|
|
1408
|
+
if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) step(end.clientX < touch.clientX ? 1 : -1);
|
|
1409
|
+
touch = null;
|
|
1410
|
+
});
|
|
1411
|
+
const controlsNext = controls.nextSibling;
|
|
1412
|
+
open.onclick = () => {
|
|
1413
|
+
session.open = true; panel.append(controls); root.append(viewer); draw(); viewer.showModal();
|
|
1414
|
+
};
|
|
1415
|
+
close.onclick = () => viewer.close();
|
|
1416
|
+
viewer.onclose = () => {
|
|
1417
|
+
session.open = false;
|
|
1418
|
+
if (boardStale) {
|
|
1419
|
+
boardStale = false;
|
|
1420
|
+
render();
|
|
1421
|
+
document.querySelector(`[data-design="${CSS.escape(d.key)}"]`)?.focus();
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
root.insertBefore(controls, controlsNext); approve.textContent = 'Approve'; change.textContent = 'Change';
|
|
1425
|
+
open.focus();
|
|
1426
|
+
};
|
|
1427
|
+
return root;
|
|
1246
1428
|
}
|
|
1247
1429
|
|
|
1248
1430
|
/** 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.
|
|
@@ -1324,47 +1506,95 @@
|
|
|
1324
1506
|
sel.onchange = () => { waitSort = sel.value; try { localStorage.setItem(SORT_KEY, waitSort); } catch {} reshow('.sort select'); };
|
|
1325
1507
|
return el('div', { class: 'filters' }, el('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by type' }, ...chips), el('label', { class: 'sort' }, 'Sort', sel));
|
|
1326
1508
|
}
|
|
1327
|
-
/**
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
el('
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1509
|
+
/** The plan is the lead card; the remaining asks are a grouped list linking to a focused decision. */
|
|
1510
|
+
function planCard(d) {
|
|
1511
|
+
const meta = [el('span', {}, plural(d.sections.length, 'section')), el('span', {}, plural(d.decisions.length, 'decision')),
|
|
1512
|
+
el('span', {}, `Agent${d.filed ? ` · ${when(d.filed)}` : ''}`)];
|
|
1513
|
+
return el('article', { class: 'plan-hero', id: `item-${d.key}` }, el('div', { class: 'plan-preview' }, rung(d)),
|
|
1514
|
+
el('div', { class: 'plan-copy' }, el('p', { class: 'eyebrow' }, `Plan${d.project ? ` · ${d.project}` : ''}`),
|
|
1515
|
+
el('h2', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about ?? ''),
|
|
1516
|
+
el('div', { class: 'plan-meta' }, ...meta), el('a', { class: 'open-link plan-open', href: `#waiting/${d.key}` }, 'Open plan')));
|
|
1517
|
+
}
|
|
1518
|
+
const askIcon = (d) => { const a = { design: 'designs', signin: 'you', plan: 'plan' }[typeOf(d)] ?? 'waiting'; return ICONS[a] ? a : 'waiting'; };
|
|
1519
|
+
function waitingPage(items, hidden) {
|
|
1520
|
+
const plans = items.filter((d) => d.type === 'plan'), asks = items.filter((d) => d.type !== 'plan');
|
|
1521
|
+
const rows = asks.map((d) => {
|
|
1522
|
+
const label = `Agent${d.silent_by ? ` · closes at ${when(d.silent_by)}` : d.filed ? ` · waiting since ${when(d.filed)}` : ''}`;
|
|
1523
|
+
const link = el('a', { class: 'ask-card', id: `item-${d.key}`, href: askHref(d) }, icon(askIcon(d)),
|
|
1524
|
+
el('span', { class: 'copy' }, el('span', { class: 'kind' }, kind(d)), el('strong', { class: 'title', dir: 'auto' }, title(d)),
|
|
1525
|
+
d.why || d.about ? el('span', { class: 'meta', dir: 'auto' }, d.why || d.about) : '', el('span', { class: 'meta' }, label)));
|
|
1526
|
+
return el('li', {}, link, xBtn(d));
|
|
1345
1527
|
});
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
})
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1528
|
+
return el('div', { class: 'waiting-page' },
|
|
1529
|
+
...plans.map(planCard), ...(asks.length ? [el('p', { class: 'pick-label' }, 'ASKS'), el('ul', { class: 'ask-group' }, ...rows)] : []),
|
|
1530
|
+
!items.length ? el('p', { class: 'empty' }, EMPTY.waiting) : '', dismissed(hidden));
|
|
1531
|
+
}
|
|
1532
|
+
const selected = new Map();
|
|
1533
|
+
function decidePage(d, next) {
|
|
1534
|
+
if (!d) return el('p', { class: 'empty' }, EMPTY.waiting);
|
|
1535
|
+
const to = next ? `#decide/${next.key}` : '#waiting', skip = next && !state.readonly ? el('button', { class: 'skip' }, 'Skip for now') : null;
|
|
1536
|
+
if (skip) skip.onclick = () => (location.hash = to);
|
|
1537
|
+
const swipe = (page, right) => {
|
|
1538
|
+
let start;
|
|
1539
|
+
page.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
|
|
1540
|
+
page.addEventListener('touchend', (e) => {
|
|
1541
|
+
const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
|
|
1542
|
+
if (!start || Math.abs(dx) <= 90 || Math.abs(t.clientY - start.clientY) >= Math.abs(dx) / 2) return;
|
|
1543
|
+
if (dx > 0) right?.(); else skip?.click();
|
|
1544
|
+
});
|
|
1545
|
+
};
|
|
1546
|
+
if (d.type === 'signin') {
|
|
1547
|
+
const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later')));
|
|
1548
|
+
if (!state.readonly) swipe(signinPage);
|
|
1549
|
+
return signinPage;
|
|
1550
|
+
}
|
|
1551
|
+
const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` }), opts = d.options ?? [], live = !state.readonly;
|
|
1552
|
+
// A pick remembered from an earlier round only counts while the ask still offers it.
|
|
1553
|
+
if (!opts.some((o) => o.value === selected.get(d.key))) selected.delete(d.key);
|
|
1554
|
+
const initial = selected.get(d.key) ?? d.recommend_value ?? opts[0]?.value;
|
|
1555
|
+
const optionButtons = opts.map((o) => {
|
|
1556
|
+
const recommended = o.value === d.recommend_value, b = el('button', { class: 'pick-option', 'aria-pressed': String(o.value === initial) },
|
|
1557
|
+
el('span', { class: 'words' }, el('span', { class: 'label-line' }, el('span', { class: 'label' }, o.label),
|
|
1558
|
+
recommended ? el('span', { class: 'recommend' }, 'Recommended') : ''), o.outcome ? el('span', { class: 'reason', dir: 'auto' }, o.outcome) : ''), el('span', { class: 'check', 'aria-hidden': 'true' }, '✓'));
|
|
1559
|
+
b.onclick = () => { selected.set(d.key, o.value); optionButtons.forEach((btn) => btn.setAttribute('aria-pressed', String(btn === b))); answer.textContent = `Answer ${o.label}`; };
|
|
1560
|
+
return b;
|
|
1561
|
+
});
|
|
1562
|
+
const selectedOption = () => opts.find((o) => o.value === (selected.get(d.key) ?? initial));
|
|
1563
|
+
const answer = el('button', { class: 'answer' }, `Answer ${selectedOption()?.label ?? 'now'}`);
|
|
1564
|
+
const answerWith = (o) => save(d, o, [...optionButtons, answer], err, () => (location.hash = to));
|
|
1565
|
+
answer.onclick = () => { const o = selectedOption(); if (o) answerWith(o); };
|
|
1566
|
+
const recOpt = opts.find((o) => o.value === d.recommend_value);
|
|
1567
|
+
const cards = d.mocks?.length && d.options?.length ? mockCards(d, []) : null;
|
|
1568
|
+
const preview = cards ?? (d.view ? rung(d) : pic(d) ? thumb('waiting', pic(d)) : proof(d));
|
|
1569
|
+
const quiet = opts.find((o) => o.value === d.default_if_silent);
|
|
1570
|
+
const silent = quiet ? `If you stay quiet: ${quiet.label}${d.silent_by ? ` · ${left(d.silent_by)}` : ''}` : '';
|
|
1571
|
+
const head = el('div', { class: 'lead' }, el('p', { class: 'eyebrow' }, kind(d)), el('h2', { dir: 'auto' }, headline(d)));
|
|
1572
|
+
const why = d.why || d.tradeoff || d.about;
|
|
1573
|
+
const back = el('a', { class: 'later', href: '#waiting' }, 'Later');
|
|
1574
|
+
const page = el('section', { class: 'decide' }, preview ? el('div', { class: 'decide-preview' }, preview) : '',
|
|
1575
|
+
...(d.mocks?.length ? [annotations(d)] : []), head,
|
|
1576
|
+
why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
|
|
1577
|
+
...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
|
|
1578
|
+
el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
|
|
1579
|
+
...(live ? [other(d, answerOf(d.key), err, () => (location.hash = to))] : []),
|
|
1580
|
+
silent ? el('p', { class: 'silent-line' }, silent) : '', err,
|
|
1581
|
+
el('div', { class: 'decide-bar' }, live && opts.length ? answer : '', skip || '', back));
|
|
1582
|
+
if (live) swipe(page, () => { if (recOpt && !answer.disabled) answerWith(recOpt); });
|
|
1583
|
+
return page;
|
|
1358
1584
|
}
|
|
1359
|
-
|
|
1585
|
+
const reshow = (control) => { render(); document.querySelector(control)?.focus({ preventScroll: true }); };
|
|
1586
|
+
// On Decide, a number selects and submits an answer; E skips to the next ask, Z undoes the last answer.
|
|
1360
1587
|
addEventListener('keydown', (e) => {
|
|
1361
|
-
if (
|
|
1362
|
-
const k = e.key.toLowerCase(),
|
|
1363
|
-
const
|
|
1588
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
|
|
1589
|
+
const k = e.key.toLowerCase(), onDecide = /^#decide\//.test(location.hash), choices = [...document.querySelectorAll('.decide .pick-option')];
|
|
1590
|
+
const pick = onDecide && /^[1-9]$/.test(k) ? choices[Number(k) - 1] : null;
|
|
1591
|
+
if (pick) { e.preventDefault(); pick.click(); document.querySelector('.decide-bar .answer')?.click(); return; }
|
|
1592
|
+
const act = k === 'e' && onDecide ? document.querySelector('.decide .skip') : k === 'z' && (location.hash === '#waiting' || onDecide) && !toast.hidden && !undoBtn.hidden ? undoBtn : null;
|
|
1364
1593
|
if (act && !act.disabled) e.preventDefault(), act.click();
|
|
1365
1594
|
});
|
|
1366
1595
|
|
|
1367
1596
|
function render() {
|
|
1597
|
+
if (designSession?.open) return (boardStale = true);
|
|
1368
1598
|
const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
|
|
1369
1599
|
// The board file appends new items at the end, so each list reads newest first; answers sort by their time.
|
|
1370
1600
|
// One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
|
|
@@ -1384,23 +1614,30 @@
|
|
|
1384
1614
|
const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
|
|
1385
1615
|
const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
|
|
1386
1616
|
by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
|
|
1387
|
-
const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1388
|
-
const
|
|
1617
|
+
const [, at, key] = /^#(waiting|decide|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1618
|
+
const decideQueue = by.waiting.filter((d) => d.type === 'signin' || (d.type !== 'plan' && d.kind !== 'design'));
|
|
1619
|
+
const askAt = decideQueue.findIndex((d) => d.key === key), decideAt = key ? askAt : 0;
|
|
1620
|
+
const active = at === 'decide' ? 'waiting' : at;
|
|
1621
|
+
const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (active ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
|
|
1389
1622
|
document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
|
|
1390
1623
|
document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
|
|
1391
|
-
...(a ===
|
|
1624
|
+
...(a === active && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
|
|
1392
1625
|
const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
|
|
1393
|
-
const
|
|
1626
|
+
const pageTitle = at === 'decide' ? 'Decide' : AREAS[at];
|
|
1627
|
+
const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: at === 'decide' ? '#waiting' : '#' }, at === 'decide' ? '‹ Waiting on you' : '‹ Summary')
|
|
1394
1628
|
: el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
|
|
1395
|
-
el('h1', { tabindex: '-1' }, at ?
|
|
1396
|
-
at === '
|
|
1629
|
+
el('h1', { tabindex: '-1' }, at ? pageTitle : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
|
|
1630
|
+
at === 'waiting' && !state.readonly && by.waiting.length ? el('p', { class: 'lede waiting-lede' }, `${shown.length} ${shown.length === 1 ? 'item needs' : 'items need'} you · ${new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}`)
|
|
1631
|
+
: at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
|
|
1397
1632
|
? 'How the tools work, step by step. Tap one to open it.' : 'Explainers the agents made for you. Tap one to open it.')
|
|
1398
1633
|
: at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, `${by.answered.filter((d) => isToday(state.answers[d.key].at)).length} answered today.`)
|
|
1399
1634
|
: at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
|
|
1400
|
-
const first = by.waiting[0], more = by.waiting.length > 1 ? `
|
|
1635
|
+
const first = by.waiting[0], more = by.waiting.length > 1 ? `Review all ${by.waiting.length} →` : 'Review ask →';
|
|
1401
1636
|
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 }),
|
|
1402
1637
|
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));
|
|
1403
|
-
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting),
|
|
1638
|
+
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
|
|
1639
|
+
: at === 'decide' ? [decideAt < 0 ? el('p', { class: 'empty' }, 'Nothing to decide for that question.')
|
|
1640
|
+
: decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined)]
|
|
1404
1641
|
: 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]))) : '',
|
|
1405
1642
|
...(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))
|
|
1406
1643
|
: 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]))),
|
|
@@ -1411,7 +1648,7 @@
|
|
|
1411
1648
|
}
|
|
1412
1649
|
function freeze() {
|
|
1413
1650
|
// Answers stay as they are, but nothing in the copy can be sent.
|
|
1414
|
-
document.querySelectorAll('#board .choices button, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
|
|
1651
|
+
document.querySelectorAll('#board .choices button, #board .pick-list button, #board .decide-bar .answer, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
|
|
1415
1652
|
// A browser will not open a data: link in a new tab; the inline preview is the copy.
|
|
1416
1653
|
document.querySelectorAll('#board a[href^="data:"]').forEach((a) => a.removeAttribute('href'));
|
|
1417
1654
|
}
|
|
@@ -1419,7 +1656,7 @@
|
|
|
1419
1656
|
// Closing a report or plan goes back to its card, with the focus on its link; an open report takes the focus on its title.
|
|
1420
1657
|
addEventListener('hashchange', () => {
|
|
1421
1658
|
const back = !reader.hidden && reader.dataset.key;
|
|
1422
|
-
if (jump && location.hash === '#waiting')
|
|
1659
|
+
if (jump && location.hash === '#waiting') jump = null;
|
|
1423
1660
|
if (location.hash === '#waiting') waitType = 'all';
|
|
1424
1661
|
render();
|
|
1425
1662
|
if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
|
package/web/point.js
CHANGED
|
@@ -17,7 +17,17 @@
|
|
|
17
17
|
};
|
|
18
18
|
window.signOffFind = find;
|
|
19
19
|
if (parent === window) return;
|
|
20
|
-
let on = false;
|
|
20
|
+
let on = false, touch;
|
|
21
|
+
const navigate = (key) => parent.postMessage({ 'sign-off': 'viewer-nav', key }, '*');
|
|
22
|
+
addEventListener('keydown', (e) => {
|
|
23
|
+
if (!e.target.closest?.('textarea, input, select, [contenteditable]') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight', 'Escape'].includes(e.key)) navigate(e.key);
|
|
24
|
+
});
|
|
25
|
+
addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
|
|
26
|
+
addEventListener('touchend', (e) => {
|
|
27
|
+
const end = e.changedTouches[0];
|
|
28
|
+
if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) navigate(end.clientX < touch.clientX ? 'ArrowRight' : 'ArrowLeft');
|
|
29
|
+
touch = null;
|
|
30
|
+
}, { passive: true });
|
|
21
31
|
const ref = (e) => {
|
|
22
32
|
if (e === document.documentElement) return 'html';
|
|
23
33
|
const path = [];
|