sign-off-axi 0.5.4 → 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 +12 -5
- package/dist/contract.js +12 -4
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +3 -3
- package/web/diagram-page.js +22 -0
- package/web/index.html +48 -6
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ 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';
|
|
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
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';
|
|
@@ -2507,6 +2507,8 @@ const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-i
|
|
|
2507
2507
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2508
2508
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2509
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>`);
|
|
2510
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
|
|
2511
2513
|
* stored hash, so each picture is measured once. */
|
|
2512
2514
|
const unfitMedia = new Map();
|
|
@@ -2755,7 +2757,8 @@ async function route(req) {
|
|
|
2755
2757
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
2756
2758
|
// A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
|
|
2757
2759
|
const dgs = d?.guided ? diagramViews(d) : [];
|
|
2758
|
-
const
|
|
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;
|
|
2759
2762
|
try {
|
|
2760
2763
|
const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
|
|
2761
2764
|
if (!mock || !type)
|
|
@@ -2763,8 +2766,12 @@ async function route(req) {
|
|
|
2763
2766
|
const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
|
|
2764
2767
|
if (statSync(actual).size > MOCK_BYTES)
|
|
2765
2768
|
return [404, { error: 'Mock is unavailable' }];
|
|
2766
|
-
|
|
2767
|
-
|
|
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'}`) }) }];
|
|
2768
2775
|
}
|
|
2769
2776
|
catch {
|
|
2770
2777
|
return [404, { error: 'Mock is unavailable' }];
|
|
@@ -3153,7 +3160,7 @@ const server = createServer(async (req, res) => {
|
|
|
3153
3160
|
return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
|
|
3154
3161
|
}
|
|
3155
3162
|
if (body && typeof body === 'object' && 'media' in body) {
|
|
3156
|
-
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;
|
|
3157
3164
|
const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
|
|
3158
3165
|
// One byte range, so phone browsers can play and seek a recording.
|
|
3159
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,8 +212,12 @@ 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
|
});
|
|
214
|
-
/** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. */
|
|
215
|
-
const diagrams = (field, v) => list(field, 1, 3, ['label', 'src'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
|
|
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');
|
|
216
221
|
text(`${field}[${n}].label`, o.label, 40, 'the view name, such as "Today", in 40 characters or fewer');
|
|
217
222
|
text(`${field}[${n}].src`, o.src, 256, 'a page path from the board file\'s folder, such as "diagrams/flow.html"');
|
|
218
223
|
if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
|
|
@@ -663,7 +668,8 @@ export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).f
|
|
|
663
668
|
const DIAGRAM_MIN_PX = 12;
|
|
664
669
|
const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
|
|
665
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,
|
|
666
|
-
* 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.
|
|
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. */
|
|
667
673
|
export function diagramPages(m, base) {
|
|
668
674
|
const at = isKey(m.key) ? `key ${m.key}: ` : '';
|
|
669
675
|
let root = resolve(base);
|
|
@@ -692,7 +698,7 @@ export function diagramPages(m, base) {
|
|
|
692
698
|
catch {
|
|
693
699
|
throw bad('cannot be read', 'Make the file readable, then post again');
|
|
694
700
|
}
|
|
695
|
-
const
|
|
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);
|
|
696
702
|
if (!svg)
|
|
697
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');
|
|
698
704
|
if (/data-brand-mark/i.test(html))
|
|
@@ -701,6 +707,8 @@ export function diagramPages(m, base) {
|
|
|
701
707
|
const small = Math.min(...sizes), real12 = small * PHONE_DRAW_PX / Number(svg[1]);
|
|
702
708
|
if (sizes.length && real12 < DIAGRAM_MIN_PX)
|
|
703
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;
|
|
704
712
|
}
|
|
705
713
|
}
|
|
706
714
|
/** The longest a block of plain text on a report page may run: about five lines on a phone. */
|
package/package.json
CHANGED
|
@@ -105,12 +105,12 @@ 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
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
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 each axis as "Axis: value." in the same order
|
|
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
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
115
|
- Keep what you checked yourself apart from what others claim: a separate section, or each line starting "Checked:" or "Reported:".
|
|
116
116
|
- End with one clear recommendation and a runner-up, each with its reasons.
|
|
@@ -126,7 +126,7 @@ Only where structure, order or dependency is the point: how a change flows, what
|
|
|
126
126
|
|
|
127
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
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.
|
|
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
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
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
132
|
|
|
@@ -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
|
@@ -917,11 +917,32 @@
|
|
|
917
917
|
.gd-rows .sm { display: block; font-weight: 400; color: var(--muted); font-size: 0.9375rem; line-height: 1.35; }
|
|
918
918
|
.gd-rows .ic.gchev { transition: transform 0.2s; color: var(--faint); }
|
|
919
919
|
.gd-rows .rowbtn[aria-expanded=true] .ic.gchev { transform: rotate(180deg); }
|
|
920
|
-
.gd-rows .dotk { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--faint); flex: none; }
|
|
921
|
-
.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); }
|
|
922
922
|
.gd-rows .body { padding: 0 0.875rem 0.5rem; }
|
|
923
923
|
.gd-rows .body[hidden] { display: none; }
|
|
924
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
|
+
}
|
|
925
946
|
.gd-opts { display: grid; gap: 0.625rem; margin: 0 0 0.75rem; }
|
|
926
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%; }
|
|
927
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; }
|
|
@@ -2312,17 +2333,38 @@
|
|
|
2312
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))))),
|
|
2313
2334
|
E('div', 'gd-seg', ...choices), noteBox, tell, ...(live ? acts('understood') : []), rc('understood'));
|
|
2314
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)]));
|
|
2315
2356
|
const secCard = (s) => {
|
|
2316
|
-
const
|
|
2357
|
+
const cmp = compared(s.items ?? []);
|
|
2358
|
+
const items = cmp ? [] : (s.items ?? []).map((it, i) => {
|
|
2317
2359
|
const part = `${s.id}:${i}`;
|
|
2318
|
-
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 ? acts(part) : []));
|
|
2319
|
-
const body = E('div', 'body', ...(it.more ? [fld(part, 'more', E('p', '', it.more))] : []), ...(it.diagram ? [diagramFig(it.diagram)] : []), ...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));
|
|
2320
2362
|
body.hidden = true;
|
|
2321
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'));
|
|
2322
2364
|
b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); body.hidden = !open; };
|
|
2323
2365
|
return E('li', '', b, body);
|
|
2324
2366
|
});
|
|
2325
|
-
return card(s.id, s.stage, head(s.heading, s.body, undefined, s.id, 'heading', 'body'), ...(s.diagram ? [diagramFig(s.diagram)] : []), ...(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));
|
|
2326
2368
|
};
|
|
2327
2369
|
|
|
2328
2370
|
const optBtns = new Map();
|