sign-off-axi 0.4.1 → 0.4.3
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 +76 -14
- package/dist/contract.js +52 -1
- package/dist/store.js +39 -10
- package/fixtures/contract/README.md +2 -2
- package/fixtures/contract/design.v1/design/inbox.png +0 -0
- package/fixtures/contract/design.v1/design/preview.png +0 -0
- package/fixtures/contract/design.v1/design/welcome-dark.png +0 -0
- package/fixtures/contract/design.v1/design/welcome.html +1 -0
- package/fixtures/contract/design.v1/design/welcome.png +0 -0
- package/fixtures/contract/design.v1/invalid-journeys-missing.json +1 -0
- package/fixtures/contract/design.v1/invalid-preview-escape.json +1 -0
- package/fixtures/contract/design.v1/invalid-unknown-field.json +1 -0
- package/fixtures/contract/design.v1/valid-basic.json +1 -0
- package/fixtures/contract/design.v1/valid-choice.json +1 -0
- package/package.json +1 -1
- package/web/index.html +181 -6
- package/web/point.js +4 -0
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
import { encode } from '@toon-format/toon';
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
|
-
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
13
|
+
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
14
14
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
15
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
16
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
@@ -86,7 +86,7 @@ examples:
|
|
|
86
86
|
`,
|
|
87
87
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
88
88
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
89
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and
|
|
89
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding, plan and design at schema 1, and report at schema 2.
|
|
90
90
|
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
91
91
|
A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
|
|
92
92
|
A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
|
|
@@ -231,6 +231,8 @@ examples:
|
|
|
231
231
|
sign-off-axi post --kind gate --from gate.json --filer done-done
|
|
232
232
|
sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
|
|
233
233
|
sign-off-axi post --kind plan --from plan.json --filer agent-41
|
|
234
|
+
sign-off-axi post --kind design --from design.json --filer agent-41
|
|
235
|
+
A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
|
|
234
236
|
sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
|
|
235
237
|
sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
|
|
236
238
|
sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
|
|
@@ -322,7 +324,7 @@ function fits(title, key) {
|
|
|
322
324
|
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
|
|
323
325
|
const MANY = ['option', 'outcome', 'merge'];
|
|
324
326
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
325
|
-
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan'];
|
|
327
|
+
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
326
328
|
/** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
|
|
327
329
|
const asSent = ({ filed: _filed, ...rest }) => rest;
|
|
328
330
|
/** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
|
|
@@ -367,12 +369,38 @@ function contractItems(items, from, file, replace = false) {
|
|
|
367
369
|
checkMessage(check);
|
|
368
370
|
if (check.kind === 'report' || check.kind === 'plan')
|
|
369
371
|
reportPage(check, dirname(file));
|
|
372
|
+
if (check.kind === 'design')
|
|
373
|
+
designFiles(check, dirname(file));
|
|
370
374
|
}
|
|
371
375
|
catch (e) {
|
|
372
376
|
throw e instanceof ContractError ? fail(`${from}: items[${i}]: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
373
377
|
}
|
|
374
378
|
});
|
|
375
379
|
}
|
|
380
|
+
function designFiles(m, base) {
|
|
381
|
+
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap((s) => Object.values(s.views)))];
|
|
382
|
+
for (const path of paths) {
|
|
383
|
+
let actual;
|
|
384
|
+
try {
|
|
385
|
+
actual = realpathSync(resolve(base, path));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
throw new ContractError(`design path ${JSON.stringify(path)} is not there`, `Put ${path} in the board folder or fix its path`);
|
|
389
|
+
}
|
|
390
|
+
const rel = relative(realpathSync(base), actual);
|
|
391
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
392
|
+
throw new ContractError(`design path ${JSON.stringify(path)} leads outside the board folder`, 'Copy every design view into the board folder');
|
|
393
|
+
const info = statSync(actual);
|
|
394
|
+
if (!info.isFile() || info.size > 5_000_000)
|
|
395
|
+
throw new ContractError(`design path ${JSON.stringify(path)} must be a file of 5 MB or less`, 'Shrink the design view');
|
|
396
|
+
try {
|
|
397
|
+
accessSync(actual, constants.R_OK);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
376
404
|
const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
|
|
377
405
|
/** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
|
|
378
406
|
function parse(cmd, args, flags) {
|
|
@@ -456,7 +484,7 @@ function filerOf(values, session = false) {
|
|
|
456
484
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
457
485
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
458
486
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
459
|
-
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan');
|
|
487
|
+
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
460
488
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
461
489
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
462
490
|
function board(file, say = warn) {
|
|
@@ -512,7 +540,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
512
540
|
const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
|
|
513
541
|
if (values.json) {
|
|
514
542
|
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
515
|
-
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions }) => [key, { answer, label, close, at, note, comments, notes, decisions, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
543
|
+
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
516
544
|
for (const r of rounds)
|
|
517
545
|
if (!out[r.key])
|
|
518
546
|
out[r.key] = { rounds: asked(r.key) };
|
|
@@ -520,7 +548,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
520
548
|
}
|
|
521
549
|
const notes = done.some((a) => a.note), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
|
|
522
550
|
const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
|
|
523
|
-
...sections.map((
|
|
551
|
+
...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
|
|
524
552
|
...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
|
|
525
553
|
]);
|
|
526
554
|
return toon({
|
|
@@ -555,7 +583,7 @@ function eventLines(file, rows, answers, rounds) {
|
|
|
555
583
|
const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
|
|
556
584
|
const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
|
|
557
585
|
const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
|
|
558
|
-
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...tail(key, comments) } })),
|
|
586
|
+
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...tail(key, comments) } })),
|
|
559
587
|
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
560
588
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
561
589
|
}
|
|
@@ -1137,6 +1165,8 @@ async function postKind(file, on, values, built) {
|
|
|
1137
1165
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1138
1166
|
if (m.kind === 'report' || m.kind === 'plan')
|
|
1139
1167
|
reportPage(m, dirname(file));
|
|
1168
|
+
if (m.kind === 'design')
|
|
1169
|
+
designFiles(m, dirname(file));
|
|
1140
1170
|
}
|
|
1141
1171
|
catch (e) {
|
|
1142
1172
|
throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
@@ -1225,11 +1255,13 @@ const outcomesOf = (values) => {
|
|
|
1225
1255
|
/** Whoever lists the choices names the one they would pick: the board puts it first and marks it. A plain yes/no may leave it out,
|
|
1226
1256
|
* and a program feeding a whole board with --replace is not held to it. */
|
|
1227
1257
|
function recommended(items, flags) {
|
|
1228
|
-
const d = items.find((i) => i?.options?.length && i.recommend_value === undefined), values = d?.options.map((o) => o.value).join(', ');
|
|
1258
|
+
const d = items.find((i) => i?.options?.length && (flags || i.type !== 'design' && i.kind !== 'design' ? i.recommend_value === undefined : i.recommend === undefined)), values = d?.options.map((o) => o.value).join(', ');
|
|
1229
1259
|
if (d && flags)
|
|
1230
1260
|
throw fail('--recommend is required when you list options', 'VALIDATION_ERROR', `Add --recommend <value>, the option you would pick: one of ${values}`);
|
|
1231
|
-
if (d)
|
|
1232
|
-
|
|
1261
|
+
if (d) {
|
|
1262
|
+
const field = !flags && (d.type === 'design' || d.kind === 'design') ? 'recommend' : 'recommend_value';
|
|
1263
|
+
throw fail(`items[${items.indexOf(d)}] (key ${d.key}) lists options but no ${field}`, 'VALIDATION_ERROR', `Add "${field}": "<value>", the option you would pick: one of ${values}`);
|
|
1264
|
+
}
|
|
1233
1265
|
}
|
|
1234
1266
|
/** Flags are validated by the same loader as a file: write them as a one-decision file first. Items are checked against the board they go to, so its answers_only or readonly holds. */
|
|
1235
1267
|
function buildIncoming(values, extra, filer, file) {
|
|
@@ -1989,7 +2021,7 @@ const state = () => {
|
|
|
1989
2021
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
1990
2022
|
// Who filed an item stays in the file: the page only learns that its filer took the answer.
|
|
1991
2023
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
1992
|
-
decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
2024
|
+
decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
1993
2025
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
1994
2026
|
readonly: readOnly(),
|
|
1995
2027
|
answers_only: answersOnly(),
|
|
@@ -2094,6 +2126,23 @@ function evidenceCard(link) {
|
|
|
2094
2126
|
}
|
|
2095
2127
|
const MESSAGE_MAX = 2000;
|
|
2096
2128
|
async function route(req) {
|
|
2129
|
+
if (req.method === 'GET' && req.url?.startsWith('/design/')) {
|
|
2130
|
+
const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
|
|
2131
|
+
const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
|
|
2132
|
+
const allowed = d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
|
|
2133
|
+
const type = assetTypes[extname(path).toLowerCase()];
|
|
2134
|
+
try {
|
|
2135
|
+
if (!allowed || !type)
|
|
2136
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2137
|
+
const actual = under(dirname(file), path);
|
|
2138
|
+
if (statSync(actual).size > MOCK_BYTES)
|
|
2139
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2140
|
+
return [200, { media: readFileSync(actual), type, point: type === 'text/html' }];
|
|
2141
|
+
}
|
|
2142
|
+
catch {
|
|
2143
|
+
return [404, { error: 'Design view is unavailable' }];
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2097
2146
|
if (req.method === 'GET' && req.url?.startsWith('/mock/')) {
|
|
2098
2147
|
// /mock/<pass>/<key>~<value>/<file>: that option's design mock, or with no value the item's own view (a diagram or interactive page),
|
|
2099
2148
|
// and the files beside it in its own folder, so a mock page's relative css, scripts and images load.
|
|
@@ -2243,7 +2292,7 @@ async function route(req) {
|
|
|
2243
2292
|
appendRound(file, key, note.trim(), comments);
|
|
2244
2293
|
return [200, state()];
|
|
2245
2294
|
}
|
|
2246
|
-
const { key, answer, note = '', notes, decisions } = JSON.parse(body) ?? {};
|
|
2295
|
+
const { key, answer, note = '', notes, decisions, verdict, pick } = JSON.parse(body) ?? {};
|
|
2247
2296
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
2248
2297
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
2249
2298
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
@@ -2263,10 +2312,21 @@ async function route(req) {
|
|
|
2263
2312
|
answered(appendAnswer(file, { key, close: 'done' }, answer === REPLY.value ? REPLY : { value: answer, label: answer }, note.trim()));
|
|
2264
2313
|
return [200, state()];
|
|
2265
2314
|
}
|
|
2315
|
+
if (d.kind === 'design') {
|
|
2316
|
+
try {
|
|
2317
|
+
answered(appendDesignAnswer(file, d, verdict, notes, pick), d);
|
|
2318
|
+
}
|
|
2319
|
+
catch (e) {
|
|
2320
|
+
if (e instanceof InputError)
|
|
2321
|
+
return [400, { error: e.message }];
|
|
2322
|
+
throw e;
|
|
2323
|
+
}
|
|
2324
|
+
return [200, state()];
|
|
2325
|
+
}
|
|
2266
2326
|
// A plan's answer is its one send-back: every section's verdict and note, and a pick for each decision.
|
|
2267
2327
|
if (d.type === 'plan') {
|
|
2268
2328
|
try {
|
|
2269
|
-
answered(appendSendBack(file, d, notes, decisions), d);
|
|
2329
|
+
answered(appendSendBack(file, d, notes, decisions, note), d);
|
|
2270
2330
|
}
|
|
2271
2331
|
catch (e) {
|
|
2272
2332
|
if (e instanceof InputError)
|
|
@@ -2392,7 +2452,7 @@ const server = createServer(async (req, res) => {
|
|
|
2392
2452
|
// Beyond loopback every request carries the token: once in the link, then in a cookie the link sets.
|
|
2393
2453
|
// The file is read on every request, so deleting or replacing it shuts old links out at once.
|
|
2394
2454
|
const token = tokenOf(file), cookie = `sign-off-${bound}`, given = req.url?.startsWith('/?') ? new URL(req.url, 'http://x').searchParams.get('token') : null;
|
|
2395
|
-
const pass = /^\/mock\/([^/?]*)\//.exec(req.url ?? '')?.[1], want = Buffer.from(pass === undefined ? token : mockPass(token));
|
|
2455
|
+
const pass = /^\/(?:mock|design)\/([^/?]*)\//.exec(req.url ?? '')?.[1], want = Buffer.from(pass === undefined ? token : mockPass(token));
|
|
2396
2456
|
const sent = Buffer.from(pass ?? given ?? req.headers.cookie?.split(/;\s*/).find((c) => c.startsWith(`${cookie}=`))?.slice(cookie.length + 1) ?? '');
|
|
2397
2457
|
if (!/^[\w-]{32,}$/.test(token) || sent.length !== want.length || !timingSafeEqual(sent, want)) {
|
|
2398
2458
|
return res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }).end('This board needs its access link: run sign-off-axi open on the computer that serves it and open the link it prints.');
|
|
@@ -2400,6 +2460,8 @@ const server = createServer(async (req, res) => {
|
|
|
2400
2460
|
if (given !== null)
|
|
2401
2461
|
return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
|
|
2402
2462
|
}
|
|
2463
|
+
if (req.method === 'GET' && req.url === '/favicon.ico')
|
|
2464
|
+
return res.writeHead(204).end();
|
|
2403
2465
|
if (req.method === 'GET' && req.url === '/') {
|
|
2404
2466
|
return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'" }).end(page);
|
|
2405
2467
|
}
|
package/dist/contract.js
CHANGED
|
@@ -11,7 +11,7 @@ export class ContractError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
/** The kinds, with the schema number of each. */
|
|
14
|
-
export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, plan: 1 };
|
|
14
|
+
export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, plan: 1, design: 1 };
|
|
15
15
|
export const KINDS = Object.keys(SCHEMAS);
|
|
16
16
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
|
|
17
17
|
const FIELDS = {
|
|
@@ -21,6 +21,7 @@ const FIELDS = {
|
|
|
21
21
|
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
22
22
|
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
23
23
|
plan: [...COMMON, 'sections', 'decisions', 'steps', 'view'],
|
|
24
|
+
design: [...COMMON, 'group', 'filed', 'status', 'preview', 'journeys', 'options', 'recommend'],
|
|
24
25
|
};
|
|
25
26
|
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
26
27
|
const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
|
|
@@ -256,6 +257,56 @@ export function checkMessage(m, want, flags = false) {
|
|
|
256
257
|
throw bad('preview', `${show(m.preview)} must sit in the folder of ${show(m.view)}`, `Put the picture beside the page, such as "${dirname(m.view)}/preview.svg"`);
|
|
257
258
|
return { kind: m.kind, schema: m.schema };
|
|
258
259
|
}
|
|
260
|
+
if (m.kind === 'design') {
|
|
261
|
+
text('group', m.group, 40, 'the project name, in 40 characters or fewer');
|
|
262
|
+
const at = typeof m.filed === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(m.filed) ? new Date(m.filed) : undefined;
|
|
263
|
+
if (!at || Number.isNaN(at.getTime()) || at.toISOString().replace('.000', '') !== m.filed)
|
|
264
|
+
throw bad('filed', 'must be a real UTC time with seconds', 'Set "filed" to YYYY-MM-DDTHH:MM:SSZ');
|
|
265
|
+
if (!['draft', 'in_review'].includes(m.status))
|
|
266
|
+
throw bad('status', 'must be draft or in_review', 'Set "status" to "draft" or "in_review"');
|
|
267
|
+
page('preview', /\.(png|jpe?g|webp)$/i, '.png, .jpg, .jpeg or .webp', '"design/preview.png"', 'a design preview');
|
|
268
|
+
const journeys = list('journeys', 1, 40, ['id', 'title', 'screens'], '{"id":"signup","title":"Sign up","screens":[...]}');
|
|
269
|
+
const ids = [];
|
|
270
|
+
journeys.forEach((j, n) => {
|
|
271
|
+
if (typeof j.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(j.id))
|
|
272
|
+
throw bad(`journeys[${n}].id`, 'must be a short lowercase id', 'Set it to a slug such as "signup"');
|
|
273
|
+
if (ids.includes(j.id))
|
|
274
|
+
throw bad(`journeys[${n}].id`, `repeats ${j.id}`, 'Give each journey its own id');
|
|
275
|
+
ids.push(j.id);
|
|
276
|
+
text(`journeys[${n}].title`, j.title, 120, 'the journey title');
|
|
277
|
+
if (!Array.isArray(j.screens) || !j.screens.length || j.screens.length > 80)
|
|
278
|
+
throw bad(`journeys[${n}].screens`, 'must list 1 to 80 screens', 'Add at least one screen with a view');
|
|
279
|
+
const screenIds = [];
|
|
280
|
+
j.screens.forEach((s, k) => {
|
|
281
|
+
const p = `journeys[${n}].screens[${k}]`;
|
|
282
|
+
if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(x => !['id', 'title', 'views'].includes(x)))
|
|
283
|
+
throw bad(p, 'must have only id, title and views', 'Use {"id":"welcome","title":"Welcome","views":{...}}');
|
|
284
|
+
if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id))
|
|
285
|
+
throw bad(`${p}.id`, 'must be a short lowercase id', 'Set it to a slug such as "welcome"');
|
|
286
|
+
if (screenIds.includes(s.id))
|
|
287
|
+
throw bad(`${p}.id`, `repeats ${s.id}`, 'Give each screen its own id');
|
|
288
|
+
screenIds.push(s.id);
|
|
289
|
+
text(`${p}.title`, s.title, 120, 'the screen title');
|
|
290
|
+
const views = s.views, variants = ['phone_light', 'phone_dark', 'computer_light', 'computer_dark'];
|
|
291
|
+
if (!views || typeof views !== 'object' || Array.isArray(views) || Object.keys(views).some(v => !variants.includes(v)) || !Object.keys(views).length)
|
|
292
|
+
throw bad(`${p}.views`, 'must map at least one supported view to a path', 'Add a phone_light, phone_dark, computer_light or computer_dark path');
|
|
293
|
+
for (const [variant, path] of Object.entries(views)) {
|
|
294
|
+
text(`${p}.views.${variant}`, path, 256, 'a board-relative image or HTML path');
|
|
295
|
+
if (/^([\\/]|[A-Za-z]:)/.test(path) || path.split(/[\\/]/).includes('..') || /[?#\\]/.test(path) || !/\.(png|jpe?g|webp|html)$/i.test(path))
|
|
296
|
+
throw bad(`${p}.views.${variant}`, 'must be a relative .png, .jpg, .jpeg, .webp or .html path without ..', 'Put the view under the board folder');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
if (m.options !== undefined) {
|
|
301
|
+
if (!Array.isArray(m.options) || m.options.length < 2 || m.options.length > 6)
|
|
302
|
+
throw bad('options', 'must list 2 to 6 options', 'Set options to 2-6 {"value":"...","label":"..."} choices');
|
|
303
|
+
checkOptions('options', m.options);
|
|
304
|
+
recommend('recommend', m, m.options);
|
|
305
|
+
}
|
|
306
|
+
else if (m.recommend !== undefined)
|
|
307
|
+
throw bad('recommend', 'requires options', 'Add options or remove recommend');
|
|
308
|
+
return { kind: m.kind, schema: m.schema };
|
|
309
|
+
}
|
|
259
310
|
// A plan is sections to approve or change, decisions with a recommended pick, steps to tap through, and where it goes as a diagram.
|
|
260
311
|
if (m.kind === 'plan') {
|
|
261
312
|
const ids = list('sections', 1, 8, ['id', 'heading', 'body'], '{"id":"goals","heading":"Goals and non-goals","body":"<what it does and does not do>"}').map((o, n) => {
|
package/dist/store.js
CHANGED
|
@@ -155,7 +155,7 @@ export function loadDecisions(path, board) {
|
|
|
155
155
|
}
|
|
156
156
|
// A ladder rung: a diagram image, or an interactive page the board frames in a sandbox.
|
|
157
157
|
// A report is a page and its picture; one stored before reports had pages still loads, as a note with neither.
|
|
158
|
-
const report = d.kind === 'report', plan = d.kind === 'plan';
|
|
158
|
+
const report = d.kind === 'report', plan = d.kind === 'plan', design = d.kind === 'design';
|
|
159
159
|
if (d.type === 'plan' && !plan)
|
|
160
160
|
throw bad('type', 'plan needs "kind": "plan": post it with sign-off-axi post --kind plan');
|
|
161
161
|
const views = report ? (d.view !== undefined || d.preview !== undefined ? VIEWS.interactive : undefined) : plan ? VIEWS.interactive : Object.hasOwn(VIEWS, d.type ?? '') ? VIEWS[d.type] : undefined;
|
|
@@ -170,9 +170,9 @@ export function loadDecisions(path, board) {
|
|
|
170
170
|
if (d[f] !== undefined)
|
|
171
171
|
throw bad(f, `must be left out: a report takes no answer${f === 'options' ? '' : ' and shows only under Reports'}`);
|
|
172
172
|
// A plan takes its answer from its sections and decisions, sent back at once, so it has no options of its own.
|
|
173
|
-
const options = d.options ?? (report || signin || plan ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
|
|
173
|
+
const options = d.options ?? (report || signin || plan || design ? [] : d.type === 'evidence' ? VERDICTS : YES_NO);
|
|
174
174
|
// An empty list is an item to look at only, unless it takes a reply: only a board another program feeds, a report or a sign-in shows those.
|
|
175
|
-
if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report && !signin && !plan) || options.length > 6
|
|
175
|
+
if (!Array.isArray(options) || options.length === 1 || (!options.length && !fed && !report && !signin && !plan && !design) || options.length > 6
|
|
176
176
|
|| !options.every((o) => isKey(o?.value) && isText(o.label) && o.label.trim() && (o.outcome === undefined || isText(o.outcome)))) {
|
|
177
177
|
throw bad('options', 'must be 2 to 6 items (or empty on a readonly or answers_only board), each a value of 1-128 of A-Z a-z 0-9 . _ - and a label');
|
|
178
178
|
}
|
|
@@ -218,6 +218,7 @@ export function loadDecisions(path, board) {
|
|
|
218
218
|
// So is a finding's proposal, with the counts and records behind it.
|
|
219
219
|
...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
|
|
220
220
|
...(plan && { sections: d.sections, decisions: d.decisions }),
|
|
221
|
+
...(design && { kind: 'design', type: 'design', group: d.group, filed: d.filed, status: d.status, preview: d.preview, journeys: d.journeys, ...(d.options && { options: d.options, recommend_value: d.recommend }) }),
|
|
221
222
|
...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
|
|
222
223
|
};
|
|
223
224
|
});
|
|
@@ -279,8 +280,33 @@ function saveAnswer(decisionsPath, d, option, note, comments = [], by) {
|
|
|
279
280
|
appendLine(answersPath(decisionsPath), answer);
|
|
280
281
|
return answer;
|
|
281
282
|
}
|
|
282
|
-
/** Saves a
|
|
283
|
-
export function
|
|
283
|
+
/** Saves a design answer with screen-specific notes and an optional listed direction pick. */
|
|
284
|
+
export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick) {
|
|
285
|
+
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
|
|
286
|
+
if (verdict !== 'approve' && verdict !== 'change')
|
|
287
|
+
throw bad('verdict must be approve or change');
|
|
288
|
+
if (!Array.isArray(notes) || (verdict === 'change' && !notes.length))
|
|
289
|
+
throw bad('notes must be a list, with at least one note on change');
|
|
290
|
+
const screens = new Set((d.journeys ?? []).flatMap(j => j.screens.map(s => `${j.id}/${s.id}`)));
|
|
291
|
+
for (const n of notes)
|
|
292
|
+
if (!n || !screens.has(`${n.journey}/${n.screen}`) || !isText(n.note) || !n.note.trim() || [...n.note].length > NOTE_MAX)
|
|
293
|
+
throw bad('each note needs a known journey and screen, and text of 500 characters or fewer');
|
|
294
|
+
if (new Set(notes.map(n => `${n.journey}/${n.screen}`)).size !== notes.length)
|
|
295
|
+
throw bad('notes must name each screen once');
|
|
296
|
+
const options = d.options ?? [];
|
|
297
|
+
if (options.length ? !options.some(o => o.value === pick) : pick !== undefined)
|
|
298
|
+
throw bad('pick must be one listed option, or omitted when no options were offered');
|
|
299
|
+
const answer = { key: d.key, answer: verdict, label: verdict === 'approve' ? 'Approved' : 'Changes requested', close: d.close ?? 'done', at: now(), notes, ...(pick !== undefined && { pick: pick }) };
|
|
300
|
+
const current = readAnswers(decisionsPath)[d.key];
|
|
301
|
+
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
|
|
302
|
+
return;
|
|
303
|
+
appendLine(answersPath(decisionsPath), answer);
|
|
304
|
+
return answer;
|
|
305
|
+
}
|
|
306
|
+
/** Saves a plan's one send-back as its answer: each section approved or changed (a change says what to change, an approval may carry a comment),
|
|
307
|
+
* one answer per decision (a listed option value, or the person's own words when none fits), and an optional whole-plan comment.
|
|
308
|
+
* An identical send-back again saves nothing. */
|
|
309
|
+
export function appendSendBack(decisionsPath, d, notes, picks = {}, comment = '') {
|
|
284
310
|
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`), ids = d.sections.map((s) => s.id), keys = d.decisions.map((q) => q.key);
|
|
285
311
|
if (!Array.isArray(notes) || notes.length !== ids.length || !ids.every((id) => notes.filter((n) => n?.section === id).length === 1))
|
|
286
312
|
throw bad(`notes must list each section once: ${ids.join(', ')}`);
|
|
@@ -293,13 +319,16 @@ export function appendSendBack(decisionsPath, d, notes, picks = {}) {
|
|
|
293
319
|
throw bad(`section ${section} needs a note on what to change`);
|
|
294
320
|
}
|
|
295
321
|
const p = picks;
|
|
296
|
-
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).length !== keys.length || !d.decisions.every((q) => q.
|
|
297
|
-
throw bad(`decisions must
|
|
322
|
+
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).length !== keys.length || !d.decisions.every((q) => isText(p[q.key]) && p[q.key].trim() && [...p[q.key].trim()].length <= NOTE_MAX)) {
|
|
323
|
+
throw bad(`decisions must answer each of: ${keys.join(', ') || 'none'}, with an option or your own words of ${NOTE_MAX} characters or fewer`);
|
|
298
324
|
}
|
|
299
|
-
|
|
300
|
-
|
|
325
|
+
if (comment !== '' && comment !== undefined && (!isText(comment) || [...comment.trim()].length > NOTE_MAX))
|
|
326
|
+
throw bad(`comment must be text of ${NOTE_MAX} characters or fewer`);
|
|
327
|
+
const note = typeof comment === 'string' ? comment.trim() : '';
|
|
328
|
+
const answer = { key: d.key, answer: 'sent', label: 'Sent back', close: d.close, at: now(), ...(note && { note }),
|
|
329
|
+
notes: ids.map((id) => notes.find((n) => n.section === id)).map(({ section, verdict, note }) => ({ section, verdict, ...(note?.trim() && { note: note.trim() }) })), decisions: Object.fromEntries(keys.map((k) => [k, p[k].trim()])) };
|
|
301
330
|
const current = readAnswers(decisionsPath)[d.key];
|
|
302
|
-
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.decisions]) === JSON.stringify([answer.notes, answer.decisions]))
|
|
331
|
+
if (current?.answer === answer.answer && JSON.stringify([current.note ?? '', current.notes, current.decisions]) === JSON.stringify([answer.note ?? '', answer.notes, answer.decisions]))
|
|
303
332
|
return;
|
|
304
333
|
appendLine(answersPath(decisionsPath), answer);
|
|
305
334
|
return answer;
|
|
@@ -9,8 +9,8 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
|
|
|
9
9
|
fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
- `<kind>` is `ask`, `report`, `evidence`, `gate`, `finding` or `
|
|
13
|
-
- Each file holds one message. An evidence fixture that names `media` ships those files next to it, and a report fixture its page and picture,
|
|
12
|
+
- `<kind>` is `ask`, `report`, `evidence`, `gate`, `finding`, `plan` or `design`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v2/`, `evidence.v1/`, `gate.v1/`, `finding.v1/`, `plan.v1/` and `design.v1/`. This is the one list of shipped folders; other docs point here.
|
|
13
|
+
- Each file holds one message. An evidence fixture that names `media` ships those files next to it, and a report fixture its page and picture, a plan fixture its page, under `page/`, and a design fixture its views, under `design/`.
|
|
14
14
|
- Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
|
|
15
15
|
- An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
|
|
16
16
|
- Demo data uses the name Umer. No real names, paths or tokens.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta name="viewport" content="width=device-width"><style>@media(prefers-color-scheme:dark){body{background:#111;color:#fff}}</style></head><body><button>Continue</button></body></html>
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"../preview.png","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","about":"Review onboarding.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png","unknown":"x","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-journeys","title":"Chat app journeys","filer":"agent-41","about":"Review onboarding and messaging.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"in_review","preview":"design/preview.png","journeys":[{"id":"onboarding","title":"Onboarding","screens":[{"id":"welcome","title":"Welcome","views":{"phone_light":"design/welcome.png","computer_dark":"design/welcome.html"}}]}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"design","schema":1,"key":"chat-choice","title":"Choose a chat direction","about":"Compare two chat layouts.","group":"Chat app","filed":"2026-10-18T12:00:00Z","status":"draft","preview":"design/preview.png","options":[{"value":"simple","label":"Simple"},{"value":"rich","label":"Rich"}],"recommend":"simple","journeys":[{"id":"messages","title":"Messages","screens":[{"id":"inbox","title":"Inbox","views":{"phone_light":"design/inbox.png"}}]}]}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -149,6 +149,60 @@
|
|
|
149
149
|
#reader .close { display: inline-flex; flex: none; align-items: center; min-height: 3rem; margin-left: -0.5rem; padding: 0 0.5rem; border-radius: 0.625rem; color: var(--accent); font-weight: 650; text-decoration: none; }
|
|
150
150
|
#reader h1 { margin: 0; min-width: 0; overflow: hidden; font-size: 1rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
151
|
#reader iframe { flex: 1; width: 100%; border: 0; background: var(--bg); }
|
|
152
|
+
#reader h1:not(:focus-visible) { outline: none; }
|
|
153
|
+
#reader h1:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
154
|
+
.i-plan { --area: var(--c-waiting); }
|
|
155
|
+
#reader .plan-scroll { flex: 1; min-height: 0; overflow: auto; padding: 1.25rem 1.25rem 9rem; }
|
|
156
|
+
.plan-layout { max-width: 72rem; margin: auto; display: grid; gap: 2rem; }
|
|
157
|
+
.plan-main { min-width: 0; }
|
|
158
|
+
.plan-main > h2 { margin: 2rem 0 0.75rem; }
|
|
159
|
+
.plan-main > h2:first-of-type { font-size: 3.5rem; line-height: 1.15; margin: 0.5rem 0; }
|
|
160
|
+
.plan-main > p { margin: 0.5rem 0 1.5rem; }
|
|
161
|
+
.plan-summary { display: flex; gap: 0.75rem; color: var(--muted); font-size: 1.125rem; line-height: 1.5; margin: 1.5rem 0 2rem; }
|
|
162
|
+
#reader .plan-view { width: 100%; height: 22rem; flex: none; border-radius: 1rem; }
|
|
163
|
+
.plan-section, .plan-decision, .plan-explain { background: var(--surface); border-radius: 1.125rem; padding: 1rem; margin: 1rem 0; scroll-margin-top: 1rem; }
|
|
164
|
+
.plan-section h3, .plan-decision h3 { font-size: 1.375rem; line-height: 1.3; margin: 0 0 0.75rem; }
|
|
165
|
+
.plan-section p { white-space: pre-line; }
|
|
166
|
+
.plan-verdicts { display: flex; gap: 0.625rem; }
|
|
167
|
+
.plan-verdicts button { flex: 1; border: 0; border-radius: 980px; background: var(--sunk); font-weight: 600; }
|
|
168
|
+
.plan-verdicts button[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px currentColor; background: var(--surface); }
|
|
169
|
+
.plan-verdicts button[aria-pressed="true"]::before, .plan-decision button[aria-pressed="true"]::before { content: none; }
|
|
170
|
+
.plan-verdicts .approve[aria-pressed="true"] { color: var(--ok); }
|
|
171
|
+
.plan-verdicts .change[aria-pressed="true"] { color: var(--warn); }
|
|
172
|
+
.plan-section label { display: grid; gap: 0.5rem; margin-top: 1rem; color: var(--muted); }
|
|
173
|
+
.plan-section label[hidden] { display: none; }
|
|
174
|
+
.plan-section textarea { width: 100%; min-height: 7rem; padding: 0.75rem; background: var(--sunk); }
|
|
175
|
+
.plan-decision label { display: grid; gap: 0.5rem; margin-top: 0.75rem; color: var(--muted); }
|
|
176
|
+
.plan-decision textarea { width: 100%; min-height: 4rem; padding: 0.75rem; background: var(--sunk); }
|
|
177
|
+
.plan-decision .choices { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.625rem; }
|
|
178
|
+
.plan-decision .choices button { text-align: left; border-radius: 0.875rem; padding: 0.875rem; gap: 0.25rem; }
|
|
179
|
+
.plan-decision .choices button .tag { margin: 0; text-transform: none; letter-spacing: normal; color: var(--accent); }
|
|
180
|
+
.plan-decision .choices button .tag::before { content: none; }
|
|
181
|
+
.plan-decision .choices button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--text); border: 2px solid var(--accent); }
|
|
182
|
+
.plan-decision .tag { display: block; color: var(--accent); font-size: 0.875rem; }
|
|
183
|
+
.plan-explain .foot { justify-content: space-between; }
|
|
184
|
+
.plan-send { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.625rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); background: var(--bar); border-top: 1px solid var(--line); }
|
|
185
|
+
.plan-send p { margin: 0 0 0.5rem; }
|
|
186
|
+
.plan-send button { width: 100%; }
|
|
187
|
+
.plan-next-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
|
|
188
|
+
.plan-next { width: auto !important; padding: 0; border: 0; background: none; color: var(--accent); font-weight: 600; }
|
|
189
|
+
.plan-send .err:empty { display: none; }
|
|
190
|
+
.plan-progress button { width: 100%; padding: 0.5rem 0; display: flex; justify-content: space-between; gap: 1rem; background: none; text-align: left; border-radius: 0.5rem; font-size: 1rem; }
|
|
191
|
+
.plan-progress span { color: var(--muted); }
|
|
192
|
+
@media (max-width: 59.999rem) { #reader .plan-scroll { margin-bottom: calc(9rem + env(safe-area-inset-bottom)); } #reader .plan-scroll.sent { margin-bottom: 0; } .plan-send h2, .plan-progress { display: none; } .plan-send p { font-size: 1rem; } }
|
|
193
|
+
#reader .plan-sent .back { display: inline-flex; }
|
|
194
|
+
.plan-sent { text-align: center; padding-top: 2rem; }
|
|
195
|
+
.plan-sent h2 { color: var(--ok); font-size: 2.125rem; }
|
|
196
|
+
.plan-sent .icon { margin: auto; background: var(--ok); }
|
|
197
|
+
.plan-layout.sent { grid-template-columns: minmax(0, 42.5rem); justify-content: center; }
|
|
198
|
+
.plan-send[hidden] { display: none; }
|
|
199
|
+
@media (min-width: 60rem) {
|
|
200
|
+
#reader .plan-scroll { padding: 2rem 3rem; }
|
|
201
|
+
.plan-layout { grid-template-columns: minmax(0, 42.5rem) minmax(15rem, 1fr); }
|
|
202
|
+
.plan-send { position: sticky; align-self: start; top: 0; border: 0; border-radius: 1.125rem; padding: 1.25rem; }
|
|
203
|
+
.plan-next { display: none; }
|
|
204
|
+
.plan-section, .plan-decision { padding: 1.5rem; }
|
|
205
|
+
}
|
|
152
206
|
body.reading { overflow: hidden; }
|
|
153
207
|
.foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; }
|
|
154
208
|
.foot .meta { margin: 0 auto 0 0; }
|
|
@@ -552,6 +606,12 @@
|
|
|
552
606
|
}
|
|
553
607
|
|
|
554
608
|
function row(d, { x = true } = {}) {
|
|
609
|
+
if (d.kind === 'design') {
|
|
610
|
+
const original = /^https?:/.test(d.link ?? '') ? el('p', { class: 'meta' }, el('a', { href: d.link, target: '_blank', rel: 'noopener noreferrer',
|
|
611
|
+
'aria-label': `Open original, ${d.title}` }, 'Open original')) : '';
|
|
612
|
+
return el('article', { class: 'row design', id: `item-${d.key}` },
|
|
613
|
+
el('div', {}, el('h3', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about)), original);
|
|
614
|
+
}
|
|
555
615
|
const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` });
|
|
556
616
|
const saved = Object.hasOwn(state.answers, d.key) ? state.answers[d.key] : undefined;
|
|
557
617
|
// A read-only board, or an item with nothing to answer, shows no answer, note or comment controls.
|
|
@@ -572,7 +632,7 @@
|
|
|
572
632
|
const update = d.type === 'evidence', card = d.evidenceCard, result = RESULT[card?.result];
|
|
573
633
|
const brow = [update && area(d) !== 'waiting' ? `Update ${d.number}` : kind(d), result ? el('span', { class: `st ${result[0]}` }, result[1]) : '',
|
|
574
634
|
update && d.filed ? (isToday(d.filed) ? el('span', { class: 'st new' }, 'New') : day(d.filed)) : '', seen].filter(Boolean);
|
|
575
|
-
const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...brow) : '', el('h3', { dir: 'auto' }, name));
|
|
635
|
+
const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...(d.type === 'plan' ? [icon('plan')] : []), ...brow) : '', el('h3', { dir: 'auto' }, name));
|
|
576
636
|
if (d.type === 'signin') return signin(d, lead, saved, err);
|
|
577
637
|
if (d.type === 'plan') return plan(d, lead);
|
|
578
638
|
const quiet = !ro && d.options.find((o) => o.value === d.default_if_silent);
|
|
@@ -869,6 +929,7 @@
|
|
|
869
929
|
evidence: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1zM9 12l2 2 4-4',
|
|
870
930
|
reports: 'M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
|
|
871
931
|
answered: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM9 12l2 2 4-4',
|
|
932
|
+
plan: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v6h6M8 13h8M8 17h8M8 9h2',
|
|
872
933
|
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' };
|
|
873
934
|
// The drawings are fixed strings from this page, never text from the board file.
|
|
874
935
|
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; };
|
|
@@ -877,7 +938,7 @@
|
|
|
877
938
|
* A plan has no options but waits for its send-back. */
|
|
878
939
|
const area = (d) => {
|
|
879
940
|
const a = isAnswered(d) ? 'answered' : d.area ?? (d.type === 'evidence' ? 'evidence'
|
|
880
|
-
: state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan') ? 'reports' : 'waiting');
|
|
941
|
+
: state.readonly || d.type === 'explainer' || !(d.options.length || d.reply || d.type === 'signin' || d.type === 'plan' || d.kind === 'design') ? 'reports' : 'waiting');
|
|
881
942
|
// Reports lists only what explains: a page to tap through, or a narrated video. Text alone (a receipt, a status line) is a note, folded under it.
|
|
882
943
|
return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
|
|
883
944
|
};
|
|
@@ -932,7 +993,7 @@
|
|
|
932
993
|
/** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
|
|
933
994
|
const compact = (d, a) => {
|
|
934
995
|
const s = state.answers[d.key];
|
|
935
|
-
return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
|
|
996
|
+
return [thumb(d.type === 'plan' ? 'plan' : a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
|
|
936
997
|
? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label), el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at)))
|
|
937
998
|
: el('span', { class: 'meta', dir: 'auto' }, sub(d)))];
|
|
938
999
|
};
|
|
@@ -1013,6 +1074,116 @@
|
|
|
1013
1074
|
return box;
|
|
1014
1075
|
}
|
|
1015
1076
|
|
|
1077
|
+
/** Plan answers stay on the trusted board, never in the agent's sandboxed page. Drafts survive refresh until the server saves them. */
|
|
1078
|
+
function planReview(d, url) {
|
|
1079
|
+
const saved = answerOf(d.key), ro = !!saved || state.readonly || !!FROZEN, draftKey = `plan:${d.key}`;
|
|
1080
|
+
let draft;
|
|
1081
|
+
try { draft = JSON.parse(drafts.get(draftKey) ?? '{}'); } catch { draft = {}; }
|
|
1082
|
+
const notes = new Map((saved?.notes ?? draft.notes ?? []).filter((n) => d.sections.some((s) => s.id === n.section)).map((n) => [n.section, n]));
|
|
1083
|
+
const picks = new Map(Object.entries(saved?.decisions ?? draft.decisions ?? {}).filter(([key, value]) => d.decisions.some((q) => q.key === key) && typeof value === 'string' && value.trim()));
|
|
1084
|
+
let comment = saved?.note ?? (typeof draft.comment === 'string' ? draft.comment : '');
|
|
1085
|
+
const answered = (q) => typeof picks.get(q.key) === 'string' && !!picks.get(q.key).trim();
|
|
1086
|
+
const remember = () => { drafts.set(draftKey, JSON.stringify({ notes: [...notes.values()], decisions: Object.fromEntries(picks), ...(comment.trim() && { comment }) })); keep(); };
|
|
1087
|
+
const main = el('div', { class: 'plan-main' }), panel = el('aside', { class: 'plan-send' });
|
|
1088
|
+
const status = el('p', { role: 'status' }), err = el('p', { class: 'err', role: 'alert' }), progress = el('div', { class: 'plan-progress' });
|
|
1089
|
+
const send = el('button', { class: 'open-link' }, 'Send back'), next = el('button', { class: 'plan-next' }, 'Go to next');
|
|
1090
|
+
const complete = (s) => { const n = notes.get(s.id); return n?.verdict === 'approve' || (n?.verdict === 'change' && n.note?.trim()); };
|
|
1091
|
+
next.onclick = () => {
|
|
1092
|
+
const section = d.sections.find((s) => !complete(s)), decision = d.decisions.find((q) => !answered(q));
|
|
1093
|
+
document.getElementById(section ? `plan-section-${section.id}` : `plan-decision-${decision?.key}`)?.scrollIntoView({ block: 'start' });
|
|
1094
|
+
};
|
|
1095
|
+
const update = () => {
|
|
1096
|
+
const left = d.sections.filter((s) => !complete(s)).length, decisions = d.decisions.filter((q) => !answered(q)).length;
|
|
1097
|
+
status.textContent = saved ? 'Sent back. Your answers are saved.' : left || decisions ? `${left} section${left === 1 ? '' : 's'} and ${decisions} decision${decisions === 1 ? '' : 's'} left` : 'Ready. Every part has an answer.';
|
|
1098
|
+
send.disabled = ro || !!left || !!decisions;
|
|
1099
|
+
next.hidden = !left && !decisions;
|
|
1100
|
+
progress.replaceChildren(...d.sections.map((s) => {
|
|
1101
|
+
const verdict = notes.get(s.id)?.verdict, b = el('button', {}, s.heading, el('span', {}, verdict === 'approve' ? 'Approved' : verdict === 'change' ? 'Change' : 'To review'));
|
|
1102
|
+
b.onclick = () => document.getElementById(`plan-section-${s.id}`).scrollIntoView(); return b;
|
|
1103
|
+
}), d.decisions.length ? el('p', { class: 'meta' }, `${d.decisions.length - decisions} of ${d.decisions.length} decisions answered`) : '');
|
|
1104
|
+
};
|
|
1105
|
+
if (saved) {
|
|
1106
|
+
main.append(el('div', { class: 'plan-sent', role: 'status' }, icon('answered'), el('h2', {}, 'Sent back'), el('p', {}, 'Your section notes and decisions are saved for the agent.'),
|
|
1107
|
+
el('a', { class: 'back', href: '#answered' }, 'See it in Answered'),
|
|
1108
|
+
saved.note ? el('p', { dir: 'auto' }, `Your whole-plan comment: ${saved.note}`) : ''));
|
|
1109
|
+
}
|
|
1110
|
+
main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
|
|
1111
|
+
if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
|
|
1112
|
+
el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length} sections to approve or change, and ${d.decisions.length} decisions to make. Nothing is built until you send back.`));
|
|
1113
|
+
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.'));
|
|
1114
|
+
d.sections.forEach((s, i) => {
|
|
1115
|
+
const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
|
|
1116
|
+
const approve = el('button', { class: 'approve', 'aria-label': `Approve, ${s.heading}` }, 'Approve');
|
|
1117
|
+
const change = el('button', { class: 'change', 'aria-label': `Change, ${s.heading}` }, 'Change');
|
|
1118
|
+
const field = el('textarea', { id: `plan-note-${s.id}`, maxlength: '500', placeholder: 'Write your note for the agent' });
|
|
1119
|
+
field.value = notes.get(s.id)?.note ?? '';
|
|
1120
|
+
field.disabled = ro;
|
|
1121
|
+
const caption = el('span', {}, '');
|
|
1122
|
+
const note = el('label', { for: field.id }, caption, field);
|
|
1123
|
+
const draw = () => {
|
|
1124
|
+
const verdict = notes.get(s.id)?.verdict;
|
|
1125
|
+
card.dataset.verdict = verdict ?? '';
|
|
1126
|
+
approve.setAttribute('aria-pressed', verdict === 'approve');
|
|
1127
|
+
approve.textContent = verdict === 'approve' ? '✓ Approved' : 'Approve';
|
|
1128
|
+
change.setAttribute('aria-pressed', verdict === 'change');
|
|
1129
|
+
approve.disabled = change.disabled = ro;
|
|
1130
|
+
caption.textContent = verdict === 'approve' ? `Comment for the agent on ${s.heading.toLowerCase()} (optional)` : `What should change in ${s.heading.toLowerCase()}?`;
|
|
1131
|
+
note.hidden = !verdict && !field.value;
|
|
1132
|
+
update();
|
|
1133
|
+
};
|
|
1134
|
+
[approve, change].forEach((b, n) => b.onclick = () => {
|
|
1135
|
+
notes.set(s.id, { section: s.id, verdict: n ? 'change' : 'approve', ...(field.value.trim() && { note: field.value }) });
|
|
1136
|
+
remember(); draw(); if (n) field.focus();
|
|
1137
|
+
});
|
|
1138
|
+
field.oninput = () => { notes.set(s.id, { section: s.id, verdict: notes.get(s.id)?.verdict ?? 'change', note: field.value }); remember(); draw(); };
|
|
1139
|
+
card.append(el('h3', { dir: 'auto' }, `${i + 1}. ${s.heading}`), el('p', { dir: 'auto' }, s.body), el('div', { class: 'plan-verdicts', role: 'group', 'aria-label': s.heading }, approve, change), note);
|
|
1140
|
+
main.append(card); draw();
|
|
1141
|
+
});
|
|
1142
|
+
if (d.decisions.length) main.append(el('h2', {}, 'Decisions for you'));
|
|
1143
|
+
d.decisions.forEach((q) => {
|
|
1144
|
+
const choices = el('div', { class: 'choices', role: 'group', 'aria-label': q.question });
|
|
1145
|
+
const other = el('textarea', { id: `plan-other-${q.key}`, maxlength: '500', placeholder: 'Other: write your own answer' });
|
|
1146
|
+
other.value = q.options.some((o) => o.value === picks.get(q.key)) ? '' : (picks.get(q.key) ?? '');
|
|
1147
|
+
other.disabled = ro;
|
|
1148
|
+
const press = () => [...choices.children].forEach((b, i) => b.setAttribute('aria-pressed', q.options[i].value === picks.get(q.key)));
|
|
1149
|
+
other.oninput = () => {
|
|
1150
|
+
if (other.value.trim()) picks.set(q.key, other.value);
|
|
1151
|
+
else if (!q.options.some((o) => o.value === picks.get(q.key))) picks.delete(q.key);
|
|
1152
|
+
remember(); press(); update();
|
|
1153
|
+
};
|
|
1154
|
+
q.options.forEach((o) => {
|
|
1155
|
+
const b = el('button', { 'aria-pressed': picks.get(q.key) === o.value, ...(ro && { disabled: '' }) }, el('strong', {}, o.label),
|
|
1156
|
+
o.value === q.recommend ? el('span', { class: 'tag' }, 'Recommended') : '', el('span', { class: 'out', dir: 'auto' }, o.outcome));
|
|
1157
|
+
b.onclick = () => { picks.set(q.key, o.value); other.value = ''; remember(); press(); update(); };
|
|
1158
|
+
choices.append(b);
|
|
1159
|
+
});
|
|
1160
|
+
main.append(el('section', { class: 'plan-decision', id: `plan-decision-${q.key}` }, el('h3', { dir: 'auto' }, q.question), el('p', { class: 'meta', dir: 'auto' }, q.why), choices,
|
|
1161
|
+
el('label', { for: other.id }, 'Other (optional)', other)));
|
|
1162
|
+
});
|
|
1163
|
+
if (d.steps?.length && !saved) {
|
|
1164
|
+
let step = 0;
|
|
1165
|
+
const text = el('p', { 'aria-live': 'polite' }), prev = el('button', { 'aria-label': 'Previous step' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next step' }, 'Next ›');
|
|
1166
|
+
const draw = () => { text.textContent = `Step ${step + 1} of ${d.steps.length}: ${d.steps[step].title}`; prev.disabled = !step; next.disabled = step === d.steps.length - 1; };
|
|
1167
|
+
prev.onclick = () => { step--; draw(); }; next.onclick = () => { step++; draw(); }; draw();
|
|
1168
|
+
main.append(el('section', { class: 'plan-explain' }, el('h3', {}, 'How it works'), text, el('div', { class: 'foot' }, prev, next), el('p', { class: 'meta' }, 'The direction page above explains these steps.')));
|
|
1169
|
+
}
|
|
1170
|
+
const commentBox = el('textarea', { id: 'plan-comment', maxlength: '500', placeholder: 'Comment for the agent on the whole plan' });
|
|
1171
|
+
commentBox.value = comment;
|
|
1172
|
+
commentBox.disabled = ro;
|
|
1173
|
+
commentBox.oninput = () => { comment = commentBox.value; remember(); };
|
|
1174
|
+
if (!saved || saved.note) main.append(el('section', { class: 'plan-section', id: 'plan-comment-section', 'aria-label': 'Whole-plan comment' },
|
|
1175
|
+
el('h3', {}, 'Whole-plan comment'), el('label', { for: commentBox.id }, 'Comment for the agent on the whole plan (optional)', commentBox)));
|
|
1176
|
+
send.onclick = () => {
|
|
1177
|
+
send.disabled = true; err.textContent = '';
|
|
1178
|
+
call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note: comment,
|
|
1179
|
+
notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries([...picks].map(([k, v]) => [k, v.trim()])) }) }, () => {
|
|
1180
|
+
drafts.delete(draftKey); keep(); location.hash = `#answered/${d.key}`;
|
|
1181
|
+
}).catch((e) => { err.textContent = `Couldn't send back: ${e.message}`; update(); });
|
|
1182
|
+
};
|
|
1183
|
+
panel.append(el('h2', {}, 'Your answers'), progress, el('div', { class: 'plan-next-row' }, status, next), send, err); panel.hidden = !!saved; update();
|
|
1184
|
+
return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1016
1187
|
/** 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.
|
|
1017
1188
|
* The frame is made again only for another page, so a redraw never resets a demo halfway through. */
|
|
1018
1189
|
const reader = document.getElementById('reader');
|
|
@@ -1021,10 +1192,13 @@
|
|
|
1021
1192
|
for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
|
|
1022
1193
|
reader.hidden = !d;
|
|
1023
1194
|
const url = d ? src(mockUrl(d.key, '', d.view)) : '';
|
|
1024
|
-
|
|
1195
|
+
const answer = d?.type === 'plan' ? JSON.stringify(state.answers[d.key] ?? null) : '';
|
|
1196
|
+
const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
|
|
1197
|
+
if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
|
|
1198
|
+
Object.assign(reader.dataset, { answer, plan });
|
|
1025
1199
|
Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
|
|
1026
1200
|
reader.replaceChildren(...(d ? [el('header', {}, el('a', { class: 'close', href: `#${from}` }, `‹ ${from === 'reports' ? 'Reports' : AREAS[from]}`), el('h1', { id: 'reader-title', tabindex: '-1', dir: 'auto' }, d.title)),
|
|
1027
|
-
el('iframe', { src: url, title:
|
|
1201
|
+
d.type === 'plan' ? planReview(d, url) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
|
|
1028
1202
|
}
|
|
1029
1203
|
addEventListener('keydown', (e) => { if (e.key === 'Escape' && !reader.hidden) location.hash = `#${reader.dataset.from}`; });
|
|
1030
1204
|
|
|
@@ -1176,7 +1350,8 @@
|
|
|
1176
1350
|
// An interactive page may post its own height, `{ height: <px> }`, and its frame then fits it with no blank below.
|
|
1177
1351
|
// Only a frame's own message sizes that frame, and never past 10000px.
|
|
1178
1352
|
addEventListener('message', (e) => {
|
|
1179
|
-
const frame = [...document.querySelectorAll('.rung iframe')].find((f) => f.contentWindow === e.source), h = e.data?.height;
|
|
1353
|
+
const frame = [...document.querySelectorAll('.rung iframe, iframe.plan-view')].find((f) => f.contentWindow === e.source), h = e.data?.height;
|
|
1354
|
+
if (frame?.classList.contains('plan-view') && e.data?.['sign-off'] === 'ready') frame.contentWindow.postMessage({ 'sign-off': 'size' }, '*');
|
|
1180
1355
|
if (frame && Number.isFinite(h) && h > 0) frame.style.height = `${Math.ceil(Math.min(h, 10_000))}px`;
|
|
1181
1356
|
});
|
|
1182
1357
|
// A design mock page may point (web/point.js): the board keeps the last element named per mock, and turns pointing on for one mock frame.
|
package/web/point.js
CHANGED
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
};
|
|
32
32
|
addEventListener('message', (m) => {
|
|
33
33
|
if (m.source === parent && m.data?.['sign-off'] === 'point') on = m.data.on === true;
|
|
34
|
+
if (m.source === parent && m.data?.['sign-off'] === 'size') {
|
|
35
|
+
const size = () => parent.postMessage({ height: document.documentElement.scrollHeight }, '*');
|
|
36
|
+
new ResizeObserver(size).observe(document.body); size();
|
|
37
|
+
}
|
|
34
38
|
});
|
|
35
39
|
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) addEventListener(type, (m) => {
|
|
36
40
|
if (!on) return;
|