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 +8 -2
- package/dist/contract.js +62 -3
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +17 -0
- package/web/index.html +56 -10
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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' }, '*');
|