sign-off-axi 0.4.5 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +28 -2
- package/dist/evidence-text.js +182 -0
- package/fixtures/contract/evidence.v1/shot.png +0 -0
- package/package.json +1 -1
- package/web/index.html +233 -100
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { encode } from '@toon-format/toon';
|
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
13
|
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
14
14
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
|
+
import { faults } from './evidence-text.js';
|
|
15
16
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
17
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
17
18
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
@@ -206,7 +207,7 @@ An empty items list removes that filer's items.
|
|
|
206
207
|
--type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
|
|
207
208
|
an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
|
|
208
209
|
The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
|
|
209
|
-
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link.
|
|
210
|
+
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
|
|
210
211
|
The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
|
|
211
212
|
--type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
|
|
212
213
|
and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --chapter m:ss=<title> (in order) are buttons that jump the video there. Answers work as for a decision.
|
|
@@ -1021,6 +1022,11 @@ function replace(file, named, on, from, filer) {
|
|
|
1021
1022
|
}
|
|
1022
1023
|
/** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key.
|
|
1023
1024
|
* `message` is a checked contract evidence message (post --kind evidence): its key, if any, takes only the same message again, and its run is stored as `evidence`. */
|
|
1025
|
+
const UNFIT = {
|
|
1026
|
+
text: (path) => `--media ${path} is mostly text: a picture of a log, receipt or summary is not evidence`,
|
|
1027
|
+
'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
|
|
1028
|
+
placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
|
|
1029
|
+
};
|
|
1024
1030
|
function postMedia(file, named, on, values, type, filer, message) {
|
|
1025
1031
|
fits(values.title);
|
|
1026
1032
|
const explainer = type === 'explainer';
|
|
@@ -1060,6 +1066,10 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1060
1066
|
throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
|
|
1061
1067
|
return path;
|
|
1062
1068
|
});
|
|
1069
|
+
// A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
|
|
1070
|
+
const [unfit] = explainer ? [] : sources.flatMap((src) => /\.(png|jpe?g|webp)$/i.test(src) ? faults(src).map((f) => ({ src, f })) : []);
|
|
1071
|
+
if (unfit)
|
|
1072
|
+
throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
|
|
1063
1073
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
1064
1074
|
return locked(`${file}.lock`, () => {
|
|
1065
1075
|
const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
|
|
@@ -2081,6 +2091,22 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
|
2081
2091
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2082
2092
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2083
2093
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
2094
|
+
/** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
|
|
2095
|
+
* stored hash, so each picture is measured once. */
|
|
2096
|
+
const unfitMedia = new Map();
|
|
2097
|
+
const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => {
|
|
2098
|
+
if (!unfitMedia.has(m.sha256)) {
|
|
2099
|
+
let found;
|
|
2100
|
+
try {
|
|
2101
|
+
found = faults(under(dirname(file), m.path)).some((f) => f !== 'text');
|
|
2102
|
+
}
|
|
2103
|
+
catch {
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
2106
|
+
unfitMedia.set(m.sha256, found);
|
|
2107
|
+
}
|
|
2108
|
+
return unfitMedia.get(m.sha256);
|
|
2109
|
+
});
|
|
2084
2110
|
const state = () => {
|
|
2085
2111
|
reap();
|
|
2086
2112
|
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
@@ -2091,7 +2117,7 @@ const state = () => {
|
|
|
2091
2117
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
2092
2118
|
// Who filed an item stays in the file: the page only learns that its filer took the answer.
|
|
2093
2119
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
2094
|
-
decisions: loadDecisions(file).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
2120
|
+
decisions: loadDecisions(file).filter((d) => !fakeEvidence(d)).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
2095
2121
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2096
2122
|
readonly: readOnly(),
|
|
2097
2123
|
answers_only: answersOnly(),
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { inflateSync } from 'node:zlib';
|
|
4
|
+
/** A picture of text (a log, receipt or summary) is a few colours: paper, ink and their anti-aliased blends, with no large flat block of a
|
|
5
|
+
* third colour. A real capture of the product has colour, images and icons off that line, or a grey panel covering a good part of the
|
|
6
|
+
* picture. Measured on the calibration set in the PR: text renders, lossy ones too, have at most 0.01% of pixels off the line and a flat
|
|
7
|
+
* third colour of 1.7% at most; real captures have 1.5% or more off the line and a flat third colour of 8.9% or more. */
|
|
8
|
+
const OFF_LINE_MAX = 0.005;
|
|
9
|
+
/** The largest flat area in a third colour (any colour but the paper or the ink), as a share of the picture. */
|
|
10
|
+
const SOLID_MAX = 0.04;
|
|
11
|
+
/** A saturated colour is at least this far between its brightest and dimmest channel; a colour band must cover this share of the picture.
|
|
12
|
+
* A colour-bar card has at least five such bands, together most of the picture; the real captures measured have one at most. */
|
|
13
|
+
const SATURATED = 96;
|
|
14
|
+
const BAND_MIN = 0.05;
|
|
15
|
+
const BARS_MIN = 5;
|
|
16
|
+
const BARS_SHARE_MIN = 0.6;
|
|
17
|
+
/** A placeholder is one flat colour over this share of the picture; the real captures measured top out at 75%, the text renders are text. */
|
|
18
|
+
const PLACEHOLDER_MIN = 0.9;
|
|
19
|
+
/** A picture with no ink at all (a flat colour) is not text, so it passes. */
|
|
20
|
+
const INK_MIN = 0.005;
|
|
21
|
+
/** How close (0-255 RGB distance) a pixel must be to the paper or to the paper-ink line to count as text. */
|
|
22
|
+
const NEAR = 24;
|
|
23
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
24
|
+
/** Samples per pixel for each PNG colour type: grey, RGB, palette, grey with alpha, RGB with alpha. */
|
|
25
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
26
|
+
/** Adam7 interlace passes: x start, y start, x step, y step. A non-interlaced PNG is one pass over every pixel. */
|
|
27
|
+
const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
|
|
28
|
+
const WHOLE = [[0, 0, 1, 1]];
|
|
29
|
+
/** The share of pixels that are ink (a colour off the paper), of pixels off the paper-ink line, and of the largest flat area in any other colour,
|
|
30
|
+
* or undefined when the file cannot be decoded. */
|
|
31
|
+
export function palette(file) {
|
|
32
|
+
const rgb = decode(file);
|
|
33
|
+
if (!rgb)
|
|
34
|
+
return undefined;
|
|
35
|
+
const total = rgb.length / 3, bins = new Uint32Array(32768);
|
|
36
|
+
const centre = (b) => [(b >> 10) * 8 + 4, ((b >> 5) & 31) * 8 + 4, (b & 31) * 8 + 4];
|
|
37
|
+
for (let i = 0; i < rgb.length; i += 3)
|
|
38
|
+
bins[(rgb[i] >> 3) << 10 | (rgb[i + 1] >> 3) << 5 | (rgb[i + 2] >> 3)]++;
|
|
39
|
+
const paperBin = bins.indexOf(Math.max(...bins)), paper = centre(paperBin);
|
|
40
|
+
let inkBin = -1;
|
|
41
|
+
for (let b = 0; b < bins.length; b++) {
|
|
42
|
+
if (bins[b] && dist(centre(b), paper) > 128 && (inkBin < 0 || bins[b] > bins[inkBin]))
|
|
43
|
+
inkBin = b;
|
|
44
|
+
}
|
|
45
|
+
let solid = 0;
|
|
46
|
+
bins.forEach((count, b) => {
|
|
47
|
+
if (b !== paperBin && b !== inkBin && count / total > solid)
|
|
48
|
+
solid = count / total;
|
|
49
|
+
});
|
|
50
|
+
// Saturated flat colours: a colour-bar test card has several, each a band of the picture.
|
|
51
|
+
let bars = 0, barShare = 0;
|
|
52
|
+
bins.forEach((count, b) => {
|
|
53
|
+
const c = centre(b), share = count / total;
|
|
54
|
+
if (Math.max(...c) - Math.min(...c) >= SATURATED && share >= BAND_MIN)
|
|
55
|
+
bars++, (barShare += share);
|
|
56
|
+
});
|
|
57
|
+
const top = bins[paperBin] / total;
|
|
58
|
+
if (inkBin < 0)
|
|
59
|
+
return { ink: 0, off: 0, solid: 0, top, bars, barShare };
|
|
60
|
+
const [pr, pg, pb] = paper, [ar, ag, ab] = centre(inkBin).map((c, i) => c - paper[i]), len2 = ar * ar + ag * ag + ab * ab;
|
|
61
|
+
let ink = 0, off = 0;
|
|
62
|
+
for (let i = 0; i < rgb.length; i += 3) {
|
|
63
|
+
const dr = rgb[i] - pr, dg = rgb[i + 1] - pg, db = rgb[i + 2] - pb;
|
|
64
|
+
if (dr * dr + dg * dg + db * db <= NEAR * NEAR)
|
|
65
|
+
continue;
|
|
66
|
+
const t = Math.min(1, Math.max(0, (dr * ar + dg * ag + db * ab) / len2));
|
|
67
|
+
const er = dr - t * ar, eg = dg - t * ag, eb = db - t * ab;
|
|
68
|
+
if (er * er + eg * eg + eb * eb <= NEAR * NEAR)
|
|
69
|
+
ink++;
|
|
70
|
+
else
|
|
71
|
+
off++;
|
|
72
|
+
}
|
|
73
|
+
return { ink: ink / total, off: off / total, solid, top, bars, barShare };
|
|
74
|
+
}
|
|
75
|
+
/** Why a still is not a picture of the product: text (a log, receipt or summary), colour bars (a test card) or a placeholder (a flat card
|
|
76
|
+
* standing in for a picture). A file that cannot be decoded has no faults: it is not refused. */
|
|
77
|
+
export function faults(file) {
|
|
78
|
+
const share = palette(file);
|
|
79
|
+
if (!share)
|
|
80
|
+
return [];
|
|
81
|
+
const text = share.ink >= INK_MIN && share.off <= OFF_LINE_MAX && share.solid <= SOLID_MAX;
|
|
82
|
+
return [
|
|
83
|
+
...(text ? ['text'] : []),
|
|
84
|
+
...(share.bars >= BARS_MIN && share.barShare >= BARS_SHARE_MIN ? ['colour bars'] : []),
|
|
85
|
+
...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
89
|
+
/** The picture as packed 8-bit RGB. PNG is decoded here; JPEG, WebP and any PNG this decoder refuses go through ImageMagick, when it is installed. */
|
|
90
|
+
function decode(file) {
|
|
91
|
+
const bytes = readFileSync(file);
|
|
92
|
+
if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
93
|
+
const own = pngRgb(bytes);
|
|
94
|
+
if (own)
|
|
95
|
+
return own;
|
|
96
|
+
}
|
|
97
|
+
const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000 });
|
|
98
|
+
return out.status === 0 && out.stdout.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out.stdout) : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
|
|
101
|
+
function pngRgb(buf) {
|
|
102
|
+
try {
|
|
103
|
+
let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
|
|
104
|
+
const idat = [];
|
|
105
|
+
for (let at = 8; at + 8 <= buf.length;) {
|
|
106
|
+
const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
|
|
107
|
+
if (name === 'IHDR')
|
|
108
|
+
[width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
|
|
109
|
+
else if (name === 'PLTE')
|
|
110
|
+
palette = data;
|
|
111
|
+
else if (name === 'IDAT')
|
|
112
|
+
idat.push(data);
|
|
113
|
+
else if (name === 'IEND')
|
|
114
|
+
break;
|
|
115
|
+
at += 12 + len;
|
|
116
|
+
}
|
|
117
|
+
const channels = CHANNELS[type];
|
|
118
|
+
if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
|
|
119
|
+
return undefined;
|
|
120
|
+
const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
|
|
121
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
122
|
+
const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
|
|
123
|
+
let at = 0;
|
|
124
|
+
for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
|
|
125
|
+
const pw = Math.ceil((width - x0) / dx), ph = Math.ceil((height - y0) / dy);
|
|
126
|
+
if (pw <= 0 || ph <= 0)
|
|
127
|
+
continue;
|
|
128
|
+
const rowBytes = Math.ceil(pw * bits / 8), px = Buffer.alloc(rowBytes * ph);
|
|
129
|
+
for (let y = 0; y < ph; y++, at += rowBytes + 1) {
|
|
130
|
+
const filter = raw[at];
|
|
131
|
+
for (let i = 0; i < rowBytes; i++) {
|
|
132
|
+
const a = i >= bpp ? px[y * rowBytes + i - bpp] : 0, b = y ? px[(y - 1) * rowBytes + i] : 0;
|
|
133
|
+
let predict = 0;
|
|
134
|
+
if (filter === 1)
|
|
135
|
+
predict = a;
|
|
136
|
+
else if (filter === 2)
|
|
137
|
+
predict = b;
|
|
138
|
+
else if (filter === 3)
|
|
139
|
+
predict = (a + b) >> 1;
|
|
140
|
+
else if (filter === 4) {
|
|
141
|
+
const c = i >= bpp && y ? px[(y - 1) * rowBytes + i - bpp] : 0, p = a + b - c;
|
|
142
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
143
|
+
predict = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
144
|
+
}
|
|
145
|
+
px[y * rowBytes + i] = (raw[at + 1 + i] + predict) & 0xff;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// The sample at index `n` of a row: a whole byte, a packed value of 1, 2 or 4 bits, or the high byte of 16 bits.
|
|
149
|
+
const sample = (y, n) => {
|
|
150
|
+
if (depth === 16)
|
|
151
|
+
return px[y * rowBytes + n * 2];
|
|
152
|
+
if (depth === 8)
|
|
153
|
+
return px[y * rowBytes + n];
|
|
154
|
+
const shift = 8 - depth - ((n * depth) & 7);
|
|
155
|
+
return (px[y * rowBytes + ((n * depth) >> 3)] >> shift) & ((1 << depth) - 1);
|
|
156
|
+
};
|
|
157
|
+
for (let y = 0; y < ph; y++) {
|
|
158
|
+
for (let x = 0; x < pw; x++) {
|
|
159
|
+
const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * 3;
|
|
160
|
+
if (type === 3) {
|
|
161
|
+
const k = sample(y, n) * 3;
|
|
162
|
+
rgb[o] = palette[k] ?? 0;
|
|
163
|
+
rgb[o + 1] = palette[k + 1] ?? 0;
|
|
164
|
+
rgb[o + 2] = palette[k + 2] ?? 0;
|
|
165
|
+
}
|
|
166
|
+
else if (channels <= 2) {
|
|
167
|
+
rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
rgb[o] = to8(sample(y, n));
|
|
171
|
+
rgb[o + 1] = to8(sample(y, n + 1));
|
|
172
|
+
rgb[o + 2] = to8(sample(y, n + 2));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return rgb;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -67,34 +67,25 @@
|
|
|
67
67
|
.i-waiting .vis .icon svg { width: 2.25rem; height: 2.25rem; }
|
|
68
68
|
.i-reports .mark { width: 0.375rem; height: 1.25rem; border-radius: 2px; background: var(--area); }
|
|
69
69
|
.pair, .areas { display: grid; gap: 1rem; margin-top: 1rem; }
|
|
70
|
-
/*
|
|
71
|
-
.needs .row
|
|
72
|
-
.needs .row .choices
|
|
70
|
+
/* Needs you first keeps the existing answer controls at a readable width. */
|
|
71
|
+
.needs .row { padding: 0; border: 0; background: none; box-shadow: none; grid-template-columns: minmax(0, 1fr); }
|
|
72
|
+
.needs .row .choices { display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
73
73
|
.needs .row .choices, .needs .rung { max-width: 28rem; }
|
|
74
|
-
.needs .media
|
|
75
|
-
.needs .media >
|
|
76
|
-
.needs .mocks
|
|
77
|
-
.focus .mocks { max-width: 24rem; }
|
|
78
|
-
.focus .shot { aspect-ratio: 3 / 4; }
|
|
74
|
+
.needs .media { flex-wrap: nowrap; }
|
|
75
|
+
.needs .media > * { flex: 1 1 0; min-width: 0; }
|
|
76
|
+
.needs .mocks { grid-auto-columns: minmax(0, 1fr); max-width: 16rem; margin: 0; }
|
|
79
77
|
.needs .shot { aspect-ratio: 1; }
|
|
80
|
-
|
|
81
|
-
.fhead { margin: 0; color: var(--muted); font-size: 0.875rem; font-weight: 650; }
|
|
82
|
-
.pbar { height: 4px; margin: 0.5rem 0 1rem; border-radius: 4px; background: var(--line); overflow: hidden; }
|
|
83
|
-
.pbar i { display: block; height: 100%; background: var(--c-waiting); }
|
|
84
|
-
.focus .since { margin: -0.375rem 0 1rem; font-weight: 600; } .needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
|
|
85
|
-
.focus .row h3 { font-size: 1.75rem; line-height: 1.2; letter-spacing: -0.01em; }
|
|
86
|
-
.focus .choices button, .needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
|
|
87
|
-
@media (min-width: 60rem) { .focus .choices button, .needs .choices .rec { min-height: 2.75rem; } }
|
|
78
|
+
.needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
|
|
88
79
|
/* On Summary the recommended answer is the large button, and the others are pills under it. */
|
|
89
80
|
.needs .choices button { font-weight: 600; } .needs .choices button:not(.rec) { justify-self: start; border-radius: 999px; }
|
|
90
81
|
.needs .choices button[aria-pressed="true"] { padding-right: 2.75rem; }
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
.needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
|
|
83
|
+
@media (min-width: 60rem) { .needs .choices .rec { min-height: 2.75rem; } }
|
|
84
|
+
@media (max-width: 59.999rem) {
|
|
85
|
+
body:has(.decide) { padding-bottom: calc(9rem + env(safe-area-inset-bottom)); }
|
|
86
|
+
.top:has(+ .decide) .back { display: none; }
|
|
87
|
+
}
|
|
88
|
+
|
|
98
89
|
/* Reports: a card per report, its steps as numbered nodes on a line with only the marks that matter under each; Open lists them in place. */
|
|
99
90
|
.reports { display: grid; gap: 0.75rem; }
|
|
100
91
|
.report { display: grid; gap: 0.75rem; scroll-margin-top: 4.5rem; }
|
|
@@ -244,8 +235,72 @@
|
|
|
244
235
|
.readonly .rung iframe { height: 80vh; }
|
|
245
236
|
.row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.875rem; padding: 1rem; scroll-margin-top: 4.5rem;
|
|
246
237
|
background: var(--surface); border-radius: 0.875rem; }
|
|
247
|
-
/* Waiting
|
|
248
|
-
.
|
|
238
|
+
/* Waiting and Decide use separate board cards; the R1 answer renderer remains the source of answer writes. */
|
|
239
|
+
.waiting-lede { margin: 0; color: var(--muted); }
|
|
240
|
+
.plan-hero { display: grid; overflow: hidden; margin: 0 0 2rem; border-radius: 1.125rem; background: var(--surface); }
|
|
241
|
+
.plan-preview { width: 100%; height: 12rem; min-width: 0; overflow: hidden; background: var(--sunk); }
|
|
242
|
+
.plan-preview .rung { height: 100%; }
|
|
243
|
+
.plan-preview .rung .frame, .plan-preview .rung iframe { height: 100%; min-height: 0; border: 0; border-radius: 0; }
|
|
244
|
+
.plan-preview .rung > .meta { display: none; }
|
|
245
|
+
.plan-copy { display: grid; align-content: center; gap: .5rem; padding: 1.25rem; }
|
|
246
|
+
.plan-copy .eyebrow, .ask-card .kind { margin: 0; color: var(--warn); font-size: .875rem; font-weight: 600; }
|
|
247
|
+
.plan-copy h2 { margin: 0; font-size: 1.75rem; }
|
|
248
|
+
.plan-copy .about { margin: 0; color: var(--muted); -webkit-line-clamp: 1; }
|
|
249
|
+
.plan-meta { display: flex; flex-wrap: wrap; gap: .35rem 1rem; color: var(--muted); font-size: .875rem; }
|
|
250
|
+
.plan-meta span { white-space: nowrap; }
|
|
251
|
+
.open-link.plan-open { display: inline-flex; width: fit-content; min-height: 2.75rem; padding: 0 1.25rem; font-size: 1rem; }
|
|
252
|
+
.ask-group { list-style: none; margin: .5rem 0 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
|
|
253
|
+
.ask-group > li { position: relative; display: flex; align-items: center; gap: .75rem; min-height: 4.125rem; padding: .5rem .5rem .5rem 0; }
|
|
254
|
+
.ask-group > li + li { border-top: .5px solid var(--line); }
|
|
255
|
+
.ask-card { display: flex; flex: 1; min-width: 0; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
|
|
256
|
+
.ask-card .icon { width: 2rem; height: 2rem; border-radius: .5rem; background: var(--action); }
|
|
257
|
+
.ask-card .icon svg { width: 1.125rem; height: 1.125rem; stroke: white; }
|
|
258
|
+
.ask-card .copy { display: grid; flex: 1; min-width: 0; gap: .125rem; }
|
|
259
|
+
.ask-card .title { font-size: 1.0625rem; font-weight: 550; line-height: 1.3; }
|
|
260
|
+
.ask-card .meta { margin: 0; color: var(--muted); font-size: .9375rem; }
|
|
261
|
+
.ask-group .x { margin: 0; }
|
|
262
|
+
.decide { max-width: 44rem; margin: 0 auto; padding-bottom: 1rem; }
|
|
263
|
+
.decide-preview { margin: .5rem 0 .75rem; padding: 1rem; min-height: 8rem; display: grid; place-items: center; overflow: hidden; border-radius: 1.125rem; background: var(--surface); }
|
|
264
|
+
.decide-preview > img { display: block; max-width: 100%; max-height: 15rem; object-fit: contain; border-radius: .75rem; }
|
|
265
|
+
.decide-preview .rung { width: 100%; }
|
|
266
|
+
.decide-preview .rung .frame, .decide-preview .rung iframe { max-height: 15rem; }
|
|
267
|
+
.decide-preview .mocks { --tall: min(32svh, 16rem); }
|
|
268
|
+
.decide-preview .shot { aspect-ratio: 236 / 273; }
|
|
269
|
+
.decide-preview .mock figcaption .label { flex: 1 1 auto; min-width: 0; font-size: .75rem; }
|
|
270
|
+
.decide-preview .mock figcaption .badge { padding: .1rem .25rem; font-size: .6875rem; white-space: nowrap; }
|
|
271
|
+
@media (max-width: 59.999rem) {
|
|
272
|
+
.top:has(+ .decide) { padding: .25rem 0 0; }
|
|
273
|
+
.decide-preview { margin: .25rem 0 .5rem; padding: .75rem; }
|
|
274
|
+
.decide-preview .mocks { grid-auto-columns: min(85%, 8rem); --tall: min(20svh, 10rem); }
|
|
275
|
+
.decide-preview .shot { aspect-ratio: 1; }
|
|
276
|
+
.decide .lead h2 { margin: .25rem 0; }
|
|
277
|
+
.decide .pick-label { margin: .5rem 0; }
|
|
278
|
+
.decide .silent-line { margin: .5rem 0; }
|
|
279
|
+
.decide .pick-option { min-height: 3.125rem; padding: .5rem 1rem .5rem 0; }
|
|
280
|
+
body:has(.decide) #toast { bottom: calc(9.5rem + env(safe-area-inset-bottom)); }
|
|
281
|
+
}
|
|
282
|
+
.decide h2 { margin: .5rem 0 .5rem; font-size: 1.75rem; line-height: 1.15; letter-spacing: -.01em; }
|
|
283
|
+
.decide .why { margin: 0; color: var(--muted); font-size: 1.0625rem; font-weight: 400; }
|
|
284
|
+
.pick-label { margin: 1rem 0 .5rem; color: var(--muted); font-size: .875rem; letter-spacing: .02em; }
|
|
285
|
+
.pick-list { list-style: none; margin: 0; padding: 0 0 0 1rem; overflow: hidden; border-radius: 1rem; background: var(--surface); }
|
|
286
|
+
.pick-list li + li { border-top: .5px solid var(--line); }
|
|
287
|
+
.pick-option { display: flex; width: 100%; min-height: 2.75rem; align-items: center; gap: .75rem; padding: .5rem 1rem .5rem 0; border: 0; border-radius: 0; background: transparent; text-align: left; }
|
|
288
|
+
.pick-option .words { display: grid; flex: 1; min-width: 0; gap: .125rem; }
|
|
289
|
+
.pick-option .label-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem; }
|
|
290
|
+
.pick-option .label { font-size: 1.25rem; line-height: 1.3; }
|
|
291
|
+
.pick-option .reason { color: var(--muted); font-size: 1rem; line-height: 1.35; }
|
|
292
|
+
.pick-option .recommend { color: var(--accent); font-size: .9375rem; }
|
|
293
|
+
.pick-option .check { width: 1.5rem; color: var(--accent); font-size: 1.5rem; visibility: hidden; }
|
|
294
|
+
.pick-option[aria-pressed="true"] { color: var(--text); background: transparent; }
|
|
295
|
+
.pick-option[aria-pressed="true"]::before { content: none; }
|
|
296
|
+
.pick-option[aria-pressed="true"] .check { visibility: visible; }
|
|
297
|
+
.silent-line { margin: 1.25rem 0; color: var(--muted); }
|
|
298
|
+
.decide-bar { position: fixed; z-index: 1; left: 0; right: 0; bottom: calc(4.125rem + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: .5rem;
|
|
299
|
+
padding: .75rem max(1.25rem, env(safe-area-inset-right)) .75rem max(1.25rem, env(safe-area-inset-left)); background: var(--bg); }
|
|
300
|
+
.decide-bar .answer { flex: 1; background: var(--action); color: var(--on-accent); }
|
|
301
|
+
.decide-bar .later { display: inline-flex; align-items: center; justify-content: center; min-height: 2.75rem; padding: 0 0.75rem; color: var(--accent); background: transparent; }
|
|
302
|
+
.decide-bar .skip { color: var(--muted); background: transparent; }
|
|
303
|
+
.ask-group + .gone-asks { margin-top: 1rem; }
|
|
249
304
|
.in-answered .row { background: transparent; box-shadow: none; }
|
|
250
305
|
.in-answered .row h3 { color: var(--muted); font-weight: 500; }
|
|
251
306
|
/* A sign-in: one big Open for its link, off once the link expired. */
|
|
@@ -437,14 +492,18 @@
|
|
|
437
492
|
button:focus-visible, summary:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
438
493
|
.shot:focus-visible { outline-offset: -2px; }
|
|
439
494
|
.empty { margin: 0; padding: 1.5rem 1rem; color: var(--muted); text-align: center; border: 1px dashed var(--line); border-radius: 0.875rem; }
|
|
495
|
+
@media (max-width: 44.999rem) {
|
|
496
|
+
.plan-preview { display: none; }
|
|
497
|
+
.plan-copy .about { display: block; -webkit-line-clamp: unset; -webkit-box-orient: initial; overflow: visible; }
|
|
498
|
+
}
|
|
440
499
|
@media (min-width: 45rem) {
|
|
441
500
|
main { padding: 0 2rem; }
|
|
442
501
|
.row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
|
|
443
|
-
.in-waiting .row { padding-left: calc(1.25rem - 3px); }
|
|
444
502
|
.row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
|
|
445
503
|
/* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
|
|
446
504
|
.mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
|
|
447
505
|
.rung iframe { height: min(32rem, 75vh); }
|
|
506
|
+
.plan-hero { grid-template-columns: 46% minmax(0, 1fr); min-height: 20rem; }
|
|
448
507
|
/* A picture card stacks like a design card: the picture, then its buttons at their own width. */
|
|
449
508
|
.row:has(> .rung) { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
|
|
450
509
|
.row:has(> .rung) .choices { justify-content: start; }
|
|
@@ -477,12 +536,11 @@
|
|
|
477
536
|
.tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
478
537
|
.pair { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
|
479
538
|
.areas { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
480
|
-
.
|
|
481
|
-
.
|
|
482
|
-
.
|
|
483
|
-
.
|
|
484
|
-
.
|
|
485
|
-
.hint, .upnext { display: none; }
|
|
539
|
+
.plan-preview { height: 20rem; }
|
|
540
|
+
.plan-preview .rung .frame, .plan-preview .rung iframe { min-height: 0; }
|
|
541
|
+
.plan-copy { padding: 2rem; }
|
|
542
|
+
.plan-copy h2, .decide h2 { font-size: 2.5rem; line-height: 1.1; }
|
|
543
|
+
.decide-bar { position: static; margin: 1rem 0 0; padding: .75rem 0; background: transparent; }
|
|
486
544
|
.reports { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
487
545
|
/* Answered sets each row as its own card, two across; an opened row takes the whole width. */
|
|
488
546
|
.rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
|
|
@@ -490,7 +548,6 @@
|
|
|
490
548
|
.rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
|
|
491
549
|
/* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
|
|
492
550
|
.list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
|
|
493
|
-
.keys { display: block; }
|
|
494
551
|
}
|
|
495
552
|
</style>
|
|
496
553
|
</head>
|
|
@@ -532,20 +589,31 @@
|
|
|
532
589
|
|
|
533
590
|
const clearIfSaved = (key, sent) => () => { if (drafts.get(key) === sent) drafts.delete(key); keep(); };
|
|
534
591
|
|
|
535
|
-
function
|
|
536
|
-
|
|
592
|
+
function lock(...btns) {
|
|
593
|
+
const controls = [...btns, ...(btns[0].closest('.decide, .row')?.querySelectorAll('button') ?? [])].filter((b) => !b.disabled);
|
|
594
|
+
controls.forEach((b) => (b.disabled = true));
|
|
595
|
+
return () => controls.forEach((b) => (b.disabled = false));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function save(d, o, buttons, err, after) {
|
|
599
|
+
const unlock = lock(...buttons);
|
|
537
600
|
// An answers-only board takes no comments, and words sent as the answer are the answer itself (a reply item's also go as a comment).
|
|
538
601
|
const typed = state.answers_only || (o.value === 'text' && !d.reply) ? '' : (drafts.get(d.key) ?? '').trim();
|
|
539
602
|
const note = drafts.get(d.key) ?? noteOf(d), before = answerOf(d.key);
|
|
540
603
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
604
|
+
try {
|
|
605
|
+
await call('/api/answers', {
|
|
606
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
|
|
607
|
+
}, () => { forget(d.key); if (!typed) drop(); });
|
|
608
|
+
} catch (e) {
|
|
546
609
|
shown(err).textContent = `Couldn't save: ${e.message}`;
|
|
547
|
-
|
|
548
|
-
}
|
|
610
|
+
return unlock();
|
|
611
|
+
}
|
|
612
|
+
offerUndo(d, before);
|
|
613
|
+
after?.();
|
|
614
|
+
if (typed) call('/api/messages', {
|
|
615
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
|
|
616
|
+
}, drop).catch(() => say('Could not save your comment', 8000));
|
|
549
617
|
}
|
|
550
618
|
|
|
551
619
|
const toast = document.getElementById('toast'), toastText = document.getElementById('toast-text'), undoBtn = document.getElementById('undo');
|
|
@@ -571,11 +639,12 @@
|
|
|
571
639
|
undoBtn.setAttribute('aria-label', `Undo, ${headline(d)}`);
|
|
572
640
|
say(`Saved: ${now.answer === 'text' ? 'your reply' : now.label}`, 5000, () => {
|
|
573
641
|
undoBtn.disabled = true;
|
|
574
|
-
cur = d.key; // Triage shows the ask again.
|
|
575
642
|
call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
576
643
|
body: JSON.stringify({ key: d.key, answer: before?.answer ?? '', note: before?.note ?? '' }) })
|
|
577
|
-
.then(() =>
|
|
578
|
-
(
|
|
644
|
+
.then(() => {
|
|
645
|
+
if (!before) location.hash = askHref(d);
|
|
646
|
+
say(before ? `Back to: ${before.answer === 'text' ? 'your reply' : before.label}` : 'Undone: not answered', 3000);
|
|
647
|
+
}, (e) => say(`Couldn't undo: ${e.message}`, 8000));
|
|
579
648
|
});
|
|
580
649
|
// The tap redrew its card and took the focus with it: put it on Undo so the keyboard can reach it in time.
|
|
581
650
|
if (document.activeElement === document.body || !document.activeElement) undoBtn.focus();
|
|
@@ -586,11 +655,11 @@
|
|
|
586
655
|
|
|
587
656
|
/** Sends a comment or a queued prompt for the agent; the typed text stays until the server has it. */
|
|
588
657
|
function send(btn, err, body, draft, text) {
|
|
589
|
-
|
|
658
|
+
const unlock = lock(btn);
|
|
590
659
|
call('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, text }) },
|
|
591
660
|
clearIfSaved(draft, drafts.get(draft))).catch((e) => {
|
|
592
661
|
shown(err).textContent = `Couldn't send: ${e.message}`;
|
|
593
|
-
|
|
662
|
+
unlock();
|
|
594
663
|
});
|
|
595
664
|
}
|
|
596
665
|
|
|
@@ -701,17 +770,20 @@
|
|
|
701
770
|
ro ? '' : other(d, saved, err), err, tech(d));
|
|
702
771
|
}
|
|
703
772
|
|
|
773
|
+
/** A count with its unit in the form it deserves: 1 section, 2 sections. */
|
|
774
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
704
775
|
/** A plan: its one-line answer, how many sections and decisions it asks about, and one Open plan for its page over the board. */
|
|
705
776
|
function plan(d, lead) {
|
|
706
|
-
const count = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
|
707
777
|
return el('article', { class: 'row plan', id: `item-${d.key}` }, el('div', {}, lead,
|
|
708
|
-
el('p', { class: 'meta' }, `${
|
|
778
|
+
el('p', { class: 'meta' }, `${plural(d.sections.length, 'section')} · ${plural(d.decisions.length, 'decision')}`)),
|
|
709
779
|
el('a', { class: 'open-link', href: `#${area(d)}/${d.key}`, 'aria-label': `Open plan, ${d.title}` }, 'Open plan'));
|
|
710
780
|
}
|
|
781
|
+
/** A span of minutes: whole minutes under the hour, hours and the rest over it. */
|
|
782
|
+
const mins = (m) => (m < 60 ? `${m} min` : `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ''}`);
|
|
711
783
|
/** The time left on a sign-in link, in whole minutes. */
|
|
712
784
|
const expiry = (at) => {
|
|
713
785
|
const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000);
|
|
714
|
-
return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${
|
|
786
|
+
return m <= 0 ? 'Link expired. Ask for a new link.' : `Expires in ${mins(m)}`;
|
|
715
787
|
};
|
|
716
788
|
/** A sign-in: the service, what it unlocks, who waits, the time left and one Open for its link. Its poster closes it with signin done. */
|
|
717
789
|
function signin(d, lead, saved, err) {
|
|
@@ -772,24 +844,24 @@
|
|
|
772
844
|
/** The one place to type on a card, folded behind Other answer so the buttons come first and typing is the last resort.
|
|
773
845
|
* Sent on its own the words are the answer (`text`); typed before a button is picked they go with that answer as its note.
|
|
774
846
|
* On a normal board they can also ask for another round (not on an evidence update) or go as a comment. */
|
|
775
|
-
function other(d, saved, err) {
|
|
847
|
+
function other(d, saved, err, after) {
|
|
776
848
|
const box = el('textarea', { id: `note-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' });
|
|
777
849
|
box.value = drafts.get(d.key) ?? (saved?.answer === 'text' ? '' : noteOf(d));
|
|
778
850
|
box.oninput = () => { drafts.set(d.key, box.value); keep(); };
|
|
779
851
|
// An option valued text would be the same answer, so that item takes words only as a note.
|
|
780
852
|
const words = !d.options.some((o) => o.value === 'text'), talk = !state.answers_only, round = talk && d.type !== 'evidence';
|
|
781
853
|
const sendBtn = el('button', { 'aria-label': `Send reply, ${headline(d)}` }, 'Send reply');
|
|
782
|
-
sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err) : (shown(err).textContent = 'Write the reply first.'));
|
|
854
|
+
sendBtn.onclick = () => (box.value.trim() ? save(d, { value: 'text' }, [sendBtn], err, after) : (shown(err).textContent = 'Write the reply first.'));
|
|
783
855
|
const roundBtn = el('button', { class: 'quick', 'aria-label': `Another round, ${headline(d)}` }, 'Another round');
|
|
784
856
|
roundBtn.onclick = () => {
|
|
785
|
-
|
|
857
|
+
const unlock = lock(roundBtn);
|
|
786
858
|
const note = (drafts.get(d.key) ?? noteOf(d)).trim();
|
|
787
859
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
788
860
|
call('/api/rounds', {
|
|
789
861
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
|
|
790
862
|
}, () => { forget(d.key); drop(); }).catch((e) => {
|
|
791
863
|
shown(err).textContent = `Couldn't ask: ${e.message}`;
|
|
792
|
-
|
|
864
|
+
unlock();
|
|
793
865
|
});
|
|
794
866
|
};
|
|
795
867
|
const commentBtn = el('button', { 'aria-label': `Send as a comment, ${headline(d)}` }, 'Send as a comment');
|
|
@@ -989,6 +1061,13 @@
|
|
|
989
1061
|
/** A page to open: an .html view, framed in a sandbox. */
|
|
990
1062
|
const reads = (d) => /\.html$/i.test(d.view ?? '');
|
|
991
1063
|
const hhmm = (at) => new Date(at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
1064
|
+
/** Where an item opens: its own area, and inside Waiting on you a plan or a design has its own screen; every other ask opens on Decide. */
|
|
1065
|
+
const askHref = (d) => {
|
|
1066
|
+
const a = area(d);
|
|
1067
|
+
if (a !== 'waiting') return `#${a === 'notes' ? 'reports' : a}`;
|
|
1068
|
+
return d.type === 'plan' ? `#waiting/${d.key}` : d.kind === 'design' ? `#designs/${d.key}` : `#decide/${d.key}`;
|
|
1069
|
+
};
|
|
1070
|
+
const left = (at) => { const m = Math.ceil((Date.parse(at) - Date.now()) / 60_000); return m <= 0 ? 'deadline passed' : `${mins(m)} left`; };
|
|
992
1071
|
const isToday = (at) => new Date(at).toDateString() === new Date().toDateString();
|
|
993
1072
|
const day = (at) => new Date(at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
994
1073
|
const when = (at) => (isToday(at) ? hhmm(at) : `${day(at)} ${hhmm(at)}`);
|
|
@@ -1045,7 +1124,7 @@
|
|
|
1045
1124
|
/** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
|
|
1046
1125
|
function section(a, items) {
|
|
1047
1126
|
const SHOWN = 4, line = (d) => {
|
|
1048
|
-
const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : `#${a}` }, ...compact(d, a));
|
|
1127
|
+
const link = el('a', { class: 'compact', href: a === 'reports' && reads(d) ? `#reports/${d.key}` : a === 'waiting' ? askHref(d) : `#${a}` }, ...compact(d, a));
|
|
1049
1128
|
link.onclick = () => (jump = d.key);
|
|
1050
1129
|
return el('li', {}, link, xBtn(d));
|
|
1051
1130
|
};
|
|
@@ -1153,7 +1232,7 @@
|
|
|
1153
1232
|
}
|
|
1154
1233
|
main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
|
|
1155
1234
|
if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
|
|
1156
|
-
el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length}
|
|
1235
|
+
el('p', { class: 'plan-summary' }, icon('plan'), `${plural(d.sections.length, 'section')} to approve or change, and ${plural(d.decisions.length, 'decision')} to make. Nothing is built until you send back.`));
|
|
1157
1236
|
main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note, an approval can carry a comment, and a decision takes an option or your own words.'));
|
|
1158
1237
|
d.sections.forEach((s, i) => {
|
|
1159
1238
|
const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
|
|
@@ -1427,43 +1506,90 @@
|
|
|
1427
1506
|
sel.onchange = () => { waitSort = sel.value; try { localStorage.setItem(SORT_KEY, waitSort); } catch {} reshow('.sort select'); };
|
|
1428
1507
|
return el('div', { class: 'filters' }, el('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by type' }, ...chips), el('label', { class: 'sort' }, 'Sort', sel));
|
|
1429
1508
|
}
|
|
1430
|
-
/**
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
el('
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1509
|
+
/** The plan is the lead card; the remaining asks are a grouped list linking to a focused decision. */
|
|
1510
|
+
function planCard(d) {
|
|
1511
|
+
const meta = [el('span', {}, plural(d.sections.length, 'section')), el('span', {}, plural(d.decisions.length, 'decision')),
|
|
1512
|
+
el('span', {}, `Agent${d.filed ? ` · ${when(d.filed)}` : ''}`)];
|
|
1513
|
+
return el('article', { class: 'plan-hero', id: `item-${d.key}` }, el('div', { class: 'plan-preview' }, rung(d)),
|
|
1514
|
+
el('div', { class: 'plan-copy' }, el('p', { class: 'eyebrow' }, `Plan${d.project ? ` · ${d.project}` : ''}`),
|
|
1515
|
+
el('h2', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about ?? ''),
|
|
1516
|
+
el('div', { class: 'plan-meta' }, ...meta), el('a', { class: 'open-link plan-open', href: `#waiting/${d.key}` }, 'Open plan')));
|
|
1517
|
+
}
|
|
1518
|
+
const askIcon = (d) => { const a = { design: 'designs', signin: 'you', plan: 'plan' }[typeOf(d)] ?? 'waiting'; return ICONS[a] ? a : 'waiting'; };
|
|
1519
|
+
function waitingPage(items, hidden) {
|
|
1520
|
+
const plans = items.filter((d) => d.type === 'plan'), asks = items.filter((d) => d.type !== 'plan');
|
|
1521
|
+
const rows = asks.map((d) => {
|
|
1522
|
+
const label = `Agent${d.silent_by ? ` · closes at ${when(d.silent_by)}` : d.filed ? ` · waiting since ${when(d.filed)}` : ''}`;
|
|
1523
|
+
const link = el('a', { class: 'ask-card', id: `item-${d.key}`, href: askHref(d) }, icon(askIcon(d)),
|
|
1524
|
+
el('span', { class: 'copy' }, el('span', { class: 'kind' }, kind(d)), el('strong', { class: 'title', dir: 'auto' }, title(d)),
|
|
1525
|
+
d.why || d.about ? el('span', { class: 'meta', dir: 'auto' }, d.why || d.about) : '', el('span', { class: 'meta' }, label)));
|
|
1526
|
+
return el('li', {}, link, xBtn(d));
|
|
1448
1527
|
});
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
})
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1528
|
+
return el('div', { class: 'waiting-page' },
|
|
1529
|
+
...plans.map(planCard), ...(asks.length ? [el('p', { class: 'pick-label' }, 'ASKS'), el('ul', { class: 'ask-group' }, ...rows)] : []),
|
|
1530
|
+
!items.length ? el('p', { class: 'empty' }, EMPTY.waiting) : '', dismissed(hidden));
|
|
1531
|
+
}
|
|
1532
|
+
const selected = new Map();
|
|
1533
|
+
function decidePage(d, next) {
|
|
1534
|
+
if (!d) return el('p', { class: 'empty' }, EMPTY.waiting);
|
|
1535
|
+
const to = next ? `#decide/${next.key}` : '#waiting', skip = next && !state.readonly ? el('button', { class: 'skip' }, 'Skip for now') : null;
|
|
1536
|
+
if (skip) skip.onclick = () => (location.hash = to);
|
|
1537
|
+
const swipe = (page, right) => {
|
|
1538
|
+
let start;
|
|
1539
|
+
page.addEventListener('touchstart', (e) => (start = e.target.closest('.mocks, textarea') ? null : e.touches[0]), { passive: true });
|
|
1540
|
+
page.addEventListener('touchend', (e) => {
|
|
1541
|
+
const t = e.changedTouches[0], dx = start ? t.clientX - start.clientX : 0;
|
|
1542
|
+
if (!start || Math.abs(dx) <= 90 || Math.abs(t.clientY - start.clientY) >= Math.abs(dx) / 2) return;
|
|
1543
|
+
if (dx > 0) right?.(); else skip?.click();
|
|
1544
|
+
});
|
|
1545
|
+
};
|
|
1546
|
+
if (d.type === 'signin') {
|
|
1547
|
+
const signinPage = el('div', { class: 'decide' }, row(d, { x: false }), el('div', { class: 'decide-bar' }, skip || '', el('a', { class: 'later', href: '#waiting' }, 'Later')));
|
|
1548
|
+
if (!state.readonly) swipe(signinPage);
|
|
1549
|
+
return signinPage;
|
|
1550
|
+
}
|
|
1551
|
+
const err = el('div', { class: 'err', role: 'alert', id: `err-${d.key}` }), opts = d.options ?? [], live = !state.readonly;
|
|
1552
|
+
// A pick remembered from an earlier round only counts while the ask still offers it.
|
|
1553
|
+
if (!opts.some((o) => o.value === selected.get(d.key))) selected.delete(d.key);
|
|
1554
|
+
const initial = selected.get(d.key) ?? d.recommend_value ?? opts[0]?.value;
|
|
1555
|
+
const optionButtons = opts.map((o) => {
|
|
1556
|
+
const recommended = o.value === d.recommend_value, b = el('button', { class: 'pick-option', 'aria-pressed': String(o.value === initial) },
|
|
1557
|
+
el('span', { class: 'words' }, el('span', { class: 'label-line' }, el('span', { class: 'label' }, o.label),
|
|
1558
|
+
recommended ? el('span', { class: 'recommend' }, 'Recommended') : ''), o.outcome ? el('span', { class: 'reason', dir: 'auto' }, o.outcome) : ''), el('span', { class: 'check', 'aria-hidden': 'true' }, '✓'));
|
|
1559
|
+
b.onclick = () => { selected.set(d.key, o.value); optionButtons.forEach((btn) => btn.setAttribute('aria-pressed', String(btn === b))); answer.textContent = `Answer ${o.label}`; };
|
|
1560
|
+
return b;
|
|
1561
|
+
});
|
|
1562
|
+
const selectedOption = () => opts.find((o) => o.value === (selected.get(d.key) ?? initial));
|
|
1563
|
+
const answer = el('button', { class: 'answer' }, `Answer ${selectedOption()?.label ?? 'now'}`);
|
|
1564
|
+
const answerWith = (o) => save(d, o, [...optionButtons, answer], err, () => (location.hash = to));
|
|
1565
|
+
answer.onclick = () => { const o = selectedOption(); if (o) answerWith(o); };
|
|
1566
|
+
const recOpt = opts.find((o) => o.value === d.recommend_value);
|
|
1567
|
+
const cards = d.mocks?.length && d.options?.length ? mockCards(d, []) : null;
|
|
1568
|
+
const preview = cards ?? (d.view ? rung(d) : pic(d) ? thumb('waiting', pic(d)) : proof(d));
|
|
1569
|
+
const quiet = opts.find((o) => o.value === d.default_if_silent);
|
|
1570
|
+
const silent = quiet ? `If you stay quiet: ${quiet.label}${d.silent_by ? ` · ${left(d.silent_by)}` : ''}` : '';
|
|
1571
|
+
const head = el('div', { class: 'lead' }, el('p', { class: 'eyebrow' }, kind(d)), el('h2', { dir: 'auto' }, headline(d)));
|
|
1572
|
+
const why = d.why || d.tradeoff || d.about;
|
|
1573
|
+
const back = el('a', { class: 'later', href: '#waiting' }, 'Later');
|
|
1574
|
+
const page = el('section', { class: 'decide' }, preview ? el('div', { class: 'decide-preview' }, preview) : '',
|
|
1575
|
+
...(d.mocks?.length ? [annotations(d)] : []), head,
|
|
1576
|
+
why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
|
|
1577
|
+
...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
|
|
1578
|
+
el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
|
|
1579
|
+
...(live ? [other(d, answerOf(d.key), err, () => (location.hash = to))] : []),
|
|
1580
|
+
silent ? el('p', { class: 'silent-line' }, silent) : '', err,
|
|
1581
|
+
el('div', { class: 'decide-bar' }, live && opts.length ? answer : '', skip || '', back));
|
|
1582
|
+
if (live) swipe(page, () => { if (recOpt && !answer.disabled) answerWith(recOpt); });
|
|
1583
|
+
return page;
|
|
1461
1584
|
}
|
|
1462
|
-
|
|
1585
|
+
const reshow = (control) => { render(); document.querySelector(control)?.focus({ preventScroll: true }); };
|
|
1586
|
+
// On Decide, a number selects and submits an answer; E skips to the next ask, Z undoes the last answer.
|
|
1463
1587
|
addEventListener('keydown', (e) => {
|
|
1464
|
-
if (
|
|
1465
|
-
const k = e.key.toLowerCase(),
|
|
1466
|
-
const
|
|
1588
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest?.('textarea, input, select')) return;
|
|
1589
|
+
const k = e.key.toLowerCase(), onDecide = /^#decide\//.test(location.hash), choices = [...document.querySelectorAll('.decide .pick-option')];
|
|
1590
|
+
const pick = onDecide && /^[1-9]$/.test(k) ? choices[Number(k) - 1] : null;
|
|
1591
|
+
if (pick) { e.preventDefault(); pick.click(); document.querySelector('.decide-bar .answer')?.click(); return; }
|
|
1592
|
+
const act = k === 'e' && onDecide ? document.querySelector('.decide .skip') : k === 'z' && (location.hash === '#waiting' || onDecide) && !toast.hidden && !undoBtn.hidden ? undoBtn : null;
|
|
1467
1593
|
if (act && !act.disabled) e.preventDefault(), act.click();
|
|
1468
1594
|
});
|
|
1469
1595
|
|
|
@@ -1488,23 +1614,30 @@
|
|
|
1488
1614
|
const shown = waitType === 'all' ? by.waiting : by.waiting.filter((d) => typeOf(d) === waitType);
|
|
1489
1615
|
const hidden = state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && area(d) === 'waiting');
|
|
1490
1616
|
by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
|
|
1491
|
-
const [, at, key] = /^#(waiting|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1492
|
-
const
|
|
1617
|
+
const [, at, key] = /^#(waiting|decide|designs|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
|
|
1618
|
+
const decideQueue = by.waiting.filter((d) => d.type === 'signin' || (d.type !== 'plan' && d.kind !== 'design'));
|
|
1619
|
+
const askAt = decideQueue.findIndex((d) => d.key === key), decideAt = key ? askAt : 0;
|
|
1620
|
+
const active = at === 'decide' ? 'waiting' : at;
|
|
1621
|
+
const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (active ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
|
|
1493
1622
|
document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
|
|
1494
1623
|
document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
|
|
1495
|
-
...(a ===
|
|
1624
|
+
...(a === active && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
|
|
1496
1625
|
const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
|
|
1497
|
-
const
|
|
1626
|
+
const pageTitle = at === 'decide' ? 'Decide' : AREAS[at];
|
|
1627
|
+
const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: at === 'decide' ? '#waiting' : '#' }, at === 'decide' ? '‹ Waiting on you' : '‹ Summary')
|
|
1498
1628
|
: el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
|
|
1499
|
-
el('h1', { tabindex: '-1' }, at ?
|
|
1500
|
-
at === '
|
|
1629
|
+
el('h1', { tabindex: '-1' }, at ? pageTitle : 'Summary'), at ? '' : icon('you'), ro ? el('p', { class: 'meta' }, ro) : '',
|
|
1630
|
+
at === 'waiting' && !state.readonly && by.waiting.length ? el('p', { class: 'lede waiting-lede' }, `${shown.length} ${shown.length === 1 ? 'item needs' : 'items need'} you · ${new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}`)
|
|
1631
|
+
: at === 'reports' && !state.readonly && by.reports.length ? el('p', { class: 'lede' }, by.reports.some((d) => d.steps)
|
|
1501
1632
|
? 'How the tools work, step by step. Tap one to open it.' : 'Explainers the agents made for you. Tap one to open it.')
|
|
1502
1633
|
: at === 'answered' && by.answered.length ? el('p', { class: 'lede' }, `${by.answered.filter((d) => isToday(state.answers[d.key].at)).length} answered today.`)
|
|
1503
1634
|
: at === 'evidence' && by.evidence.length ? el('p', { class: 'lede' }, updates(by.evidence)) : '');
|
|
1504
|
-
const first = by.waiting[0], more = by.waiting.length > 1 ? `
|
|
1635
|
+
const first = by.waiting[0], more = by.waiting.length > 1 ? `Review all ${by.waiting.length} →` : 'Review ask →';
|
|
1505
1636
|
const needs = el('section', { class: 'card needs' }, el('h2', {}, 'Needs you first'), ...(first ? [first.filed ? el('p', { class: 'meta since' }, `Waiting since ${when(first.filed)}`) : '', row(first, { x: false }),
|
|
1506
1637
|
el('div', { class: 'foot' }, xBtn(first, 'Dismiss'), first.kind === 'design' ? el('a', { class: 'open-link', href: `#designs/${first.key}` }, 'Open designs') : el('a', { class: 'more', href: '#waiting' }, more))] : [el('p', { class: 'meta' }, 'Nothing is waiting on you.')]), dismissed(hidden));
|
|
1507
|
-
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting),
|
|
1638
|
+
document.getElementById('board').replaceChildren(top, ...(at === 'waiting' && !state.readonly ? [filters(by.waiting), waitingPage(shown, hidden)]
|
|
1639
|
+
: at === 'decide' ? [decideAt < 0 ? el('p', { class: 'empty' }, 'Nothing to decide for that question.')
|
|
1640
|
+
: decidePage(decideQueue[decideAt], decideQueue.length > 1 ? decideQueue[(decideAt + 1) % decideQueue.length] : undefined)]
|
|
1508
1641
|
: at === 'designs' ? [...designGroups(by.designs)] : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
|
|
1509
1642
|
...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes)] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'evidence' && by[at].length ? el('div', { class: 'sec-head' }, el('h2', {}, 'Updates')) : '', at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
|
|
1510
1643
|
: el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [el('div', { class: 'tiles' }, ...Object.keys(AREAS).map((a) => tile(a, by[a]))),
|
|
@@ -1515,7 +1648,7 @@
|
|
|
1515
1648
|
}
|
|
1516
1649
|
function freeze() {
|
|
1517
1650
|
// Answers stay as they are, but nothing in the copy can be sent.
|
|
1518
|
-
document.querySelectorAll('#board .choices button, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
|
|
1651
|
+
document.querySelectorAll('#board .choices button, #board .pick-list button, #board .decide-bar .answer, #board .mock button, #board .note button, #board .note textarea').forEach((e) => (e.disabled = true));
|
|
1519
1652
|
// A browser will not open a data: link in a new tab; the inline preview is the copy.
|
|
1520
1653
|
document.querySelectorAll('#board a[href^="data:"]').forEach((a) => a.removeAttribute('href'));
|
|
1521
1654
|
}
|
|
@@ -1523,7 +1656,7 @@
|
|
|
1523
1656
|
// Closing a report or plan goes back to its card, with the focus on its link; an open report takes the focus on its title.
|
|
1524
1657
|
addEventListener('hashchange', () => {
|
|
1525
1658
|
const back = !reader.hidden && reader.dataset.key;
|
|
1526
|
-
if (jump && location.hash === '#waiting')
|
|
1659
|
+
if (jump && location.hash === '#waiting') jump = null;
|
|
1527
1660
|
if (location.hash === '#waiting') waitType = 'all';
|
|
1528
1661
|
render();
|
|
1529
1662
|
if (!reader.hidden) { scrollTo(0, 0); return reader.querySelector('h1').focus({ preventScroll: true }); }
|