sign-off-axi 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +19 -14
- package/dist/store.js +23 -9
- package/package.json +1 -1
- package/web/index.html +115 -11
- package/web/point.js +11 -1
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
import { encode } from '@toon-format/toon';
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
|
-
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
13
|
+
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
14
14
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
15
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
16
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
@@ -484,7 +484,7 @@ function filerOf(values, session = false) {
|
|
|
484
484
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
485
485
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
486
486
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
487
|
-
const waits = (d) => !d.dismissed &&
|
|
487
|
+
const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
488
488
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
489
489
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
490
490
|
function board(file, say = warn) {
|
|
@@ -515,7 +515,7 @@ function ready(file, on, root) {
|
|
|
515
515
|
}
|
|
516
516
|
function list(args) {
|
|
517
517
|
const { file, values, on } = parse('list', args, TIERS);
|
|
518
|
-
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
|
|
518
|
+
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
|
|
519
519
|
if (values.json)
|
|
520
520
|
return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
|
|
521
521
|
const help = [
|
|
@@ -525,7 +525,7 @@ function list(args) {
|
|
|
525
525
|
];
|
|
526
526
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
527
527
|
if (values.full) {
|
|
528
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer
|
|
528
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
529
529
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
530
530
|
}
|
|
531
531
|
return toon({
|
|
@@ -604,7 +604,9 @@ async function wait(args) {
|
|
|
604
604
|
const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
|
|
605
605
|
// With a filer, only its own items count, and an answer it has not taken yet is new however old it is.
|
|
606
606
|
const mine = (rows) => (filer === undefined ? rows : rows.filter((d) => d.filer === filer));
|
|
607
|
-
|
|
607
|
+
// A design with only screen notes still waits: it counts as unanswered here until its design verdict lands.
|
|
608
|
+
const open = (rows) => rows.map((d) => (awaitsVerdict(d.type, d.answer) ? { ...d, answer: undefined } : d));
|
|
609
|
+
const before = new Map(open(board(file)).flatMap((d) => (d.answer ? [[d.key, JSON.stringify(d.answer)]] : [])));
|
|
608
610
|
// Rounds seen before waiting starts stay quiet; only a newly asked round wakes the loop.
|
|
609
611
|
const seenRounds = new Map();
|
|
610
612
|
for (const r of readRounds(file))
|
|
@@ -618,8 +620,8 @@ async function wait(args) {
|
|
|
618
620
|
}
|
|
619
621
|
catch { }
|
|
620
622
|
for (let first = true;; first = false) {
|
|
621
|
-
const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
|
|
622
|
-
const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) :
|
|
623
|
+
const rows = mine(open(board(file, first ? warn : () => { }))), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
|
|
624
|
+
const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : before.get(d.key) !== JSON.stringify(d.answer))));
|
|
623
625
|
const counts = new Map(), asked = [];
|
|
624
626
|
for (const r of readRounds(file, first ? warn : () => { })) {
|
|
625
627
|
counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
|
|
@@ -974,7 +976,7 @@ function replace(file, named, on, from, filer) {
|
|
|
974
976
|
// A key whose answer outlived it may not come back: the board and the answers are read here, under the lock the feed writes with.
|
|
975
977
|
const saved = readAnswers(file);
|
|
976
978
|
for (const i of fed.items)
|
|
977
|
-
if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
|
|
979
|
+
if (i?.kind !== undefined && !first.has(i?.key) && Object.hasOwn(saved, i?.key) && !awaitsVerdict(i.kind, saved[i.key])) {
|
|
978
980
|
throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
979
981
|
}
|
|
980
982
|
const current = board(file);
|
|
@@ -982,7 +984,7 @@ function replace(file, named, on, from, filer) {
|
|
|
982
984
|
const d = incoming.find((x) => x.key === had.key);
|
|
983
985
|
if (filer && d && had.filer && had.filer !== filer)
|
|
984
986
|
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)) {
|
|
987
|
+
if (had.answer && !awaitsVerdict(had.type, had.answer) && d && question(d) !== question(had)) {
|
|
986
988
|
throw fail(`key ${d.key} was answered and now holds a different question on ${tilde(file)}`, 'CONFLICT', 'Post the new question under a new key');
|
|
987
989
|
}
|
|
988
990
|
}
|
|
@@ -1072,7 +1074,8 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1072
1074
|
}
|
|
1073
1075
|
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
|
|
1074
1076
|
}
|
|
1075
|
-
|
|
1077
|
+
const answers = readAnswers(file);
|
|
1078
|
+
if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
|
|
1076
1079
|
throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
1077
1080
|
if (own && current.some((d) => d.key === own))
|
|
1078
1081
|
throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
|
|
@@ -1354,7 +1357,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1354
1357
|
return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
1355
1358
|
};
|
|
1356
1359
|
// An answer outlives its item: a new question under that key would inherit it.
|
|
1357
|
-
if (!had && Object.hasOwn(saved, d.key))
|
|
1360
|
+
if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
|
|
1358
1361
|
throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
|
|
1359
1362
|
// The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
|
|
1360
1363
|
const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
@@ -1367,7 +1370,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1367
1370
|
fits(d.title, d.key);
|
|
1368
1371
|
items.push({ filed: now, ...raw[i] });
|
|
1369
1372
|
}
|
|
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 };
|
|
1373
|
+
return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
|
|
1371
1374
|
});
|
|
1372
1375
|
if (raised.some((r) => r.status === 'raised')) {
|
|
1373
1376
|
const before = waitingKeys(file);
|
|
@@ -2359,7 +2362,7 @@ async function route(req) {
|
|
|
2359
2362
|
appendRound(file, key, note.trim(), comments);
|
|
2360
2363
|
return [200, state()];
|
|
2361
2364
|
}
|
|
2362
|
-
const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
|
|
2365
|
+
const { key, answer, note = '', notes, decisions, verdict, pick, requested } = JSON.parse(body) ?? {};
|
|
2363
2366
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
2364
2367
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
2365
2368
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
@@ -2381,7 +2384,9 @@ async function route(req) {
|
|
|
2381
2384
|
}
|
|
2382
2385
|
if (d.kind === 'design') {
|
|
2383
2386
|
try {
|
|
2384
|
-
|
|
2387
|
+
const before = readAnswers(file)[d.key]?.answer, a = appendDesignAnswer(file, d, verdict, notes, pick, requested);
|
|
2388
|
+
if (a && !awaitsVerdict(d.type, a) && (verdict !== undefined || a.answer !== before))
|
|
2389
|
+
answered(a, d);
|
|
2385
2390
|
}
|
|
2386
2391
|
catch (e) {
|
|
2387
2392
|
if (e instanceof InputError)
|
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);
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -359,6 +359,27 @@
|
|
|
359
359
|
.design-review .foot > * { flex: 1; }
|
|
360
360
|
.design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
|
|
361
361
|
.design-review > a { display: block; max-width: 56rem; margin: 1rem auto; color: var(--accent); }
|
|
362
|
+
.design-viewer { position: fixed; inset: 0; width: 100%; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: var(--bg); color: var(--text); }
|
|
363
|
+
.design-viewer[open] { display: flex; flex-direction: column; }
|
|
364
|
+
.design-viewer header { display: flex; gap: 1rem; align-items: center; padding: 0.75rem max(1rem, env(safe-area-inset-right)) 0.75rem max(1rem, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); }
|
|
365
|
+
.design-viewer header > div { flex: 1; min-width: 0; max-height: 22dvh; overflow: auto; }
|
|
366
|
+
.design-viewer h2, .design-viewer p { margin: 0; }
|
|
367
|
+
.design-viewer h2 { font-size: 1.25rem; line-height: 1.2; }
|
|
368
|
+
.design-viewer .meta { font-size: 1rem; }
|
|
369
|
+
.design-viewer-panel button { padding: 0 0.75rem; font-size: 1rem; white-space: nowrap; }
|
|
370
|
+
.design-viewer button, .design-viewer select { min-height: 2.75rem; }
|
|
371
|
+
.design-viewer-body { display: grid; grid-template-rows: minmax(0, 1fr) auto; flex: 1; min-height: 0; }
|
|
372
|
+
.design-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--sunk); touch-action: pan-y; }
|
|
373
|
+
.design-stage img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
|
374
|
+
.design-stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--surface); }
|
|
375
|
+
.design-viewer-panel { overflow: auto; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)); background: var(--surface); }
|
|
376
|
+
.design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
|
|
377
|
+
.design-viewer-panel .foot > * { flex: 1; }
|
|
378
|
+
.design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
|
|
379
|
+
.design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
|
|
380
|
+
.design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
|
|
381
|
+
@media (min-width: 60rem) { .design-viewer header h2 { font-size: 2.125rem; } .design-viewer-body { grid-template: minmax(0, 1fr) / minmax(0, 1fr) 22.5rem; } .design-viewer-panel { padding: 1.25rem; } }
|
|
382
|
+
@media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }
|
|
362
383
|
.needs .foot .open-link { min-height: 2.75rem; padding: 0 1rem; background: var(--action); color: var(--on-accent); text-align: center; }
|
|
363
384
|
/* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
|
|
364
385
|
.gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
|
|
@@ -955,7 +976,8 @@
|
|
|
955
976
|
you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
|
|
956
977
|
// The drawings are fixed strings from this page, never text from the board file.
|
|
957
978
|
const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
|
|
958
|
-
|
|
979
|
+
// A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
|
|
980
|
+
const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
|
|
959
981
|
/** Each item lives in one area: answered items, then the area the item names, then evidence updates, then what is only to read (explainers, pages with nothing to answer), then the open asks.
|
|
960
982
|
* A plan has no options but waits for its send-back. */
|
|
961
983
|
const area = (d) => {
|
|
@@ -1206,6 +1228,11 @@
|
|
|
1206
1228
|
return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
|
|
1207
1229
|
}
|
|
1208
1230
|
|
|
1231
|
+
let designSession, boardStale = false;
|
|
1232
|
+
addEventListener('message', (e) => {
|
|
1233
|
+
const viewer = document.querySelector('.design-viewer[open]');
|
|
1234
|
+
if (viewer && e.source === viewer.querySelector('iframe')?.contentWindow && e.data?.['sign-off'] === 'viewer-nav') viewer.dispatchEvent(new KeyboardEvent('keydown', { key: e.data.key }));
|
|
1235
|
+
});
|
|
1209
1236
|
function designReview(d) {
|
|
1210
1237
|
const saved = state.answers[d.key], screens = (d.journeys ?? []).flatMap((journey) => journey.screens.map((screen) => ({ journey, screen }))), savedNote = saved?.notes?.[0];
|
|
1211
1238
|
const screenKey = (target) => target && `${target.journey.id}/${target.screen.id}`, screenFor = (key) => screens.find((target) => screenKey(target) === key);
|
|
@@ -1214,11 +1241,19 @@
|
|
|
1214
1241
|
for (const option of d.options ?? []) pick.append(el('option', { value: option.value }, option.label));
|
|
1215
1242
|
pick.value = saved?.pick ?? '';
|
|
1216
1243
|
const screenSelect = screens.length > 1 ? el('select', { 'aria-label': 'Screen for note' }, ...screens.map((target) => el('option', { value: screenKey(target) }, `${target.journey.title} — ${target.screen.title}`))) : '';
|
|
1217
|
-
|
|
1244
|
+
if (designSession?.key !== d.key) designSession = { key: d.key, open: false, selected: '', drafts: new Map() };
|
|
1245
|
+
const session = designSession;
|
|
1246
|
+
const selectedKey = session.selected || (savedNote?.journey && savedNote?.screen ? `${savedNote.journey}/${savedNote.screen}` : screenKey(screens[0]));
|
|
1218
1247
|
if (screenSelect) screenSelect.value = screenFor(selectedKey) ? selectedKey : screenKey(screens[0]);
|
|
1219
1248
|
const target = () => screenSelect ? screenFor(screenSelect.value) : screens[0];
|
|
1220
|
-
|
|
1221
|
-
const
|
|
1249
|
+
const currentNote = () => state.answers[d.key]?.notes?.find((n) => n.journey === target()?.journey.id && n.screen === target()?.screen.id);
|
|
1250
|
+
const screenLine = () => (currentNote() ? (currentNote().verdict === 'approve' ? 'Screen approved' : 'Changes requested') : 'Not reviewed yet');
|
|
1251
|
+
note.value = session.drafts.get(screenKey(target())) ?? currentNote()?.note ?? '';
|
|
1252
|
+
note.oninput = () => session.drafts.set(screenKey(target()), note.value);
|
|
1253
|
+
const previewPath = () => {
|
|
1254
|
+
const views = target()?.screen.views, form = innerWidth < 960 ? 'phone' : 'computer', theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
1255
|
+
return views?.[`${form}_${theme}`] || views?.[`${form}_${theme === 'dark' ? 'light' : 'dark'}`] || (views && Object.values(views)[0]) || d.preview;
|
|
1256
|
+
};
|
|
1222
1257
|
const previewElement = (path) => /\.html?$/i.test(path) ? el('iframe', { class: 'design-review-preview', src: src(designUrl(d, path)), title: `Design preview: ${d.title}`, sandbox: 'allow-scripts' }) : el('img', { class: 'design-review-preview', src: src(designUrl(d, path)), alt: `Design preview: ${d.title}` });
|
|
1223
1258
|
const preview = previewPath() ? el('div', { class: 'design-preview-window' }, previewElement(previewPath())) : '';
|
|
1224
1259
|
const fullPreview = previewPath() ? el('a', { class: 'design-preview-full', href: src(designUrl(d, previewPath())), target: '_blank', rel: 'noopener noreferrer' }, 'Open full preview') : '';
|
|
@@ -1226,23 +1261,91 @@
|
|
|
1226
1261
|
const path = previewPath();
|
|
1227
1262
|
if (preview) preview.replaceChildren(previewElement(path));
|
|
1228
1263
|
if (fullPreview) fullPreview.href = src(designUrl(d, path));
|
|
1264
|
+
session.selected = screenKey(target());
|
|
1265
|
+
note.value = session.drafts.get(session.selected) ?? currentNote()?.note ?? '';
|
|
1229
1266
|
};
|
|
1230
1267
|
const status = el('p', { class: 'meta', role: 'status' }, saved ? saved.label : d.status === 'draft' ? 'Draft' : 'In review');
|
|
1231
1268
|
const err = el('p', { class: 'err', role: 'alert' });
|
|
1269
|
+
let saving = false;
|
|
1232
1270
|
const save = (verdict) => {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1271
|
+
const screen = session.open;
|
|
1272
|
+
if (!screen && d.options?.length && !pick.value) { err.textContent = 'Choose a direction before sending your review.'; pick.focus(); return Promise.resolve(); }
|
|
1273
|
+
// A screen save (the viewer) sends no design-wide verdict; a note carries the verdict of the button that saved it, on a screen or at design level.
|
|
1274
|
+
const selected = target(), text = note.value.trim(), writes = (selected && screen) || text, live = new Set(screens.map(screenKey));
|
|
1275
|
+
const notes = (state.answers[d.key]?.notes ?? []).filter((n) => (!n.journey || live.has(`${n.journey}/${n.screen}`)) && (n.journey !== selected?.journey.id || n.screen !== selected?.screen.id));
|
|
1276
|
+
if (writes) notes.push({ ...(selected && { journey: selected.journey.id, screen: selected.screen.id }), verdict, note: text });
|
|
1277
|
+
saving = true; approve.disabled = change.disabled = true;
|
|
1278
|
+
return call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, ...(!screen && { verdict }), ...(screen && { requested: verdict === 'change' }),
|
|
1279
|
+
notes, ...(pick.value && { pick: pick.value }) }) }, () => session.drafts.delete(screenKey(selected)))
|
|
1280
|
+
.finally(() => { saving = false; approve.disabled = change.disabled = state.readonly || !!state.answers_only; if (session.open) screenStatus.textContent = screenLine(); });
|
|
1237
1281
|
};
|
|
1238
1282
|
const approve = el('button', { class: 'open-link' }, 'Approve'), change = el('button', {}, 'Change');
|
|
1239
1283
|
approve.disabled = change.disabled = state.readonly || !!state.answers_only;
|
|
1240
1284
|
approve.onclick = () => save('approve').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; });
|
|
1241
1285
|
change.onclick = () => { if (!note.value.trim()) { err.textContent = 'Add a note describing what should change.'; note.focus(); return; } save('change').catch((e) => { err.textContent = `Couldn't save: ${e.message}`; }); };
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1286
|
+
const controls = el('div', {}, d.options?.length ? el('label', {}, 'Pick a direction', pick) : '',
|
|
1287
|
+
el('label', {}, screens.length ? 'Screen note' : 'Design note', note), el('div', { class: 'foot' }, approve, change), err);
|
|
1288
|
+
const open = el('button', { class: 'open-link', 'aria-haspopup': 'dialog', 'data-design': d.key }, 'Review full screen');
|
|
1289
|
+
const root = el('div', { class: 'design-review' }, status, d.about ? el('p', { class: 'about' }, d.about) : '', preview, fullPreview,
|
|
1290
|
+
screens.length ? open : '', screenSelect ? el('label', {}, 'Screen for note', screenSelect) : '', controls,
|
|
1245
1291
|
d.link ? el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer' }, 'Open original design') : '');
|
|
1292
|
+
if (!screens.length) return root;
|
|
1293
|
+
const heading = el('h2', { id: 'design-screen-title', dir: 'auto' }), count = el('p', { class: 'meta', 'aria-live': 'polite' });
|
|
1294
|
+
const stage = el('div', { class: 'design-stage' }), screenStatus = el('p', { class: 'meta', role: 'status' });
|
|
1295
|
+
const previous = el('button', { 'aria-label': 'Previous screen' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next screen' }, 'Next ›');
|
|
1296
|
+
const close = el('button', { 'aria-label': 'Close viewer' }, 'Close');
|
|
1297
|
+
const viewer = el('dialog', { class: 'design-viewer', 'aria-labelledby': heading.id }, el('header', {}, el('div', {}, count, heading), close),
|
|
1298
|
+
el('div', { class: 'design-viewer-body' }, stage, el('section', { class: 'design-viewer-panel', 'aria-label': 'Screen review' },
|
|
1299
|
+
el('div', { class: 'foot' }, previous, next), screenStatus)));
|
|
1300
|
+
const panel = viewer.querySelector('.design-viewer-panel');
|
|
1301
|
+
const draw = () => {
|
|
1302
|
+
const i = screens.indexOf(target());
|
|
1303
|
+
heading.textContent = `${target().journey.title} — ${target().screen.title}`;
|
|
1304
|
+
count.textContent = `${i + 1} of ${screens.length}`;
|
|
1305
|
+
previous.disabled = i === 0; next.disabled = i === screens.length - 1;
|
|
1306
|
+
stage.replaceChildren(previewElement(previewPath())); stage.scrollTop = 0;
|
|
1307
|
+
screenStatus.textContent = screenLine();
|
|
1308
|
+
approve.textContent = 'Approve screen'; change.textContent = 'Request change'; err.textContent = '';
|
|
1309
|
+
};
|
|
1310
|
+
const step = (delta) => {
|
|
1311
|
+
const i = screens.indexOf(target()) + delta;
|
|
1312
|
+
if (i < 0 || i >= screens.length || saving) return;
|
|
1313
|
+
session.drafts.set(screenKey(target()), note.value);
|
|
1314
|
+
if (screenSelect) { screenSelect.value = screenKey(screens[i]); screenSelect.onchange(); }
|
|
1315
|
+
draw();
|
|
1316
|
+
};
|
|
1317
|
+
previous.onclick = () => step(-1); next.onclick = () => step(1);
|
|
1318
|
+
const key = (e) => {
|
|
1319
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); viewer.close(); }
|
|
1320
|
+
else if (!e.target.closest?.('textarea, input, select') && !e.ctrlKey && !e.metaKey && !e.altKey && ['ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
1321
|
+
e.preventDefault(); step(e.key === 'ArrowRight' ? 1 : -1);
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
viewer.onkeydown = key;
|
|
1325
|
+
let touch;
|
|
1326
|
+
stage.addEventListener('touchstart', (e) => { touch = e.touches[0]; }, { passive: true });
|
|
1327
|
+
stage.addEventListener('touchend', (e) => {
|
|
1328
|
+
const end = e.changedTouches[0];
|
|
1329
|
+
if (touch && Math.abs(end.clientX - touch.clientX) > 60 && Math.abs(end.clientX - touch.clientX) > Math.abs(end.clientY - touch.clientY) * 1.5) step(end.clientX < touch.clientX ? 1 : -1);
|
|
1330
|
+
touch = null;
|
|
1331
|
+
});
|
|
1332
|
+
const controlsNext = controls.nextSibling;
|
|
1333
|
+
open.onclick = () => {
|
|
1334
|
+
session.open = true; panel.append(controls); root.append(viewer); draw(); viewer.showModal();
|
|
1335
|
+
};
|
|
1336
|
+
close.onclick = () => viewer.close();
|
|
1337
|
+
viewer.onclose = () => {
|
|
1338
|
+
session.open = false;
|
|
1339
|
+
if (boardStale) {
|
|
1340
|
+
boardStale = false;
|
|
1341
|
+
render();
|
|
1342
|
+
document.querySelector(`[data-design="${CSS.escape(d.key)}"]`)?.focus();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
root.insertBefore(controls, controlsNext); approve.textContent = 'Approve'; change.textContent = 'Change';
|
|
1346
|
+
open.focus();
|
|
1347
|
+
};
|
|
1348
|
+
return root;
|
|
1246
1349
|
}
|
|
1247
1350
|
|
|
1248
1351
|
/** The report a #reports/<key> link opens (or the plan a #waiting/<key> link opens), over the whole board: a bar with the way back and its title, then its page in a sandboxed frame.
|
|
@@ -1365,6 +1468,7 @@
|
|
|
1365
1468
|
});
|
|
1366
1469
|
|
|
1367
1470
|
function render() {
|
|
1471
|
+
if (designSession?.open) return (boardStale = true);
|
|
1368
1472
|
const by = { waiting: [], designs: [], evidence: [], reports: [], answered: [], notes: [] };
|
|
1369
1473
|
// The board file appends new items at the end, so each list reads newest first; answers sort by their time.
|
|
1370
1474
|
// One decision is one card: a card an open ask merges is not shown; that ask's answer closes it.
|
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 = [];
|