sign-off-axi 0.5.3 → 0.5.4

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 CHANGED
@@ -14,7 +14,7 @@ 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
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';
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
  }
@@ -2749,7 +2753,9 @@ async function route(req) {
2749
2753
  // and the files beside it in its own folder, so a mock page's relative css, scripts and images load.
2750
2754
  const [item, ...rest] = new URL(req.url, 'http://localhost').pathname.split('/').slice(3), [key, value] = item?.split('~') ?? [];
2751
2755
  const d = loadDecisions(file).find((x) => x.key === key);
2752
- const mock = value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2756
+ // A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
2757
+ const dgs = d?.guided ? diagramViews(d) : [];
2758
+ const mock = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))]?.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2753
2759
  try {
2754
2760
  const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
2755
2761
  if (!mock || !type)
package/dist/contract.js CHANGED
@@ -211,6 +211,13 @@ export function checkMessage(m, want, flags = false) {
211
211
  text(`${field}[${n}].label`, o.label, 40, 'the number\'s name, in 40 characters or fewer');
212
212
  text(`${field}[${n}].value`, o.value, 120, 'the number and its unit, in 120 characters or fewer');
213
213
  });
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) => {
216
+ text(`${field}[${n}].label`, o.label, 40, 'the view name, such as "Today", in 40 characters or fewer');
217
+ text(`${field}[${n}].src`, o.src, 256, 'a page path from the board file\'s folder, such as "diagrams/flow.html"');
218
+ if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
219
+ 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');
220
+ });
214
221
  /** 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
222
  const guide = (minSections) => {
216
223
  list('understood', 1, 6, ['label', 'text'], '{"label":"You asked","text":"Make the first start smooth."}').forEach((o, n) => {
@@ -220,7 +227,7 @@ export function checkMessage(m, want, flags = false) {
220
227
  const v = m.visual;
221
228
  if (!Object.hasOwn(m, 'visual'))
222
229
  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":[...]}');
230
+ one('visual', v, ['type', 'heading', 'text', 'unit', 'rows', 'details', 'diagram'], '{"type":"bars","heading":"...","rows":[...]}');
224
231
  if (!['bars', 'steps', 'tiles'].includes(v.type))
225
232
  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
233
  text('visual.heading', v.heading, 100, 'the point the picture makes, in 100 characters or fewer');
@@ -253,7 +260,9 @@ export function checkMessage(m, want, flags = false) {
253
260
  });
254
261
  if (Object.hasOwn(v, 'details'))
255
262
  details('visual.details', v.details);
256
- const ids = list('sections', minSections, 6, ['id', 'stage', 'heading', 'body', 'items', 'details'], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
263
+ if (Object.hasOwn(v, 'diagram'))
264
+ diagrams('visual.diagram', v.diagram);
265
+ 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
266
  const at = `sections[${n}]`;
258
267
  if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
259
268
  throw bad(`${at}.id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "steps"');
@@ -262,15 +271,19 @@ export function checkMessage(m, want, flags = false) {
262
271
  text(`${at}.heading`, o.heading, 80, 'the section heading, in 80 characters or fewer');
263
272
  text(`${at}.body`, o.body, 300, 'the section\'s lead, in 300 characters or fewer');
264
273
  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) => {
274
+ 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
275
  text(`${at}.items[${k}].title`, it.title, 80, 'the item, in 80 characters or fewer');
267
276
  text(`${at}.items[${k}].text`, it.text, 160, 'one line under it, in 160 characters or fewer');
268
277
  if (Object.hasOwn(it, 'more'))
269
278
  text(`${at}.items[${k}].more`, it.more, 600, 'what opens when it is tapped, in 600 characters or fewer, or leave it out');
270
279
  tone(`${at}.items[${k}]`, it);
280
+ if (Object.hasOwn(it, 'diagram'))
281
+ diagrams(`${at}.items[${k}].diagram`, it.diagram);
271
282
  });
272
283
  if (Object.hasOwn(o, 'details'))
273
284
  details(`${at}.details`, o.details);
285
+ if (Object.hasOwn(o, 'diagram'))
286
+ diagrams(`${at}.diagram`, o.diagram);
274
287
  return o.id;
275
288
  });
276
289
  const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
@@ -644,6 +657,52 @@ export function mediaFiles(m, base) {
644
657
  return real;
645
658
  });
646
659
  }
660
+ /** 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'. */
661
+ export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
662
+ /** 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). */
663
+ const DIAGRAM_MIN_PX = 12;
664
+ const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
665
+ /** 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. */
667
+ export function diagramPages(m, base) {
668
+ const at = isKey(m.key) ? `key ${m.key}: ` : '';
669
+ let root = resolve(base);
670
+ try {
671
+ root = realpathSync(base);
672
+ }
673
+ catch { }
674
+ for (const v of diagramViews(m)) {
675
+ const name = `diagram ${show(v.src)}`, bad = (rule, help) => new ContractError(`${at}${name} ${rule}`, help);
676
+ let real;
677
+ try {
678
+ real = realpathSync(resolve(root, v.src));
679
+ }
680
+ catch {
681
+ throw bad('is not there', `Put the file at ${v.src} from the board file's folder, or fix the path`);
682
+ }
683
+ const rel = relative(root, real);
684
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
685
+ throw bad('leads outside the board file\'s folder', 'Copy the page into the board file\'s folder, such as under diagrams/');
686
+ const info = statSync(real);
687
+ if (!info.isFile() || info.size > 5_000_000)
688
+ throw bad('must be a file of 5 MB or less', 'Render it again as a narrow architecture diagram');
689
+ try {
690
+ accessSync(real, constants.R_OK);
691
+ }
692
+ catch {
693
+ throw bad('cannot be read', 'Make the file readable, then post again');
694
+ }
695
+ const html = readFileSync(real, 'utf8'), svg = html.match(/<svg\b[^>]*\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+[\d.]+[^>]*>([\s\S]*?)<\/svg>/i);
696
+ if (!svg)
697
+ 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
+ if (/data-brand-mark/i.test(html))
699
+ throw bad('uses brand marks, whose licenses differ', 'Draw it again without "brand" on any component');
700
+ const sizes = [...svg[2].matchAll(/<text\b[^>]*?\bfont-size\s*=\s*["']([\d.]+)/gi)].map((t) => Number(t[1]));
701
+ const small = Math.min(...sizes), real12 = small * PHONE_DRAW_PX / Number(svg[1]);
702
+ if (sizes.length && real12 < DIAGRAM_MIN_PX)
703
+ 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`);
704
+ }
705
+ }
647
706
  /** The longest a block of plain text on a report page may run: about five lines on a phone. */
648
707
  const BLOCK_CHARS = 240;
649
708
  /** 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
@@ -108,11 +108,28 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
108
108
  - Short lines: `text` one line, `more` the part that opens in place; 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 each axis as "Axis: value." in the same order in `more`.
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.
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.
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; }
@@ -2226,6 +2235,38 @@
2226
2235
  tray.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); drop(true); } });
2227
2236
 
2228
2237
  // ---- the picture
2238
+ // ---- diagrams. dgAll counts the views in the order the server's ~diagram.<n> route does.
2239
+ const dgAll = [d.visual?.diagram, ...sections.flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
2240
+ const night = matchMedia('(prefers-color-scheme: dark)'), phone = matchMedia('(max-width: 43.6875rem)');
2241
+ /** The picture in a sandboxed frame, its views as tabs; Show me opens the interactive viewer in place, Full size in a tab. */
2242
+ function diagramFig(views, list) {
2243
+ let cur = 0, full = false, listOpen = false;
2244
+ const frame = el('iframe', { sandbox: 'allow-scripts', loading: 'lazy' }), box = E('div', 'gd-dgbox', frame);
2245
+ const panel = list ? E('div', 'gd-panel', list) : null;
2246
+ const paint = () => {
2247
+ const v = views[cur], url = `${mockUrl(d.key, `diagram.${dgAll.indexOf(v)}`, v.src)}?theme=${night.matches ? 'dark' : 'light'}`;
2248
+ box.classList.toggle('full', full);
2249
+ frame.style.height = ''; // a height the picture's own size handshake set must not outlive its mode
2250
+ frame.title = `${full ? 'Interactive diagram' : 'Diagram'}: ${v.label}`;
2251
+ frame.src = full ? url : `${url}&embed=1`;
2252
+ wide.href = src(phone.matches ? `${url}&embed=1` : url); // a phone's Full size is the drawing alone, at the screen's width
2253
+ tabs.forEach((b, i) => b.setAttribute('aria-pressed', String(i === cur)));
2254
+ showLabel.nodeValue = full ? 'Back to the picture' : 'Show me';
2255
+ show.setAttribute('aria-expanded', String(full));
2256
+ if (panel) { panel.hidden = !listOpen; listBtn.setAttribute('aria-expanded', String(listOpen)); }
2257
+ };
2258
+ 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; }) : [];
2259
+ const showLabel = document.createTextNode('Show me');
2260
+ const show = el('button', { class: 'gd-lnk deep gd-show', type: 'button', 'aria-expanded': 'false' }, showLabel, gic('chev', 'gchev'));
2261
+ show.onclick = () => { full = !full; paint(); };
2262
+ const wide = el('a', { class: 'gd-lnk', target: '_blank', rel: 'noopener' }, 'Full size');
2263
+ const listBtn = el('button', { class: 'gd-lnk', type: 'button', 'aria-expanded': 'false' }, 'As a list', gic('chev', 'gchev'));
2264
+ listBtn.onclick = () => { listOpen = !listOpen; paint(); };
2265
+ 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] : []));
2266
+ for (const q of [night, phone]) q.addEventListener('change', () => { if (fig.isConnected) paint(); });
2267
+ paint();
2268
+ return fig;
2269
+ }
2229
2270
  function visual(v) {
2230
2271
  let body;
2231
2272
  if (v.type === 'bars') {
@@ -2236,6 +2277,10 @@
2236
2277
  } else if (v.type === 'steps') {
2237
2278
  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
2279
  } 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)] : []))));
2280
+ if (v.diagram) {
2281
+ // The picture is the diagram; the rows stay the same answer in plain steps (open on a phone, one tap away on a desk).
2282
+ return diagramFig(v.diagram, body);
2283
+ }
2239
2284
  return E('figure', 'gd-fig', body);
2240
2285
  }
2241
2286
 
@@ -2270,14 +2315,14 @@
2270
2315
  const secCard = (s) => {
2271
2316
  const items = (s.items ?? []).map((it, i) => {
2272
2317
  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));
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));
2275
2320
  body.hidden = true;
2276
2321
  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
2322
  b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); body.hidden = !open; };
2278
2323
  return E('li', '', b, body);
2279
2324
  });
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));
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));
2281
2326
  };
2282
2327
 
2283
2328
  const optBtns = new Map();
@@ -2624,11 +2669,12 @@
2624
2669
  const openKeys = new Set(open.map((d) => d.key));
2625
2670
  const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
2626
2671
  for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key) && !lapsed(d)) by[area(d)].push(d);
2627
- by.evidence.sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
2672
+ // 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.
2673
+ // 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.
2674
+ 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);
2675
+ by.evidence = [...by.evidence.filter((d) => d.type !== 'evidence'), ...by.provenToday].sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
2676
+ by.provenToday = by.evidence.filter((d) => d.type === 'evidence');
2628
2677
  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
2678
  // 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
2679
  let latest = -Infinity;
2634
2680
  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 +2724,7 @@
2678
2724
  ...(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
2725
  : 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
2726
  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.evidence, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
2727
+ el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.provenToday, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
2682
2728
  ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()])]));
2683
2729
  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
2730
  if (at === 'decide') document.querySelector('#board .decide')?.prepend(bulkBar(bulk));
@@ -2710,8 +2756,8 @@
2710
2756
  // An interactive page may post its own height, `{ height: <px> }`, and its frame then fits it with no blank below.
2711
2757
  // Only a frame's own message sizes it. Document readers must fit even long pages; previews stay capped.
2712
2758
  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');
2759
+ 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;
2760
+ const documentFrame = frame?.matches('iframe.plan-view, #reader .read iframe, .gd-dgbox iframe');
2715
2761
  if (documentFrame && e.data?.['sign-off'] === 'ready') {
2716
2762
  frame.scrolling = 'no';
2717
2763
  frame.contentWindow.postMessage({ 'sign-off': 'size' }, '*');