sign-off-axi 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +19 -6
- package/dist/contract.js +70 -3
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +18 -1
- package/web/diagram-page.js +22 -0
- package/web/index.html +101 -13
package/dist/cli.js
CHANGED
|
@@ -13,8 +13,8 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
import { encode } from '@toon-format/toon';
|
|
14
14
|
import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
|
|
15
15
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
16
|
-
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
|
-
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
16
|
+
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
|
+
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
18
18
|
import { faults, isStill } from './evidence-text.js';
|
|
19
19
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
20
20
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
@@ -453,6 +453,8 @@ function contractItems(items, from, file, replace = false) {
|
|
|
453
453
|
checkMessage(check);
|
|
454
454
|
if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
|
|
455
455
|
reportPage(check, dirname(file));
|
|
456
|
+
if (guided(check))
|
|
457
|
+
diagramPages(check, dirname(file));
|
|
456
458
|
if (check.kind === 'design')
|
|
457
459
|
designFiles(check, dirname(file));
|
|
458
460
|
}
|
|
@@ -1466,6 +1468,8 @@ async function postKind(file, named, on, values, built) {
|
|
|
1466
1468
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1467
1469
|
if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
|
|
1468
1470
|
reportPage(m, dirname(file));
|
|
1471
|
+
if (guided(m))
|
|
1472
|
+
diagramPages(m, dirname(file));
|
|
1469
1473
|
if (m.kind === 'design')
|
|
1470
1474
|
designFiles(m, dirname(file));
|
|
1471
1475
|
}
|
|
@@ -2503,6 +2507,8 @@ const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-i
|
|
|
2503
2507
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2504
2508
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2505
2509
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
2510
|
+
/** Added to each diagram page: on the page opened by itself, a way back to its plan or report, and a phone's drawing-alone page sized to the drawing; see web/diagram-page.js. */
|
|
2511
|
+
const diagramPage = (back, label) => Buffer.from(`<script data-back="${back}" data-label="${label}">${readFileSync(new URL('../web/diagram-page.js', import.meta.url), 'utf8')}</script>`);
|
|
2506
2512
|
/** 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
|
|
2507
2513
|
* stored hash, so each picture is measured once. */
|
|
2508
2514
|
const unfitMedia = new Map();
|
|
@@ -2749,7 +2755,10 @@ async function route(req) {
|
|
|
2749
2755
|
// and the files beside it in its own folder, so a mock page's relative css, scripts and images load.
|
|
2750
2756
|
const [item, ...rest] = new URL(req.url, 'http://localhost').pathname.split('/').slice(3), [key, value] = item?.split('~') ?? [];
|
|
2751
2757
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
2752
|
-
|
|
2758
|
+
// A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
|
|
2759
|
+
const dgs = d?.guided ? diagramViews(d) : [];
|
|
2760
|
+
const diagram = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))] : undefined;
|
|
2761
|
+
const mock = diagram ? diagram.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
|
|
2753
2762
|
try {
|
|
2754
2763
|
const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
|
|
2755
2764
|
if (!mock || !type)
|
|
@@ -2757,8 +2766,12 @@ async function route(req) {
|
|
|
2757
2766
|
const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
|
|
2758
2767
|
if (statSync(actual).size > MOCK_BYTES)
|
|
2759
2768
|
return [404, { error: 'Mock is unavailable' }];
|
|
2760
|
-
|
|
2761
|
-
|
|
2769
|
+
const bytes = readFileSync(actual);
|
|
2770
|
+
// A diagram is its one pinned page: the file the plan posted, or nothing. A plan posted before the pin carries none and is served as it was.
|
|
2771
|
+
if (diagram?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== diagram.bytes || sha256(bytes) !== diagram.sha256))
|
|
2772
|
+
return [404, { error: 'Diagram file changed since it was posted' }];
|
|
2773
|
+
// Every HTML view gets the bridge: document readers need its size handshake too. A diagram also gets its way back.
|
|
2774
|
+
return [200, { media: bytes, type, point: type === 'text/html', ...(diagram && type === 'text/html' && d && { page: diagramPage(`/#${d.type === 'plan' && !reading(d) ? 'waiting' : 'reports'}/${d.key}`, `Back to the ${d.type === 'plan' ? 'plan' : 'report'}`) }) }];
|
|
2762
2775
|
}
|
|
2763
2776
|
catch {
|
|
2764
2777
|
return [404, { error: 'Mock is unavailable' }];
|
|
@@ -3147,7 +3160,7 @@ const server = createServer(async (req, res) => {
|
|
|
3147
3160
|
return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
|
|
3148
3161
|
}
|
|
3149
3162
|
if (body && typeof body === 'object' && 'media' in body) {
|
|
3150
|
-
const { media: bytes, type, point } = body, media = point ? Buffer.concat([bytes, pointer]) : bytes;
|
|
3163
|
+
const { media: bytes, type, point, page } = body, media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes;
|
|
3151
3164
|
const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
|
|
3152
3165
|
// One byte range, so phone browsers can play and seek a recording.
|
|
3153
3166
|
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
|
package/dist/contract.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
4
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
5
|
import { isKey, MEDIA_MAX, REPORT_FORMS } from './store.js';
|
|
@@ -211,6 +212,17 @@ export function checkMessage(m, want, flags = false) {
|
|
|
211
212
|
text(`${field}[${n}].label`, o.label, 40, 'the number\'s name, in 40 characters or fewer');
|
|
212
213
|
text(`${field}[${n}].value`, o.value, 120, 'the number and its unit, in 120 characters or fewer');
|
|
213
214
|
});
|
|
215
|
+
/** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. Post adds each file's `sha256` and `bytes`, as it does for media. */
|
|
216
|
+
const diagrams = (field, v) => list(field, 1, 3, ['label', 'src', 'sha256', 'bytes'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
|
|
217
|
+
if (Object.hasOwn(o, 'sha256') && !/^[a-f0-9]{64}$/.test(o.sha256))
|
|
218
|
+
throw bad(`${field}[${n}].sha256`, 'must be 64 lowercase hex digits', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
219
|
+
if (Object.hasOwn(o, 'bytes') && !(Number.isInteger(o.bytes) && o.bytes >= 0))
|
|
220
|
+
throw bad(`${field}[${n}].bytes`, 'must be a whole number of bytes', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
221
|
+
text(`${field}[${n}].label`, o.label, 40, 'the view name, such as "Today", in 40 characters or fewer');
|
|
222
|
+
text(`${field}[${n}].src`, o.src, 256, 'a page path from the board file\'s folder, such as "diagrams/flow.html"');
|
|
223
|
+
if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
|
|
224
|
+
throw bad(`${field}[${n}].src`, `${show(o.src)} must be a relative .html path with no .., ?, # or \\`, 'Draw the diagram with the archify skill, then set "src" to its .html file next to the message');
|
|
225
|
+
});
|
|
214
226
|
/** The guided page every plan 2 and report 3 is: what I understood, one picture, sections with expandable items, and the next step. Returns the section ids. */
|
|
215
227
|
const guide = (minSections) => {
|
|
216
228
|
list('understood', 1, 6, ['label', 'text'], '{"label":"You asked","text":"Make the first start smooth."}').forEach((o, n) => {
|
|
@@ -220,7 +232,7 @@ export function checkMessage(m, want, flags = false) {
|
|
|
220
232
|
const v = m.visual;
|
|
221
233
|
if (!Object.hasOwn(m, 'visual'))
|
|
222
234
|
throw bad('visual', 'is required: one picture for the answer', 'Add "visual": {"type":"bars","heading":"...","rows":[{"label":"Cloud box","value":4.5}]}');
|
|
223
|
-
one('visual', v, ['type', 'heading', 'text', 'unit', 'rows', 'details'], '{"type":"bars","heading":"...","rows":[...]}');
|
|
235
|
+
one('visual', v, ['type', 'heading', 'text', 'unit', 'rows', 'details', 'diagram'], '{"type":"bars","heading":"...","rows":[...]}');
|
|
224
236
|
if (!['bars', 'steps', 'tiles'].includes(v.type))
|
|
225
237
|
throw bad('visual.type', `${show(v.type)} is not one of bars, steps, tiles`, 'Set "type" to "bars" for numbers, "steps" for a sequence, "tiles" for parallel points');
|
|
226
238
|
text('visual.heading', v.heading, 100, 'the point the picture makes, in 100 characters or fewer');
|
|
@@ -253,7 +265,9 @@ export function checkMessage(m, want, flags = false) {
|
|
|
253
265
|
});
|
|
254
266
|
if (Object.hasOwn(v, 'details'))
|
|
255
267
|
details('visual.details', v.details);
|
|
256
|
-
|
|
268
|
+
if (Object.hasOwn(v, 'diagram'))
|
|
269
|
+
diagrams('visual.diagram', v.diagram);
|
|
270
|
+
const ids = list('sections', minSections, 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram'], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
|
|
257
271
|
const at = `sections[${n}]`;
|
|
258
272
|
if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
|
|
259
273
|
throw bad(`${at}.id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "steps"');
|
|
@@ -262,15 +276,19 @@ export function checkMessage(m, want, flags = false) {
|
|
|
262
276
|
text(`${at}.heading`, o.heading, 80, 'the section heading, in 80 characters or fewer');
|
|
263
277
|
text(`${at}.body`, o.body, 300, 'the section\'s lead, in 300 characters or fewer');
|
|
264
278
|
if (Object.hasOwn(o, 'items'))
|
|
265
|
-
list(`${at}.items`, 1, 8, ['title', 'text', 'more', 'tone'], '{"title":"Ship 0.2.5","text":"You test it.","more":"..."}', o.items).forEach((it, k) => {
|
|
279
|
+
list(`${at}.items`, 1, 8, ['title', 'text', 'more', 'tone', 'diagram'], '{"title":"Ship 0.2.5","text":"You test it.","more":"..."}', o.items).forEach((it, k) => {
|
|
266
280
|
text(`${at}.items[${k}].title`, it.title, 80, 'the item, in 80 characters or fewer');
|
|
267
281
|
text(`${at}.items[${k}].text`, it.text, 160, 'one line under it, in 160 characters or fewer');
|
|
268
282
|
if (Object.hasOwn(it, 'more'))
|
|
269
283
|
text(`${at}.items[${k}].more`, it.more, 600, 'what opens when it is tapped, in 600 characters or fewer, or leave it out');
|
|
270
284
|
tone(`${at}.items[${k}]`, it);
|
|
285
|
+
if (Object.hasOwn(it, 'diagram'))
|
|
286
|
+
diagrams(`${at}.items[${k}].diagram`, it.diagram);
|
|
271
287
|
});
|
|
272
288
|
if (Object.hasOwn(o, 'details'))
|
|
273
289
|
details(`${at}.details`, o.details);
|
|
290
|
+
if (Object.hasOwn(o, 'diagram'))
|
|
291
|
+
diagrams(`${at}.diagram`, o.diagram);
|
|
274
292
|
return o.id;
|
|
275
293
|
});
|
|
276
294
|
const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
|
|
@@ -644,6 +662,55 @@ export function mediaFiles(m, base) {
|
|
|
644
662
|
return real;
|
|
645
663
|
});
|
|
646
664
|
}
|
|
665
|
+
/** The diagram views of a guided plan or report, in the order the board counts them for `diagram.<n>`: the picture's, then each section's and its items'. */
|
|
666
|
+
export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
|
|
667
|
+
/** The text size a diagram must keep on a phone: its smallest text, drawn as wide as Full size draws it on a 390 px screen (the chrome-free page, 8 px of margin each side). */
|
|
668
|
+
const DIAGRAM_MIN_PX = 12;
|
|
669
|
+
const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
|
|
670
|
+
/** A guided plan's or report's diagram pages, from `base`, the board file's folder: each a file of 5 MB or less whose real path stays inside it,
|
|
671
|
+
* with a drawing whose smallest text is at least 12 px at 390 and no brand marks (their licenses differ). The first broken rule throws.
|
|
672
|
+
* Each view that passes is pinned with the file's `sha256` and `bytes`, so the board serves only the file that was posted and the revision covers its content. */
|
|
673
|
+
export function diagramPages(m, base) {
|
|
674
|
+
const at = isKey(m.key) ? `key ${m.key}: ` : '';
|
|
675
|
+
let root = resolve(base);
|
|
676
|
+
try {
|
|
677
|
+
root = realpathSync(base);
|
|
678
|
+
}
|
|
679
|
+
catch { }
|
|
680
|
+
for (const v of diagramViews(m)) {
|
|
681
|
+
const name = `diagram ${show(v.src)}`, bad = (rule, help) => new ContractError(`${at}${name} ${rule}`, help);
|
|
682
|
+
let real;
|
|
683
|
+
try {
|
|
684
|
+
real = realpathSync(resolve(root, v.src));
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
throw bad('is not there', `Put the file at ${v.src} from the board file's folder, or fix the path`);
|
|
688
|
+
}
|
|
689
|
+
const rel = relative(root, real);
|
|
690
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
691
|
+
throw bad('leads outside the board file\'s folder', 'Copy the page into the board file\'s folder, such as under diagrams/');
|
|
692
|
+
const info = statSync(real);
|
|
693
|
+
if (!info.isFile() || info.size > 5_000_000)
|
|
694
|
+
throw bad('must be a file of 5 MB or less', 'Render it again as a narrow architecture diagram');
|
|
695
|
+
try {
|
|
696
|
+
accessSync(real, constants.R_OK);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
throw bad('cannot be read', 'Make the file readable, then post again');
|
|
700
|
+
}
|
|
701
|
+
const file = readFileSync(real), html = file.toString('utf8'), svg = html.match(/<svg\b[^>]*\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+[\d.]+[^>]*>([\s\S]*?)<\/svg>/i);
|
|
702
|
+
if (!svg)
|
|
703
|
+
throw bad('has no <svg> drawing with a viewBox', 'Render it with the archify skill: an architecture diagram with a viewBox 320 to 340 wide and its legend hidden');
|
|
704
|
+
if (/data-brand-mark/i.test(html))
|
|
705
|
+
throw bad('uses brand marks, whose licenses differ', 'Draw it again without "brand" on any component');
|
|
706
|
+
const sizes = [...svg[2].matchAll(/<text\b[^>]*?\bfont-size\s*=\s*["']([\d.]+)/gi)].map((t) => Number(t[1]));
|
|
707
|
+
const small = Math.min(...sizes), real12 = small * PHONE_DRAW_PX / Number(svg[1]);
|
|
708
|
+
if (sizes.length && real12 < DIAGRAM_MIN_PX)
|
|
709
|
+
throw bad(`has text of ${Math.round(real12 * 10) / 10} px at ${PHONE_PX} (${small} in a ${svg[1]} wide drawing), under ${DIAGRAM_MIN_PX}`, `Draw it narrower, an architecture viewBox 320 to 340 wide with no sublabels and the legend hidden ("legend": {"mode": "hidden"} in "meta"), so every text is ${DIAGRAM_MIN_PX} px or more on a phone`);
|
|
710
|
+
v.sha256 = createHash('sha256').update(file).digest('hex');
|
|
711
|
+
v.bytes = file.length;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
647
714
|
/** The longest a block of plain text on a report page may run: about five lines on a phone. */
|
|
648
715
|
const BLOCK_CHARS = 240;
|
|
649
716
|
/** A report's or plan's page, and a report's picture, from `base`, the board file's folder: each a file of 5 MB or less
|
package/package.json
CHANGED
|
@@ -105,14 +105,31 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
|
|
|
105
105
|
- Write each part for the reader, in the order the board shows it: *What this is* (the answer and one picture: bars, steps or tiles), *What I understood* (the request in your words, so they can say it is off), *What I found*, *What I recommend* (plans), *What you decide* (plans, at most five questions, each with your pick and why), *Next step*.
|
|
106
106
|
- Answer first: the heading of the picture is the answer, one plain sentence.
|
|
107
107
|
- One picture per point; the numbers go in `details`, behind a tap, never in the sentence.
|
|
108
|
-
- Short lines: `text` one line, `more` the part that opens in place; never a paragraph the reader must scroll.
|
|
108
|
+
- Short lines: `text` one line, `more` the part that opens in place, with a line break between points; never a paragraph the reader must scroll.
|
|
109
109
|
- Plain words for a person who does not write code; no ids, no file paths, no tool names.
|
|
110
110
|
- Name the date the facts were true if they will go stale.
|
|
111
|
+
- A research report (a comparison, an option study, a "should we use X?" question) must clear a higher bar than an explainer. A few options backed only by your own numbers is rejected.
|
|
112
|
+
- Name the must-haves first. Survey the whole real field, then short-list against them. Say in one line why each option was cut.
|
|
113
|
+
- Give every short-listed option the same axes and its full pros and cons, as one comparable table: one `found` item per option, pros and cons in `text`, and in `more` each axis on its own line as "Axis: value.", the same axes in the same order. The board then draws the section as one table; an older board shows the same lines as text.
|
|
114
|
+
- Back each claim with outside proof and its link: GitHub issues (open bugs, regressions, upgrade breaks, how the maintainers answered), Reddit, Hacker News, X, forums and blogs. If a site blocks fetching, say what you could not read. Vendor marketing, star counts and your own team's work-log numbers are not proof.
|
|
115
|
+
- Keep what you checked yourself apart from what others claim: a separate section, or each line starting "Checked:" or "Reported:".
|
|
116
|
+
- End with one clear recommendation and a runner-up, each with its reasons.
|
|
117
|
+
- A plan clears the same research bar for every choice in it, then gives what a go-ahead needs: the options with their pros and cons, your recommendation, the scope (what is in and what is out), the steps in order, each risk with what you do about it, and how done is proven (`Done when` in `understood`, and where the proof lands in `next`).
|
|
111
118
|
- New guided defaults are plan schema 3 and report schema 4 (contract 1), with your required filer. Older schemas still load. Every part offers **Ask a question**, **Go deeper**, **Simplify this**, **Show an example**, **Why?**, plus free text and follow-ups. Ask a question focuses the section composer; text and Send are required. Other presets send immediately. Question/custom need nonblank text (at most 500 code points); free text and follow-ups use custom. Answer the exact action and question in its section: explain, give detail, use simpler words, give an example, or explain why. All use one engagement path: version 1 carries action, immutable owner, document revision and target, with an optional selection (authored field, code-point start/end, exact quote): when present, answer about those quoted words in their place in the document. Read these fields with `deeper --json --all`; answer the requested action from the original document, never rewrite text or change plan decisions. Active replies need `--filer <your id>`; registered sessions acknowledge the exact id first. Stale targets and other owners are refused. The answer expands in place and stays New across reload until visibly read. Keep the owner dispatcher/listener alive after reports and sent plans, and after every reply.
|
|
112
119
|
- The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. Never answer with a long essay: say less, they can ask a follow-up.
|
|
113
120
|
- Open the board at 390 px wide and look before you tell the person it is there: the first screen must give the answer and the picture.
|
|
114
121
|
- Only if you must keep an HTML page of your own (a demo to tap), run `report init <folder> --page` and post it with `--type report --view --preview`.
|
|
115
122
|
|
|
123
|
+
## Draw a diagram with Archify
|
|
124
|
+
|
|
125
|
+
Only where structure, order or dependency is the point: how a change flows, what depends on what, a today and after. When the point is a number, keep the bars or tiles; a diagram that hides the numbers is the wrong picture. Archify is a separate skill you install yourself (`npx skills add tt-a1i/archify -g`); sign-off ships none of it. Then:
|
|
126
|
+
|
|
127
|
+
- Add `"diagram": [{"label": "Today", "src": "diagrams/flow.html"}]` (1 to 3 views, shown as tabs) to the `visual`, a section or an item, beside the rows, which stay as the list. `src` is a page next to the plan or report file, as `view` is.
|
|
128
|
+
- Draw an `architecture` diagram with a `viewBox` 320 to 340 wide (320 is Archify's smallest): two columns, labels under about 16 characters, no sublabels, `"legend": {"mode": "hidden"}` in `meta`. Sequence, workflow and `compare` pages are too wide for a phone; show the same flow as a numbered column instead, and draw Today and After as two pages, one per tab.
|
|
129
|
+
- Post refuses a diagram whose smallest text is under 12 px on a 390 px phone, one with brand marks (never set `brand`), or one that is not there. Post pins each page (its `sha256` and `bytes`, written for you): a page changed after posting is not shown, so draw a changed page again and post the plan again.
|
|
130
|
+
- Run `ARCHIFY_UPDATE_CHECK_DISABLED=1 archify finalize` with `--quality showcase`, so it makes no network call. Archify draws in its own mono type; sign-off cannot change it.
|
|
131
|
+
- On a phone the board shows the picture and **Full size**, which opens the drawing alone at the full width of the screen; on a desk **Show me** opens the interactive page in place. Look at both at 390 before you say it is posted.
|
|
132
|
+
|
|
116
133
|
## Ask the user to sign in
|
|
117
134
|
|
|
118
135
|
When your work needs the user to sign in (for example a login link from `npm login`), post one sign-in card. It goes to the top of what waits.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Added to each diagram page the board serves. Only on the page opened by itself (Full size, not the card's frame): a way back to the plan or report,
|
|
2
|
+
// and on a phone's drawing-alone page (embed=1) the page is sized to the drawing, with the drawing's own surface carried down the rest of the screen, so no grey area follows it.
|
|
3
|
+
(() => {
|
|
4
|
+
if (parent !== window) return;
|
|
5
|
+
const me = document.currentScript, back = me.dataset.back, label = me.dataset.label;
|
|
6
|
+
const style = document.createElement('style');
|
|
7
|
+
style.textContent = '.so-back { display: flex; align-items: center; gap: 0.375rem; box-sizing: border-box; width: fit-content; min-height: 2.75rem; margin: 0.25rem 0.5rem; padding: 0 0.875rem; border: 1px solid currentColor; border-radius: 999px; color: inherit; font: 600 0.9375rem/1 system-ui, sans-serif; text-decoration: none; }'
|
|
8
|
+
+ '.so-back:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }';
|
|
9
|
+
const surface = document.querySelector('.diagram-container');
|
|
10
|
+
if (new URLSearchParams(location.search).get('embed') === '1') {
|
|
11
|
+
style.textContent += 'html, body { height: auto !important; min-height: 0 !important; }';
|
|
12
|
+
if (surface) {
|
|
13
|
+
// The drawing sits on its own surface over the page's colour; the page takes that surface too, so the screen below the drawing is the drawing's own.
|
|
14
|
+
const s = getComputedStyle(surface);
|
|
15
|
+
style.textContent += `body { background-image: ${s.backgroundImage}, linear-gradient(${s.backgroundColor}, ${s.backgroundColor}) !important; background-size: ${s.backgroundSize}, auto !important; }`
|
|
16
|
+
+ '.diagram-container { background: none !important; }';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const link = document.createElement('a');
|
|
20
|
+
link.className = 'so-back'; link.href = back; link.textContent = `← ${label}`;
|
|
21
|
+
document.head.append(style); document.body.prepend(link);
|
|
22
|
+
})();
|
package/web/index.html
CHANGED
|
@@ -820,6 +820,15 @@
|
|
|
820
820
|
@media (min-width: 43.75rem) { #reader .gd-sec h2 { font-size: 1.75rem; } }
|
|
821
821
|
.gd-sec > .gh:not(:has(.gd-lead)) h2 { margin-bottom: 0.875rem; }
|
|
822
822
|
.gd-lead { margin: 0 0 1rem; line-height: 1.5; }
|
|
823
|
+
/* A diagram: a framed picture, its views as tabs, and Show me opening the interactive viewer in place. On a phone the open viewer's toolbar is wider than the card, so Full size is the way in there. */
|
|
824
|
+
.gd-dg { padding: 0.5rem; }
|
|
825
|
+
.gd-dgbox { border-radius: 0.75rem; overflow: hidden; border: 1px solid var(--hair2); background: var(--tile); }
|
|
826
|
+
.gd-dgbox iframe { display: block; width: 100%; height: 6rem; border: 0; }
|
|
827
|
+
.gd-dgbox.full iframe { height: min(36rem, 78vh); }
|
|
828
|
+
.gd-dgbar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin: 0.625rem 0.25rem 0.125rem; }
|
|
829
|
+
.gd-dg .gd-seg { margin: 0 0.25rem 0.625rem; }
|
|
830
|
+
.gd-dg a.gd-lnk { text-decoration: none; }
|
|
831
|
+
@media (max-width: 43.6875rem) { .gd-dg .gd-show { display: none; } }
|
|
823
832
|
.gd-fig { margin: 0.25rem 0 1rem; border-radius: 1rem; background: var(--well); padding: 1rem; }
|
|
824
833
|
.gd-cap { margin: 0.625rem 0 0; font-size: 0.875rem; color: var(--muted); line-height: 1.4; }
|
|
825
834
|
.gd-acts { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.125rem 0 0.25rem; }
|
|
@@ -908,11 +917,32 @@
|
|
|
908
917
|
.gd-rows .sm { display: block; font-weight: 400; color: var(--muted); font-size: 0.9375rem; line-height: 1.35; }
|
|
909
918
|
.gd-rows .ic.gchev { transition: transform 0.2s; color: var(--faint); }
|
|
910
919
|
.gd-rows .rowbtn[aria-expanded=true] .ic.gchev { transform: rotate(180deg); }
|
|
911
|
-
.gd-rows .dotk { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--faint); flex: none; }
|
|
912
|
-
.gd-rows .dotk.bad { background: var(--bad); } .gd-rows .dotk.ok { background: var(--ok); } .gd-rows .dotk.warn { background: var(--warn); }
|
|
920
|
+
.gd-rows .dotk, .gd-ct .dotk { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--faint); flex: none; }
|
|
921
|
+
:is(.gd-rows, .gd-ct) .dotk.bad { background: var(--bad); } :is(.gd-rows, .gd-ct) .dotk.ok { background: var(--ok); } :is(.gd-rows, .gd-ct) .dotk.warn { background: var(--warn); }
|
|
913
922
|
.gd-rows .body { padding: 0 0.875rem 0.5rem; }
|
|
914
923
|
.gd-rows .body[hidden] { display: none; }
|
|
915
924
|
.gd-rows .body p { margin: 0 0 0.625rem; }
|
|
925
|
+
.gd-rows .body .ln { display: block; } .gd-rows .body .ln + .ln { margin-top: 0.375rem; }
|
|
926
|
+
/* A comparison table: on a phone (and past 4 options) each point lists every option under it; with room, options run across. */
|
|
927
|
+
.gd-cmp { container-type: inline-size; margin: 0 0 0.375rem; }
|
|
928
|
+
.gd-ct { display: block; font-size: 0.9375rem; line-height: 1.4; }
|
|
929
|
+
.gd-ct thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
930
|
+
.gd-ct tbody, .gd-ct tr, .gd-ct th, .gd-ct td { display: block; text-align: left; overflow-wrap: anywhere; }
|
|
931
|
+
.gd-ct tbody tr { border: 1px solid var(--hair2); border-radius: 1rem; padding: 0.125rem 0.875rem; margin: 0 0 0.625rem; }
|
|
932
|
+
.gd-ct tbody th { padding: 0.625rem 0 0.125rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
|
933
|
+
.gd-ct td { padding: 0.5rem 0; border-top: 1px solid var(--hair2); }
|
|
934
|
+
.gd-ct th + td { border-top: 0; }
|
|
935
|
+
.gd-ct .ol { display: flex; align-items: baseline; gap: 0.5rem; font-weight: 600; }
|
|
936
|
+
@container (min-width: 34rem) {
|
|
937
|
+
.gd-cmp:not(.many) .gd-ct { display: table; width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; border: 1px solid var(--hair2); border-radius: 1rem; }
|
|
938
|
+
.gd-cmp:not(.many) .gd-ct thead { position: static; width: auto; height: auto; overflow: visible; clip-path: none; display: table-header-group; }
|
|
939
|
+
.gd-cmp:not(.many) .gd-ct tbody { display: table-row-group; }
|
|
940
|
+
.gd-cmp:not(.many) .gd-ct tr { display: table-row; }
|
|
941
|
+
.gd-cmp:not(.many) .gd-ct th, .gd-cmp:not(.many) .gd-ct td { display: table-cell; vertical-align: top; padding: 0.625rem 0.75rem; border-top: 0; border-bottom: 1px solid var(--hair2); }
|
|
942
|
+
.gd-cmp:not(.many) .gd-ct tbody tr:last-child > * { border-bottom: 0; }
|
|
943
|
+
.gd-cmp:not(.many) .gd-ct thead td { width: 7rem; }
|
|
944
|
+
.gd-cmp:not(.many) .gd-ct td .ol { display: none; }
|
|
945
|
+
}
|
|
916
946
|
.gd-opts { display: grid; gap: 0.625rem; margin: 0 0 0.75rem; }
|
|
917
947
|
.gd-opt { display: grid; grid-template-columns: 1.5rem 1fr; gap: 0.75rem; align-items: start; text-align: left; padding: 0.875rem; border-radius: 1rem; border: 1.5px solid var(--hair) !important; background: var(--tile) !important; width: 100%; }
|
|
918
948
|
.gd-opt .rdo { width: 1.375rem; height: 1.375rem; border-radius: 50%; border: 2px solid var(--hair); margin-top: 1px; display: grid; place-items: center; }
|
|
@@ -2226,6 +2256,38 @@
|
|
|
2226
2256
|
tray.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); drop(true); } });
|
|
2227
2257
|
|
|
2228
2258
|
// ---- the picture
|
|
2259
|
+
// ---- diagrams. dgAll counts the views in the order the server's ~diagram.<n> route does.
|
|
2260
|
+
const dgAll = [d.visual?.diagram, ...sections.flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
|
|
2261
|
+
const night = matchMedia('(prefers-color-scheme: dark)'), phone = matchMedia('(max-width: 43.6875rem)');
|
|
2262
|
+
/** The picture in a sandboxed frame, its views as tabs; Show me opens the interactive viewer in place, Full size in a tab. */
|
|
2263
|
+
function diagramFig(views, list) {
|
|
2264
|
+
let cur = 0, full = false, listOpen = false;
|
|
2265
|
+
const frame = el('iframe', { sandbox: 'allow-scripts', loading: 'lazy' }), box = E('div', 'gd-dgbox', frame);
|
|
2266
|
+
const panel = list ? E('div', 'gd-panel', list) : null;
|
|
2267
|
+
const paint = () => {
|
|
2268
|
+
const v = views[cur], url = `${mockUrl(d.key, `diagram.${dgAll.indexOf(v)}`, v.src)}?theme=${night.matches ? 'dark' : 'light'}`;
|
|
2269
|
+
box.classList.toggle('full', full);
|
|
2270
|
+
frame.style.height = ''; // a height the picture's own size handshake set must not outlive its mode
|
|
2271
|
+
frame.title = `${full ? 'Interactive diagram' : 'Diagram'}: ${v.label}`;
|
|
2272
|
+
frame.src = full ? url : `${url}&embed=1`;
|
|
2273
|
+
wide.href = src(phone.matches ? `${url}&embed=1` : url); // a phone's Full size is the drawing alone, at the screen's width
|
|
2274
|
+
tabs.forEach((b, i) => b.setAttribute('aria-pressed', String(i === cur)));
|
|
2275
|
+
showLabel.nodeValue = full ? 'Back to the picture' : 'Show me';
|
|
2276
|
+
show.setAttribute('aria-expanded', String(full));
|
|
2277
|
+
if (panel) { panel.hidden = !listOpen; listBtn.setAttribute('aria-expanded', String(listOpen)); }
|
|
2278
|
+
};
|
|
2279
|
+
const tabs = views.length > 1 ? views.map((v, i) => { const b = el('button', { class: 'gd-choice', type: 'button', 'aria-pressed': 'false' }, v.label); b.onclick = () => { cur = i; paint(); }; return b; }) : [];
|
|
2280
|
+
const showLabel = document.createTextNode('Show me');
|
|
2281
|
+
const show = el('button', { class: 'gd-lnk deep gd-show', type: 'button', 'aria-expanded': 'false' }, showLabel, gic('chev', 'gchev'));
|
|
2282
|
+
show.onclick = () => { full = !full; paint(); };
|
|
2283
|
+
const wide = el('a', { class: 'gd-lnk', target: '_blank', rel: 'noopener' }, 'Full size');
|
|
2284
|
+
const listBtn = el('button', { class: 'gd-lnk', type: 'button', 'aria-expanded': 'false' }, 'As a list', gic('chev', 'gchev'));
|
|
2285
|
+
listBtn.onclick = () => { listOpen = !listOpen; paint(); };
|
|
2286
|
+
const fig = E('figure', 'gd-fig gd-dg', ...(tabs.length ? [E('div', 'gd-seg', ...tabs)] : []), box, E('div', 'gd-dgbar', show, wide, ...(list ? [listBtn] : [])), ...(panel ? [panel] : []));
|
|
2287
|
+
for (const q of [night, phone]) q.addEventListener('change', () => { if (fig.isConnected) paint(); });
|
|
2288
|
+
paint();
|
|
2289
|
+
return fig;
|
|
2290
|
+
}
|
|
2229
2291
|
function visual(v) {
|
|
2230
2292
|
let body;
|
|
2231
2293
|
if (v.type === 'bars') {
|
|
@@ -2236,6 +2298,10 @@
|
|
|
2236
2298
|
} else if (v.type === 'steps') {
|
|
2237
2299
|
body = E('ol', 'gd-chain', ...v.rows.map((r, i) => E('li', '', E('span', `nd ${r.tone ?? ''}`, String(i + 1)), E('div', 'tx', ...(r.note ? [E('small', '', r.note)] : []), r.label, ...(r.text ? [E('small', '', r.text)] : [])))));
|
|
2238
2300
|
} else body = E('div', 'gd-tiles', ...v.rows.map((r) => E('div', 'gd-tile', ...(r.note ? [E('span', `gd-chip ${r.tone ?? ''}`, r.note)] : []), E('b', '', r.label), ...(r.text ? [E('span', 'gt', r.text)] : []))));
|
|
2301
|
+
if (v.diagram) {
|
|
2302
|
+
// The picture is the diagram; the rows stay the same answer in plain steps (open on a phone, one tap away on a desk).
|
|
2303
|
+
return diagramFig(v.diagram, body);
|
|
2304
|
+
}
|
|
2239
2305
|
return E('figure', 'gd-fig', body);
|
|
2240
2306
|
}
|
|
2241
2307
|
|
|
@@ -2267,17 +2333,38 @@
|
|
|
2267
2333
|
el('dl', { class: 'gd-uq' }, ...d.understood.map((u, n) => E('div', '', fld('understood', `understood.${n}.label`, el('dt', {}, u.label)), fld('understood', `understood.${n}.text`, el('dd', {}, u.text))))),
|
|
2268
2334
|
E('div', 'gd-seg', ...choices), noteBox, tell, ...(live ? acts('understood') : []), rc('understood'));
|
|
2269
2335
|
|
|
2336
|
+
// A comparison: every item's detail is the same "Axis: value" lines in the same order, so the section is one table.
|
|
2337
|
+
// The detail stays plain lines, so a board that predates the table still reads it as text.
|
|
2338
|
+
const compared = (items) => {
|
|
2339
|
+
if (items.length < 2 || items.some((it) => !it.more || it.diagram)) return null;
|
|
2340
|
+
const rows = items.map((it) => it.more.split('\n').filter((l) => l.trim()).map((l) => /^([^:]{1,40}):\s+(\S.*)$/.exec(l.trim())));
|
|
2341
|
+
const axes = rows[0].map((m) => m?.[1]);
|
|
2342
|
+
if (axes.length < 2 || rows.some((r) => r.length !== axes.length || r.some((m, k) => !m || m[1] !== axes[k]))) return null;
|
|
2343
|
+
return { axes, values: rows.map((r) => r.map((m) => m[2])) };
|
|
2344
|
+
};
|
|
2345
|
+
const cmpTable = (items, { axes, values }) => {
|
|
2346
|
+
const opt = (it) => E('span', 'ol', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), it.title);
|
|
2347
|
+
// A link in a narrow cell breaks after a slash, not inside a word.
|
|
2348
|
+
const value = (t) => E('span', '', ...t.split(/(?<=\/)(?=\S)/).flatMap((part, i) => (i ? [el('wbr'), part] : [part])));
|
|
2349
|
+
const row = (label, cells) => el('tr', {}, el('th', { scope: 'row' }, label), ...cells.map((c, i) => el('td', {}, opt(items[i]), value(c))));
|
|
2350
|
+
return E('div', `gd-cmp${items.length > 4 ? ' many' : ''}`, el('table', { class: 'gd-ct' },
|
|
2351
|
+
el('thead', {}, el('tr', {}, el('td', {}), ...items.map((it) => el('th', { scope: 'col' }, opt(it))))),
|
|
2352
|
+
el('tbody', {}, row('In short', items.map((it) => it.text)), ...axes.map((a, k) => row(a, values.map((v) => v[k]))))));
|
|
2353
|
+
};
|
|
2354
|
+
// One block per line of detail; the newline stays in the text, so a selection's offsets still count it.
|
|
2355
|
+
const lines = (t) => t.split('\n').flatMap((l, i) => (i ? ['\n', E('span', 'ln', l)] : [E('span', 'ln', l)]));
|
|
2270
2356
|
const secCard = (s) => {
|
|
2271
|
-
const
|
|
2357
|
+
const cmp = compared(s.items ?? []);
|
|
2358
|
+
const items = cmp ? [] : (s.items ?? []).map((it, i) => {
|
|
2272
2359
|
const part = `${s.id}:${i}`;
|
|
2273
|
-
if (!it.more) return E('li', '', E('div', 'rowstat', E('span', '', E('span', 'ti', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), fld(part, 'title', E('span', '', it.title))), fld(part, 'text', E('span', 'sm', it.text)))), ...(d.engagement ? acts(part) : []));
|
|
2274
|
-
const body = E('div', 'body', fld(part, 'more', E('p', '', it.more)), ...acts(part));
|
|
2360
|
+
if (!it.more && !it.diagram) return E('li', '', E('div', 'rowstat', E('span', '', E('span', 'ti', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), fld(part, 'title', E('span', '', it.title))), fld(part, 'text', E('span', 'sm', it.text)))), ...(d.engagement ? [E('div', 'body', ...acts(part))] : []));
|
|
2361
|
+
const body = E('div', 'body', ...(it.more ? [fld(part, 'more', E('p', '', ...lines(it.more)))] : []), ...(it.diagram ? [diagramFig(it.diagram)] : []), ...acts(part));
|
|
2275
2362
|
body.hidden = true;
|
|
2276
2363
|
const b = el('button', { class: 'rowbtn', type: 'button', 'aria-expanded': 'false' }, E('span', '', E('span', 'ti', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), it.title), E('span', 'sm', it.text)), gic('chev', 'gchev'));
|
|
2277
2364
|
b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); body.hidden = !open; };
|
|
2278
2365
|
return E('li', '', b, body);
|
|
2279
2366
|
});
|
|
2280
|
-
return card(s.id, s.stage, head(s.heading, s.body, undefined, s.id, 'heading', 'body'), ...(items.length ? [E('ul', 'gd-rows', ...items)] : []), ...acts(s.id, s.details), rc(s.id));
|
|
2367
|
+
return card(s.id, s.stage, head(s.heading, s.body, undefined, s.id, 'heading', 'body'), ...(s.diagram ? [diagramFig(s.diagram)] : []), ...(cmp ? [cmpTable(s.items, cmp)] : items.length ? [E('ul', 'gd-rows', ...items)] : []), ...acts(s.id, s.details), rc(s.id));
|
|
2281
2368
|
};
|
|
2282
2369
|
|
|
2283
2370
|
const optBtns = new Map();
|
|
@@ -2624,11 +2711,12 @@
|
|
|
2624
2711
|
const openKeys = new Set(open.map((d) => d.key));
|
|
2625
2712
|
const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
|
|
2626
2713
|
for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key) && !lapsed(d)) by[area(d)].push(d);
|
|
2627
|
-
|
|
2714
|
+
// Today and Evidence read one list: the passed updates filed in the last 24 hours (the window Evidence archives after) that are not dismissed, answered or not, newest first.
|
|
2715
|
+
// A capture with no run, a failed run or one that could not be checked is no proof, so it is on neither; its own screen still opens, and a verdict moves it to Answered.
|
|
2716
|
+
by.provenToday = state.decisions.filter((d) => d.type === 'evidence' && d.evidenceCard?.result === 'RUN PASS' && !gone.has(d.key) && Date.now() - Date.parse(d.filed) <= 864e5);
|
|
2717
|
+
by.evidence = [...by.evidence.filter((d) => d.type !== 'evidence'), ...by.provenToday].sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
|
|
2718
|
+
by.provenToday = by.evidence.filter((d) => d.type === 'evidence');
|
|
2628
2719
|
by.designs = [...state.decisions].reverse().filter((d) => d.kind === 'design' && !gone.has(d.key));
|
|
2629
|
-
// Proven today counts every update filed in the last 24 hours that is not dismissed, answered or not: a verdict moves it off Evidence, not out of the count.
|
|
2630
|
-
// It is the window Evidence archives after, so an update Evidence shows from last evening is counted too (only a restored or read-only board's older updates differ).
|
|
2631
|
-
by.provenToday = state.decisions.filter((d) => d.type === 'evidence' && !gone.has(d.key) && Date.now() - Date.parse(d.filed) <= 864e5).sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
|
|
2632
2720
|
// Waiting on you reads newest first unless the viewer chose oldest first. A sign-in blocks someone now, so sign-ins come first either way.
|
|
2633
2721
|
let latest = -Infinity;
|
|
2634
2722
|
const inFileOrder = by.waiting.slice().reverse().map((d, i) => { const t = Date.parse(d.filed); if (!Number.isNaN(t)) latest = t; return { d, t: latest, i }; });
|
|
@@ -2678,7 +2766,7 @@
|
|
|
2678
2766
|
...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes), dismissed(state.decisions.filter((d) => gone.has(d.key) && !merged.has(d.key) && ['reports', 'notes'].includes(area(d))), 'Archived')] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
|
|
2679
2767
|
: el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [search(), glance(by),
|
|
2680
2768
|
el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence', by.provenToday.length, 'Proven today'), tile('reports', filedToday(by.reports).length, 'New report')),
|
|
2681
|
-
el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.
|
|
2769
|
+
el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.provenToday, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
|
|
2682
2770
|
...(FROZEN || state.readonly || state.answers_only ? [] : [ask()])]));
|
|
2683
2771
|
readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || at === 'evidence' && d.type === 'evidence' || d.type === 'plan' && (area(d) === at || (d.guided && (at === 'waiting' || at === 'answered'))))) : undefined, at);
|
|
2684
2772
|
if (at === 'decide') document.querySelector('#board .decide')?.prepend(bulkBar(bulk));
|
|
@@ -2710,8 +2798,8 @@
|
|
|
2710
2798
|
// An interactive page may post its own height, `{ height: <px> }`, and its frame then fits it with no blank below.
|
|
2711
2799
|
// Only a frame's own message sizes it. Document readers must fit even long pages; previews stay capped.
|
|
2712
2800
|
addEventListener('message', (e) => {
|
|
2713
|
-
const frame = [...document.querySelectorAll('.rung iframe, iframe.plan-view, #reader .read iframe')].find((f) => f.contentWindow === e.source), h = e.data?.height;
|
|
2714
|
-
const documentFrame = frame?.matches('iframe.plan-view, #reader .read iframe');
|
|
2801
|
+
const frame = [...document.querySelectorAll('.rung iframe, iframe.plan-view, #reader .read iframe, .gd-dgbox:not(.full) iframe')].find((f) => f.contentWindow === e.source), h = e.data?.height;
|
|
2802
|
+
const documentFrame = frame?.matches('iframe.plan-view, #reader .read iframe, .gd-dgbox iframe');
|
|
2715
2803
|
if (documentFrame && e.data?.['sign-off'] === 'ready') {
|
|
2716
2804
|
frame.scrolling = 'no';
|
|
2717
2805
|
frame.contentWindow.postMessage({ 'sign-off': 'size' }, '*');
|