sign-off-axi 0.6.6 → 0.6.7
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/README.md +14 -3
- package/dist/cli.js +95 -20
- package/dist/contract.js +23 -12
- package/dist/pagestyle.js +73 -0
- package/dist/store.js +25 -1
- package/fixtures/contract/report.v2/page/index.html +14 -12
- package/fixtures/contract/report.v2/valid-full.json +1 -1
- package/fixtures/contract/report.v2/valid-minimal.json +2 -0
- package/fixtures/contract/report.v3/valid-full.json +2 -0
- package/fixtures/contract/report.v3/valid-minimal.json +2 -0
- package/fixtures/contract/report.v4/valid-full.json +2 -0
- package/fixtures/contract/report.v4/valid-minimal.json +2 -0
- package/fixtures/contract/report.v5/valid-full.json +1 -0
- package/fixtures/contract/report.v5/valid-minimal.json +1 -0
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +27 -3
- package/web/index.html +7 -11
- package/web/report-template.json +1 -0
- package/web/report.html +13 -13
package/README.md
CHANGED
|
@@ -78,7 +78,18 @@ What you send on a card (an answer, a note, a new round, a comment) shows its tr
|
|
|
78
78
|
|
|
79
79
|
### Plans and reports, on their own pages
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
An agent writes a report as data and the board draws it: `sign-off-axi report init <folder> --purpose <kind>` writes `report.json`, and each section takes `blocks` to show a point rather than tell it.
|
|
82
|
+
|
|
83
|
+
| Block | What it draws |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `callout` | a note, warning or question set apart |
|
|
86
|
+
| `beats` | a stepper of pictures, each with a time, title and words |
|
|
87
|
+
| `video` | a short film with its poster |
|
|
88
|
+
| `table` | the same measures for each option |
|
|
89
|
+
| `compare` | before and after |
|
|
90
|
+
| `evidence` | captures, each marked Fixed, In progress or Waiting on you |
|
|
91
|
+
|
|
92
|
+
Design output (screens a model made) goes in a `gallery` on the section: `report init --gallery`. Every picture and film is a file beside `report.json`, and post pins it. A page an agent writes itself (`report init --page`) is refused when it brings its own colours, three or more, or a card inside a card; reports already on the board stay as filed.
|
|
82
93
|
|
|
83
94
|
<table>
|
|
84
95
|
<tr>
|
|
@@ -150,8 +161,8 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
150
161
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
151
162
|
| Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
|
|
152
163
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
153
|
-
| Report | `report init <folder
|
|
154
|
-
| Design output report | `report init <folder> --gallery`, put the screenshots under the board's folder, fill the gallery (label, model, run time, a caption and device per frame), then `post --kind report` | a swipeable strip of options on a phone, a row on a desk: each option's phone and desktop frame side by side, the caption with the model and run time under each, a tap to open full size with Previous and Next |
|
|
164
|
+
| Report | `report init <folder> --purpose <kind>` (always: research, plan, decision, audit, incident, status or other), fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers under their part, one bar (Agree, Disagree, Comment, Ask) that acts on the part in view, and one Activity of every note, ask, reply and read |
|
|
165
|
+
| Design output report | `report init <folder> --purpose <kind> --gallery`, put the screenshots under the board's folder, fill the gallery (label, model, run time, a caption and device per frame), then `post --kind report` | a swipeable strip of options on a phone, a row on a desk: each option's phone and desktop frame side by side, the caption with the model and run time under each, a tap to open full size with Previous and Next |
|
|
155
166
|
| Report kit blocks | add `blocks` to a section of `report.json`: `callout`, `beats` (a stepper), `video`, `table`, `compare` (before and after), `evidence` rows, with files under the board's folder | the board draws each in its own type and colours, light and dark: full width, one gutter, no card inside a card; a gallery and a video may run wider than the reading column on a desk |
|
|
156
167
|
| Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed, and open full screen at phone or desktop width |
|
|
157
168
|
| Plan | `report init <folder> --plan`, fill `plan.json`, then `post --kind plan --from plan.json` | the same scroll, plus each decision with my pick and a Go ahead |
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { parseArgs } from 'node:util';
|
|
|
14
14
|
import { encode } from '@toon-format/toon';
|
|
15
15
|
import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
|
|
16
16
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
17
|
-
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
|
+
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendKind, readKinds, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
18
18
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, galleryFrames, guided, kitWarnings, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
19
19
|
import { faults, isStill } from './evidence-text.js';
|
|
20
20
|
import { copiesOf, differs } from './images.js';
|
|
@@ -124,7 +124,7 @@ examples:
|
|
|
124
124
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
125
125
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
126
126
|
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate and finding at schema 1, design at schema 2 (schema 1 still passes), report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
|
|
127
|
-
A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init).
|
|
127
|
+
A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init). Every report names what it is for in "purpose", required on a report 5 and on any new report, and so is its "project".
|
|
128
128
|
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
129
129
|
A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
|
|
130
130
|
A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
|
|
@@ -380,8 +380,10 @@ examples:
|
|
|
380
380
|
sign-off-axi feedback fleet-dashboard-full --reply "The colours now come from the new tokens." --link https://github.com/owner/repo/pull/245
|
|
381
381
|
sign-off-axi feedback fleet-dashboard-full --acted --link https://github.com/owner/repo/pull/245
|
|
382
382
|
`,
|
|
383
|
-
report: `usage: sign-off-axi report init <folder>
|
|
384
|
-
|
|
383
|
+
report: `usage: sign-off-axi report init <folder> --purpose research|plan|decision|audit|incident|status|other [--page] [--gallery]
|
|
384
|
+
sign-off-axi report init <folder> --plan
|
|
385
|
+
sign-off-axi report kind <key> <kind> --project <name> --by <who> [--board board.json] (or --from <file> of key<TAB>kind<TAB>project lines)
|
|
386
|
+
Writes the guided template to fill: <folder>/report.json (--purpose is required), or <folder>/plan.json with --plan. A file already there is kept.
|
|
385
387
|
Every report names what it is for in "purpose", its kind: research (research and discovery), plan (implementation plan), decision (decision and recommendation),
|
|
386
388
|
audit, incident (incident and retro), status, or other. --purpose fills it in and writes that kind's parts to fill, one section each, which post then requires
|
|
387
389
|
(research: question, scope, method, sources with links and a user report, options, findings with their confidence, not-checked, recommendation, open-questions;
|
|
@@ -392,7 +394,7 @@ Numbers sit behind a Details tap; every part has a Go deeper button that asks yo
|
|
|
392
394
|
Replace every FILL: line, then post it: post --kind report --from <folder>/report.json, or post --kind plan --from <folder>/plan.json --filer <your id>.
|
|
393
395
|
A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
|
|
394
396
|
--gallery adds a gallery to the first section: a design output's options, each with its model, run time and a phone and a desktop frame (an image or page, as a path from the board file's folder), which the board draws as a swipeable strip with each frame opening full size.
|
|
395
|
-
--page writes the older scaffold page instead, for a
|
|
397
|
+
--page (with --purpose) writes the older scaffold page instead, for a demo to tap that no block of the kit can show (report schema 2); post it with --type report --purpose <kind>. The board owns the colours: post refuses a page that writes out three or more colours of its own, or draws a card inside a card, on its first post. It is:
|
|
396
398
|
Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
|
|
397
399
|
The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
|
|
398
400
|
1. the answer in one plain sentence and a picture of it 2. a diagram of how it works
|
|
@@ -400,8 +402,10 @@ The page is one self-contained file, phone first, light and dark, in four parts,
|
|
|
400
402
|
Replace every FILL: line, open the page in a browser 390 px wide and look: mostly text means it is not ready.
|
|
401
403
|
Then post it with the command it prints; the paths are from the board file's folder, so make the folder under it, such as .sign-off/mocks/<key>.
|
|
402
404
|
A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
|
|
405
|
+
report kind sets the kind and the project of a report posted before every report had them, and records --by, who set it; the board itself is not changed, only <board>.kinds.jsonl. All the lines of --from are checked before the first is written.
|
|
403
406
|
examples:
|
|
404
|
-
sign-off-axi report init .sign-off/
|
|
407
|
+
sign-off-axi report init .sign-off/reports/sign-in-flow --purpose research
|
|
408
|
+
sign-off-axi report kind sign-in-flow research --project Beacon --by agent-41
|
|
405
409
|
`,
|
|
406
410
|
init: `usage: sign-off-axi init [dir] [--agent auto|<name>[,<name>]...]
|
|
407
411
|
Writes a repo's sign-off setup under <dir>/.sign-off/ (config, an empty board, mocks/ and evidence/ folders, and a .gitignore keeping media and answer files out of git). A file that is already there is kept, never changed, except that a given --agent replaces the config's agent: line; a re-run adds what is missing.
|
|
@@ -511,6 +515,18 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design
|
|
|
511
515
|
const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
|
|
512
516
|
/** A report names its own `filed` time, so only the notice record goes. */
|
|
513
517
|
const asReport = ({ announced: _announced, ...rest }) => rest;
|
|
518
|
+
/** The projects the board knows, for a refusal that names them. */
|
|
519
|
+
const knownProjects = (file) => { const all = existsSync(file) ? readProjects(file).map((p) => p.name) : []; return all.length ? `known projects: ${all.join(', ')}` : 'no project is registered on this board yet (sign-off-axi project --name <project> --icon <picture>), so name the one the report is about'; };
|
|
520
|
+
/** A new report names its kind and its project, whatever its schema; one already on the board stays a re-post of its own key (an older one is named with report kind). */
|
|
521
|
+
function newReportFields(m, file) {
|
|
522
|
+
if (m?.kind !== 'report')
|
|
523
|
+
return;
|
|
524
|
+
if (!Object.hasOwn(m, 'purpose')) {
|
|
525
|
+
throw new ContractError(`report ${m.key} has no kind: "purpose" is required, what the report is for`, `Set "purpose" to one of ${REPORT_PURPOSES.join(', ')}; ${REPORT_INIT} --purpose <kind> writes a report with it filled in`);
|
|
526
|
+
}
|
|
527
|
+
if (!Object.hasOwn(m, 'project'))
|
|
528
|
+
throw new ContractError(`report ${m.key} has no project: "project" is required, so its row shows the project's icon`, `Set "project" to the project the report is about; ${knownProjects(file)}`);
|
|
529
|
+
}
|
|
514
530
|
/** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
|
|
515
531
|
* A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
|
|
516
532
|
* that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
|
|
@@ -551,6 +567,8 @@ function contractItems(items, from, file, replace = false) {
|
|
|
551
567
|
}
|
|
552
568
|
try {
|
|
553
569
|
checkMessage(check);
|
|
570
|
+
if (!held)
|
|
571
|
+
newReportFields(check, file);
|
|
554
572
|
if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
|
|
555
573
|
reportPage(check, dirname(file));
|
|
556
574
|
if (guided(check))
|
|
@@ -1139,12 +1157,12 @@ async function locked(lock, fn, busy = 'another raise is still writing the board
|
|
|
1139
1157
|
const mockFile = (path) => path.split('?')[0];
|
|
1140
1158
|
/** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
|
|
1141
1159
|
function post(args, cmd = 'post') {
|
|
1142
|
-
const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
|
|
1160
|
+
const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, purpose: { type: 'string' }, project: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
|
|
1143
1161
|
media: { type: 'string', multiple: true }, attach: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
1144
1162
|
...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
|
|
1145
1163
|
const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, 'ask-ref': { type: 'string' }, ticket: { type: 'string' }, priority: { type: 'string' }, ...typed,
|
|
1146
1164
|
...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
|
|
1147
|
-
const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
1165
|
+
const given = [...RAISE_FIELDS, 'type', 'purpose', 'project', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
1148
1166
|
// Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
|
|
1149
1167
|
const optionLinks = values.link ?? [];
|
|
1150
1168
|
if (values.type !== 'design' && optionLinks.length)
|
|
@@ -1850,15 +1868,21 @@ function postReport(file, named, on, values) {
|
|
|
1850
1868
|
const odd = ['option', 'recommend', 'default', 'close', 'detail', 'mock', 'tradeoff', 'media', 'chapter'].find((f) => values[f] !== undefined);
|
|
1851
1869
|
if (odd)
|
|
1852
1870
|
throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
1871
|
+
if (values.purpose === undefined)
|
|
1872
|
+
throw fail(`a report needs --purpose <kind>, what it is for: ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --title "<the answer>" --about "<why it matters>" --view <page>.html --preview <picture>.svg');
|
|
1873
|
+
if (!REPORT_PURPOSES.includes(values.purpose))
|
|
1874
|
+
throw fail(`--purpose ${values.purpose} must be one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --title "<the answer>" ...');
|
|
1875
|
+
if (values.project === undefined)
|
|
1876
|
+
throw fail(`a report needs --project <name>, the project it is about; ${knownProjects(file)}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --project Beacon --title "<the answer>" ...');
|
|
1853
1877
|
const missing = ['title', 'about', 'view', 'preview'].filter((f) => values[f] === undefined).map((f) => `--${f}`);
|
|
1854
1878
|
if (missing.length) {
|
|
1855
|
-
throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
|
|
1879
|
+
throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --purpose <kind> --project <name> --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
|
|
1856
1880
|
}
|
|
1857
1881
|
const key = values.key ?? slug(values.title), had = existsSync(file) ? readItems(file).items.find((i) => i?.key === key) : undefined;
|
|
1858
1882
|
// Posting the same report again is a no-op, so it keeps the time it was first filed.
|
|
1859
1883
|
const filed = (had?.kind === 'report' && had.filed) || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
1860
1884
|
const { title, about, view, preview, link } = values;
|
|
1861
|
-
return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, ...(link !== undefined && { link }), filed });
|
|
1885
|
+
return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, purpose: values.purpose, project: values.project, ...(link !== undefined && { link }), filed });
|
|
1862
1886
|
}
|
|
1863
1887
|
/** Posts one contract message (docs/contract.md), from a file or from stdin with --from -: checked against --kind, then stored like a raised item.
|
|
1864
1888
|
* `built` is a message post --type report made from its flags. */
|
|
@@ -1890,12 +1914,14 @@ async function postKind(file, named, on, values, built) {
|
|
|
1890
1914
|
// A new ask is held to the question standard, and so is a new design's title; a card already on the board still re-posts as it was.
|
|
1891
1915
|
const existing = !built && ['ask', 'design'].includes(kind) && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
|
|
1892
1916
|
checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existing, !built && kind === 'design' && !existing);
|
|
1917
|
+
if (!(existsSync(file) && readItems(file).items.some((i) => i?.key === m.key)))
|
|
1918
|
+
newReportFields(m, file);
|
|
1893
1919
|
// Media paths read from the message file's folder, or from the current folder for stdin.
|
|
1894
1920
|
if (m.kind === 'evidence')
|
|
1895
1921
|
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
1896
1922
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1897
1923
|
if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
|
|
1898
|
-
reportPage(m, dirname(file));
|
|
1924
|
+
reportPage(m, dirname(file), !(existsSync(file) && readItems(file).items.some((i) => i?.key === m.key)));
|
|
1899
1925
|
if (guided(m)) {
|
|
1900
1926
|
diagramPages(m, dirname(file));
|
|
1901
1927
|
for (const w of kitWarnings(m))
|
|
@@ -2584,18 +2610,27 @@ const kindSections = (purpose) => REPORT_PARTS[purpose].map(([part, name]) => {
|
|
|
2584
2610
|
function report(args) {
|
|
2585
2611
|
let parsed;
|
|
2586
2612
|
try {
|
|
2587
|
-
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' } } });
|
|
2613
|
+
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' }, project: { type: 'string' }, board: { type: 'string' }, by: { type: 'string' }, from: { type: 'string' } } });
|
|
2588
2614
|
}
|
|
2589
2615
|
catch (e) {
|
|
2590
2616
|
throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
|
|
2591
2617
|
}
|
|
2592
2618
|
const [sub, dir, extra] = parsed.positionals, { plan, page, purpose, gallery } = parsed.values;
|
|
2619
|
+
if (parsed.values.project !== undefined && sub !== 'kind')
|
|
2620
|
+
throw fail('--project belongs to report kind; a new report carries its project in report.json', 'VALIDATION_ERROR', 'sign-off-axi report kind <key> <kind> --project <name> --by <who>');
|
|
2621
|
+
if (sub === 'kind')
|
|
2622
|
+
return reportKind(parsed.positionals.slice(1), parsed.values);
|
|
2623
|
+
if (parsed.values.board !== undefined || parsed.values.by !== undefined || parsed.values.from !== undefined)
|
|
2624
|
+
throw fail('--board, --by and --from belong to report kind', 'VALIDATION_ERROR', 'sign-off-axi report kind <key> <kind> --by <who>');
|
|
2625
|
+
if (purpose !== undefined && plan)
|
|
2626
|
+
throw fail('--purpose does not go with --plan: a plan is always an implementation plan', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --plan');
|
|
2593
2627
|
if (gallery && (plan || page))
|
|
2594
|
-
throw fail(`--gallery does not go with --${plan ? 'plan' : 'page'}: a gallery is a section of a guided report`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --gallery');
|
|
2595
|
-
if (purpose !== undefined && (plan || page))
|
|
2596
|
-
throw fail(`--purpose does not go with --${plan ? 'plan' : 'page'}: ${plan ? 'a plan is always an implementation plan' : 'a page report has no purpose field'}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
|
|
2628
|
+
throw fail(`--gallery does not go with --${plan ? 'plan' : 'page'}: a gallery is a section of a guided report`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research --gallery');
|
|
2597
2629
|
if (purpose !== undefined && !REPORT_PURPOSES.includes(purpose))
|
|
2598
2630
|
throw fail(`--purpose ${purpose} must be one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
|
|
2631
|
+
if (sub === 'init' && !plan && purpose === undefined) {
|
|
2632
|
+
throw fail(`report init needs --purpose <kind>, what the report is for: ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
|
|
2633
|
+
}
|
|
2599
2634
|
if (sub !== 'init' || dir === undefined || extra !== undefined || (plan && page)) {
|
|
2600
2635
|
throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : plan && page ? '--plan and --page do not go together' : 'report init needs the folder to write the template in', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> [--plan]');
|
|
2601
2636
|
}
|
|
@@ -2622,7 +2657,6 @@ function report(args) {
|
|
|
2622
2657
|
created.push(shellPath(path));
|
|
2623
2658
|
}
|
|
2624
2659
|
return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
|
|
2625
|
-
...(plan || purpose ? [] : [`Set "purpose" to what the report is for: ${REPORT_PURPOSES.join(', ')} (or run report init again with --purpose <kind> in a new folder)`]),
|
|
2626
2660
|
`Replace every FILL: line in ${kind}.json with your own words: the answer first, one picture (bars, steps or tiles), what you understood, then the sections${plan ? ' and the decisions' : ''}`,
|
|
2627
2661
|
`${plan ? 'A plan' : 'A design or architecture report'} needs one real diagram: a flow or architecture drawing as inline SVG, in light and dark, as "diagram" on the visual (see the skill)`,
|
|
2628
2662
|
...(gallery ? ['Save each option\'s screenshots (or pages) under the board file\'s folder, a phone frame 390 wide and a desktop frame 1280 wide, and fill the gallery: its label, model, run time (UTC), and per frame its path from that folder and a caption'] : []),
|
|
@@ -2642,10 +2676,51 @@ function report(args) {
|
|
|
2642
2676
|
return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
|
|
2643
2677
|
'Replace every FILL: line: the answer and its picture first, then the diagram, the steps to tap, and the folded detail',
|
|
2644
2678
|
'Open index.html in a browser 390 px wide and look before posting: mostly text means it is not ready',
|
|
2645
|
-
served ? `Run \`sign-off-axi post ${shellPath(board)} --type report --title "<the answer>" --about "<why it matters>" --view ${rel}/index.html --preview ${rel}/preview.svg\``
|
|
2646
|
-
: `Run \`sign-off-axi post <board.json> --type report --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
|
|
2679
|
+
served ? `Run \`sign-off-axi post ${shellPath(board)} --type report --purpose ${purpose} --project <name> --title "<the answer>" --about "<why it matters>" --view ${rel}/index.html --preview ${rel}/preview.svg\``
|
|
2680
|
+
: `Run \`sign-off-axi post <board.json> --type report --purpose ${purpose} --project <name> --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
|
|
2647
2681
|
]);
|
|
2648
2682
|
}
|
|
2683
|
+
/** report kind <key> <kind> --by <who>, or --from <file>: one `key<TAB>kind<TAB>project` line each, with a note after if you like. Sets the kind and the project of reports posted before every report had one, and records who set it.
|
|
2684
|
+
* Every key is checked before the first is written, so a bad line changes nothing. */
|
|
2685
|
+
function reportKind(given, values) {
|
|
2686
|
+
const example = 'sign-off-axi report kind sign-in-flow research --project Beacon --by agent-41', { by, from, project } = values;
|
|
2687
|
+
if (by === undefined || !by.trim() || by.length > 80)
|
|
2688
|
+
throw fail('report kind needs --by <who set it>, in 80 characters or fewer', 'VALIDATION_ERROR', example);
|
|
2689
|
+
if (from !== undefined === given.length > 0)
|
|
2690
|
+
throw fail(from !== undefined ? '--from takes the place of <key> <kind>' : 'report kind needs <key> <kind> --project <name>, or --from <file> with one `key<TAB>kind<TAB>project` line each', 'VALIDATION_ERROR', example);
|
|
2691
|
+
let pairs = [[...given, project]];
|
|
2692
|
+
if (from !== undefined) {
|
|
2693
|
+
let text;
|
|
2694
|
+
try {
|
|
2695
|
+
text = readFileSync(from, 'utf8');
|
|
2696
|
+
}
|
|
2697
|
+
catch (e) {
|
|
2698
|
+
throw fail(`${from} ${reasonOf(e.code)}`, 'NOT_FOUND', 'Check the path names a readable file');
|
|
2699
|
+
}
|
|
2700
|
+
pairs = text.split('\n').filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.split('\t').map((x) => x.trim()));
|
|
2701
|
+
}
|
|
2702
|
+
const file = values.board === undefined ? boardHere() : resolve(values.board), items = readItems(file).items, reports = new Map(items.filter((i) => i?.kind === 'report').map((i) => [i.key, i]));
|
|
2703
|
+
const set = readKinds(file, warn), rows = pairs.map(([key, kind, name, _why, ...rest], n) => {
|
|
2704
|
+
const at = from === undefined ? '' : `${from} line ${n + 1}: `;
|
|
2705
|
+
if (key === undefined || kind === undefined || !name || rest.length)
|
|
2706
|
+
throw fail(`${at}expected <key> <kind> <project>`, 'VALIDATION_ERROR', example);
|
|
2707
|
+
if (!REPORT_PURPOSES.includes(kind))
|
|
2708
|
+
throw fail(`${at}${kind} is not a report kind: use one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', example);
|
|
2709
|
+
if (name.length > 40)
|
|
2710
|
+
throw fail(`${at}project ${name} is over 40 characters`, 'VALIDATION_ERROR', example);
|
|
2711
|
+
const d = reports.get(key);
|
|
2712
|
+
if (!d)
|
|
2713
|
+
throw fail(`${at}${key} is not a report on ${tilde(file)}`, 'NOT_FOUND', 'Run `sign-off-axi list` for the keys; a plan is always an implementation plan and needs no kind');
|
|
2714
|
+
for (const [what, has, now] of [['kind', d.purpose, kind], ['project', d.project, name]])
|
|
2715
|
+
if (has !== undefined && has !== now)
|
|
2716
|
+
throw fail(`${at}${key} already has its ${what} ${has} in the report itself`, 'VALIDATION_ERROR', 'Only a report posted without one is named with report kind');
|
|
2717
|
+
return { key, kind, project: name, same: (d.purpose ?? set.get(key)?.purpose) === kind && (d.project ?? set.get(key)?.project) === name };
|
|
2718
|
+
});
|
|
2719
|
+
const changed = rows.filter((r) => !r.same);
|
|
2720
|
+
for (const r of changed)
|
|
2721
|
+
appendKind(file, r.key, r.kind, r.project, by);
|
|
2722
|
+
return toon({ board: tilde(file), by, changed: changed.length, unchanged: rows.length - changed.length, reports: changed.map(({ key, kind, project }) => ({ key, kind, project })) }, [changed.length ? 'The board shows each kind and project on its row and head now' : 'Every report named already had that kind and project']);
|
|
2723
|
+
}
|
|
2649
2724
|
/** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
|
|
2650
2725
|
async function notify(args) {
|
|
2651
2726
|
const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
|
|
@@ -3176,13 +3251,13 @@ const state = () => {
|
|
|
3176
3251
|
const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
|
|
3177
3252
|
// The board file is read once for the whole state: it can hold thousands of items.
|
|
3178
3253
|
const { extra } = readItems(file), fed = extra.readonly === true || extra.answers_only === true, items = loadDecisions(file);
|
|
3179
|
-
const lines = items.some((d) => d.type === 'design') ? answerLines(file) : [];
|
|
3254
|
+
const lines = items.some((d) => d.type === 'design') ? answerLines(file) : [], kinds = readKinds(file, warn);
|
|
3180
3255
|
return {
|
|
3181
3256
|
file,
|
|
3182
3257
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
3183
3258
|
// Who filed an item stays in the file, except as `by`, the name a card's thread says posted it and took its answer: never a bare session id, which names nobody.
|
|
3184
3259
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
3185
|
-
decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
3260
|
+
decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(d.kind === 'report' && kinds.has(d.key) && { purpose: d.purpose ?? kinds.get(d.key).purpose, project: d.project ?? kinds.get(d.key).project, kind_by: kinds.get(d.key).by }), ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
3186
3261
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
3187
3262
|
readonly: extra.readonly === true,
|
|
3188
3263
|
answers_only: extra.answers_only === true,
|
package/dist/contract.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { CAPTION_MAX, EVIDENCE_STATUSES, GUARDS, isKey, MEDIA_MAX, PRIORITIES, REPORT_FORMS, REPORT_PARTS, REPORT_PURPOSES } from './store.js';
|
|
6
|
+
import { nestedCard, ownColours } from './pagestyle.js';
|
|
6
7
|
/** A message that does not match: what is wrong, and the one line that says how to fix it. */
|
|
7
8
|
export class ContractError extends Error {
|
|
8
9
|
help;
|
|
@@ -23,7 +24,7 @@ export const KINDS = Object.keys(SCHEMAS);
|
|
|
23
24
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'ticket', 'priority', 'link', 'about'];
|
|
24
25
|
const FIELDS = {
|
|
25
26
|
ask: [...COMMON, 'ask_ref', 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'guard', 'close', 'merges', 'proof', 'report'],
|
|
26
|
-
report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'view', 'preview'],
|
|
27
|
+
report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'purpose', 'project', 'view', 'preview'],
|
|
27
28
|
evidence: [...COMMON, 'media', 'run', 'status', 'captions'],
|
|
28
29
|
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
29
30
|
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
@@ -36,7 +37,7 @@ const DESIGN_1 = [...COMMON, 'group', 'filed', 'status', 'preview', 'journeys',
|
|
|
36
37
|
export const FRAME_ROLES = ['today', 'proposed', 'built', 'target'];
|
|
37
38
|
const GUIDE = ['understood', 'visual', 'sections', 'next'];
|
|
38
39
|
const GUIDE_FIELDS = {
|
|
39
|
-
report: [...COMMON, 'filed', 'group', 'decided', 'asked_by', 'purpose', ...GUIDE],
|
|
40
|
+
report: [...COMMON, 'filed', 'group', 'decided', 'asked_by', 'purpose', 'project', ...GUIDE],
|
|
40
41
|
plan: [...COMMON, 'project', 'decisions', ...GUIDE],
|
|
41
42
|
};
|
|
42
43
|
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
@@ -57,6 +58,7 @@ const KIT_TONES = ['note', 'warn', 'ask'];
|
|
|
57
58
|
export const MEDIA_BYTES = 50_000_000;
|
|
58
59
|
/** The command that makes a report's page, named in every refusal of a report without one. */
|
|
59
60
|
export const REPORT_INIT = 'sign-off-axi report init <folder>';
|
|
61
|
+
const KIT_HELP = `Draw the report with the board's own blocks instead: run \`${REPORT_INIT} --purpose <kind>\` and add \`blocks\` to a section (callout, beats, video, table, compare, evidence). A page of your own may use the system colours (Canvas, CanvasText, GrayText) and no cards`;
|
|
60
62
|
const PAGE_HELP = `Run \`${REPORT_INIT} --page\` to make the page, fill it, then post it with "view" and "preview"`;
|
|
61
63
|
const GUIDE_HELP = `Run \`${REPORT_INIT}\` to write report.json, fill every FILL: line, then post it with \`post --kind report --from <folder>/report.json\``;
|
|
62
64
|
/** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
|
|
@@ -495,8 +497,7 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
495
497
|
throw bad('schema', `${m.schema} of ${m.kind} is not in this sign-off-axi`, `Upgrade sign-off-axi, or post ${m.kind} schema ${SCHEMAS[m.kind]}`);
|
|
496
498
|
if (g && /FILL:/.test(JSON.stringify(m)))
|
|
497
499
|
throw bad('message', 'still has FILL: lines from the template', 'Replace every FILL: line with your own words, then post again');
|
|
498
|
-
|
|
499
|
-
const fields = (guided(m) ? GUIDE_FIELDS[m.kind] : m.kind === 'design' && m.schema === 1 ? DESIGN_1 : FIELDS[m.kind]).filter((f) => f !== 'purpose' || (m.kind === 'report' && m.schema >= 5)), extra = Object.keys(m).find((f) => !fields.includes(f));
|
|
500
|
+
const fields = (guided(m) ? GUIDE_FIELDS[m.kind] : m.kind === 'design' && m.schema === 1 ? DESIGN_1 : FIELDS[m.kind]), extra = Object.keys(m).find((f) => !fields.includes(f));
|
|
500
501
|
if (extra === 'options' && m.kind === 'report')
|
|
501
502
|
throw bad('options', 'is not a field of report: a report takes no answer', 'Remove "options", or post an ask to get an answer');
|
|
502
503
|
if (extra === 'options' && m.kind === 'plan')
|
|
@@ -558,6 +559,8 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
558
559
|
}
|
|
559
560
|
if (Object.hasOwn(m, 'group'))
|
|
560
561
|
text('group', m.group, 40, 'the section it belongs to, in 40 characters or fewer, or leave it out');
|
|
562
|
+
if (Object.hasOwn(m, 'project'))
|
|
563
|
+
text('project', m.project, 40, 'the project it is about, in 40 characters or fewer');
|
|
561
564
|
if (Object.hasOwn(m, 'decided'))
|
|
562
565
|
text('decided', m.decided, 200, 'what was decided, in 200 characters or fewer, or leave it out');
|
|
563
566
|
if (Object.hasOwn(m, 'form') && !REPORT_FORMS.includes(m.form))
|
|
@@ -566,13 +569,12 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
566
569
|
text('video', m.video, 10, 'the video length such as "0:28", in 10 characters or fewer, or leave it out');
|
|
567
570
|
if (Object.hasOwn(m, 'asked_by'))
|
|
568
571
|
text('asked_by', m.asked_by, 40, 'who asked for it, such as "You asked" or "Docs agent", in 40 characters or fewer, or leave it out');
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
572
|
+
// A report 5 must name what it is for; an older one may, and a new post of one must (newReportKind).
|
|
573
|
+
const set = `Set "purpose" to what this report is for, one of ${REPORT_PURPOSES.join(', ')}`;
|
|
574
|
+
if (m.schema >= 5 && !Object.hasOwn(m, 'purpose'))
|
|
575
|
+
throw bad('purpose', 'is required: the kind of report, which the board shows on its row and head', set);
|
|
576
|
+
if (Object.hasOwn(m, 'purpose') && !REPORT_PURPOSES.includes(m.purpose))
|
|
577
|
+
throw bad('purpose', `${show(m.purpose)} is not one of ${REPORT_PURPOSES.join(', ')}`, set);
|
|
576
578
|
// A report is a page to tap through and a picture of it for the card: text alone is a status note, not a report.
|
|
577
579
|
if (g) {
|
|
578
580
|
guide(1);
|
|
@@ -1052,7 +1054,7 @@ export function diagramPages(m, base) {
|
|
|
1052
1054
|
const BLOCK_CHARS = 240;
|
|
1053
1055
|
/** 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
|
|
1054
1056
|
* whose real path stays inside that folder, and a page filled in the ladder shape the scaffold gives. The first broken rule throws. */
|
|
1055
|
-
export function reportPage(m, base) {
|
|
1057
|
+
export function reportPage(m, base, fresh = false) {
|
|
1056
1058
|
const at = isKey(m.key) ? `key ${m.key}: ` : '';
|
|
1057
1059
|
// A board not made yet has no folder, so its page is not there either.
|
|
1058
1060
|
let root = resolve(base);
|
|
@@ -1101,6 +1103,15 @@ export function reportPage(m, base) {
|
|
|
1101
1103
|
throw bad(`${page} has no folded detail`, 'Fold the detail into <details><summary>Details</summary>...</details> at the end');
|
|
1102
1104
|
if (/<(script|img|iframe|link|video|audio|source)\b[^>]*\b(src|href)\s*=\s*["']?\s*(https?:)?\/\//i.test(html))
|
|
1103
1105
|
throw bad(`${page} loads something from the web, which the board blocks`, 'Put scripts, styles and pictures in the page or beside it');
|
|
1106
|
+
// The look rules bind a report posted for the first time: one already on the board is shown as it was filed, and is re-rendered through the kit instead.
|
|
1107
|
+
if (fresh) {
|
|
1108
|
+
const colours = ownColours(html);
|
|
1109
|
+
if (colours.length > 2)
|
|
1110
|
+
throw bad(`${page} draws its own colour palette (${colours.slice(0, 4).join(', ')}${colours.length > 4 ? ', ...' : ''}), but the board owns the colours`, KIT_HELP);
|
|
1111
|
+
const nested = nestedCard(html);
|
|
1112
|
+
if (nested)
|
|
1113
|
+
throw bad(`${page} puts a card inside a card (${nested}), but the board draws one level of card at most`, KIT_HELP);
|
|
1114
|
+
}
|
|
1104
1115
|
// A block is one paragraph or list item; about five lines on a phone is the most a reader takes in at once.
|
|
1105
1116
|
const long = [...html.matchAll(/<(p|li|dd|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/gi)].map((b) => b[2].replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim())
|
|
1106
1117
|
.find((t) => [...t].length > BLOCK_CHARS);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** The look rules for a page an agent writes for a report (report schema 2, `--page`): the board owns the palette and the card, so a page that brings its own is refused. */
|
|
2
|
+
const COLOUR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(/gi;
|
|
3
|
+
const PLAIN = new Set(['#fff', '#ffffff', '#000', '#000000']);
|
|
4
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
5
|
+
const CONTROLS = new Set(['button', 'summary', 'input', 'select', 'textarea', 'kbd', 'code', 'pre', 'th', 'td', 'img', 'video', 'canvas']);
|
|
6
|
+
/** The CSS a page declares: its style blocks and its style attributes, with comments gone. */
|
|
7
|
+
function css(html) {
|
|
8
|
+
const blocks = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
|
|
9
|
+
const inline = [...html.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]);
|
|
10
|
+
return [...blocks, ...inline].join('\n').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
11
|
+
}
|
|
12
|
+
/** The colours a page writes out by hand (a hex, rgb, hsl and the like), black and white aside; one accent is a choice, three or more are a palette. */
|
|
13
|
+
export function ownColours(html) {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (const [c] of css(html).matchAll(COLOUR)) {
|
|
16
|
+
const v = c.toLowerCase();
|
|
17
|
+
if (v.endsWith('(') || !PLAIN.has(v))
|
|
18
|
+
seen.add(v);
|
|
19
|
+
}
|
|
20
|
+
return [...seen];
|
|
21
|
+
}
|
|
22
|
+
/** A length in a border-radius that rounds a box into a card: 8 px or more. A percentage is a circle or a pill, never a card. */
|
|
23
|
+
function rounded(value) {
|
|
24
|
+
return [...value.matchAll(/(\d*\.?\d+)(px|rem|em)\b/g)].some((m) => parseFloat(m[1]) * (m[2] === 'px' ? 1 : 16) >= 8);
|
|
25
|
+
}
|
|
26
|
+
/** The compounds (a tag, .class or #id, joined) of the CSS rules that draw a card: round corners with a fill, an edge or a shadow. */
|
|
27
|
+
function cardRules(sheet) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const [, selectors, body] of sheet.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
|
30
|
+
const d = new Map([...body.matchAll(/([a-z-]+)\s*:\s*([^;]+)/gi)].map((m) => [m[1].toLowerCase(), m[2].trim()]));
|
|
31
|
+
const radius = [...d].some(([k, v]) => /^border(-\w+)*-radius$/.test(k) && rounded(v));
|
|
32
|
+
const drawn = [...d].some(([k, v]) => (k === 'box-shadow' && v !== 'none') || (/^border(-top|-right|-bottom|-left)?$/.test(k) && !/^(0|none)\b/.test(v)) || (/^background(-color)?$/.test(k) && !/^(none|transparent|inherit)\b/.test(v)));
|
|
33
|
+
if (!radius || !drawn)
|
|
34
|
+
continue;
|
|
35
|
+
for (const s of selectors.split(',')) {
|
|
36
|
+
const last = s.trim().split(/[\s>+~]+/).pop() ?? '';
|
|
37
|
+
if (last && !last.includes(':') && !CONTROLS.has(last))
|
|
38
|
+
out.push(last);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** A card inside a card: the first element drawn as one that sits in another, as "section.panel in .wrap", or undefined when there is none. */
|
|
44
|
+
export function nestedCard(html) {
|
|
45
|
+
const cards = cardRules(css(html));
|
|
46
|
+
if (!cards.length)
|
|
47
|
+
return undefined;
|
|
48
|
+
const is = (tag, attrs, compound) => {
|
|
49
|
+
const t = /^[a-z][a-z0-9]*/i.exec(compound)?.[0], id = /#([\w-]+)/.exec(compound)?.[1];
|
|
50
|
+
const want = [...compound.matchAll(/\.([\w-]+)/g)].map((m) => m[1]);
|
|
51
|
+
const have = (/\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs) ?? []).slice(1).join(' ').split(/\s+/);
|
|
52
|
+
return (!t || t.toLowerCase() === tag) && (!id || new RegExp(`\\sid\\s*=\\s*["']?${id}["'\\s>]`, 'i').test(attrs + ' ')) && want.every((w) => have.includes(w));
|
|
53
|
+
};
|
|
54
|
+
const body = html.replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '').replace(/<svg\b[\s\S]*?<\/svg>/gi, '<svg></svg>');
|
|
55
|
+
const stack = [];
|
|
56
|
+
for (const [, close, name, attrs] of body.matchAll(/<(\/?)([a-z][a-z0-9]*)\b([^>]*)>/gi)) {
|
|
57
|
+
const tag = name.toLowerCase();
|
|
58
|
+
if (close) {
|
|
59
|
+
const at = stack.map((s) => s.tag).lastIndexOf(tag);
|
|
60
|
+
if (at >= 0)
|
|
61
|
+
stack.length = at;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (VOID.has(tag) || attrs.endsWith('/'))
|
|
65
|
+
continue;
|
|
66
|
+
const card = cards.find((c) => is(tag, ' ' + attrs, c));
|
|
67
|
+
const outer = card && stack.find((s) => s.card);
|
|
68
|
+
if (outer)
|
|
69
|
+
return `${card} in ${outer.card}`;
|
|
70
|
+
stack.push({ tag, card });
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -290,7 +290,7 @@ export function loadDecisions(path, board) {
|
|
|
290
290
|
...(d.kind === 'gate' && d.run && { run: { run_id: d.run.run_id, commit: d.run.commit } }),
|
|
291
291
|
// So is a finding's proposal, with the counts and records behind it.
|
|
292
292
|
...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
|
|
293
|
-
...(plan && { sections: d.sections, decisions: d.decisions, ...(d.project && { project: d.project })
|
|
293
|
+
...(plan && { sections: d.sections, decisions: d.decisions }), ...((plan || report) && d.project && { project: d.project }),
|
|
294
294
|
...(engagement && { engagement: { kind: d.kind, schema: d.schema, filer: d.filer, revision: documentRevision(d) } }),
|
|
295
295
|
...(guided && { guided: true, understood: d.understood, visual: d.visual, next: d.next, ...(report && { sections: d.sections }) }),
|
|
296
296
|
...(design && { kind: 'design', type: 'design', group: d.group, filed: d.filed, status: d.status, preview: d.preview, journeys: d.journeys, ...(d.accepts && { accepts: d.accepts }), ...(d.reviewed && { reviewed: d.reviewed }), ...(d.options && { options: d.options, recommend_value: d.recommend }) }),
|
|
@@ -551,6 +551,30 @@ export function readDiscards(decisionsPath) {
|
|
|
551
551
|
? [{ key: x.key, reason: x.reason, at: x.at, last: !after.length }] : [];
|
|
552
552
|
});
|
|
553
553
|
}
|
|
554
|
+
/** The kind and project an agent read an older report's words and set for it, one line per key and change: who set it and when. The report's own file is never rewritten. */
|
|
555
|
+
export const kindsPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.kinds.jsonl';
|
|
556
|
+
export function appendKind(decisionsPath, key, purpose, project, by) {
|
|
557
|
+
const line = { key, purpose, project, by, at: now() };
|
|
558
|
+
appendLine(kindsPath(decisionsPath), line);
|
|
559
|
+
return line;
|
|
560
|
+
}
|
|
561
|
+
/** Every report's set kind as its latest line says. A torn or unknown line is skipped and reported through warn. */
|
|
562
|
+
export function readKinds(decisionsPath, warn = () => { }) {
|
|
563
|
+
const kinds = new Map();
|
|
564
|
+
readRaw(kindsPath(decisionsPath)).split('\n').forEach((line, n) => {
|
|
565
|
+
try {
|
|
566
|
+
const x = line && JSON.parse(line);
|
|
567
|
+
if (x && typeof x.key === 'string' && REPORT_PURPOSES.includes(x.purpose) && isText(x.project) && typeof x.by === 'string')
|
|
568
|
+
kinds.set(x.key, x);
|
|
569
|
+
else if (x)
|
|
570
|
+
throw new Error('not a kind');
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
warn(`skipped unreadable line ${n + 1} in ${kindsPath(decisionsPath)}`);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
return kinds;
|
|
577
|
+
}
|
|
554
578
|
/** Design reviews Main has put to the user, one line per key and change. A design a lead posts is reviewed under Designs and is no ask of the user's
|
|
555
579
|
* until the promote command names it here; no post, feed or board request writes this log. */
|
|
556
580
|
export const promotedPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.promoted.jsonl';
|
|
@@ -10,38 +10,40 @@
|
|
|
10
10
|
<style>
|
|
11
11
|
:root {
|
|
12
12
|
color-scheme: light dark;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
:root { --bg: #111114; --surface: #1C1C21; --line: #2E2E36; --text: #EDEDF0; --muted: #A4A4AE; --accent: #9491FF; --accent-soft: #26264A; --on-accent: #111114; --ok: #51CF66; }
|
|
13
|
+
/* No palette of its own: the browser's system colours follow light and dark, so the page needs no colour written out. A report the board can draw
|
|
14
|
+
itself (report init without --page) needs no page at all. */
|
|
15
|
+
--bg: Canvas; --text: CanvasText; --muted: GrayText; --line: color-mix(in srgb, CanvasText 18%, Canvas);
|
|
16
|
+
--accent: CanvasText; --soft: color-mix(in srgb, CanvasText 7%, Canvas); --on-accent: Canvas;
|
|
18
17
|
}
|
|
18
|
+
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
|
19
19
|
* { box-sizing: border-box; }
|
|
20
|
-
body { margin: 0; background: var(--bg); color: var(--text); font: 17px/1.
|
|
20
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 17px/1.4706 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; letter-spacing: -0.022em; }
|
|
21
21
|
main { max-width: 34rem; margin: 0 auto; padding: 1rem 1rem 3rem; display: grid; gap: 1rem; }
|
|
22
|
-
section {
|
|
22
|
+
section { border-top: 1px solid var(--line); padding: 1.25rem 0; display: grid; gap: 0.75rem; }
|
|
23
|
+
section:first-child { border-top: 0; }
|
|
23
24
|
h1 { font-size: 1.625rem; line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
|
|
24
|
-
h2 { font-size: 0.
|
|
25
|
+
h2 { font-size: 0.9375rem; font-weight: 600; color: var(--muted); margin: 0; }
|
|
25
26
|
p { margin: 0; }
|
|
26
27
|
.lead { font-size: 1.125rem; }
|
|
28
|
+
.asof { font-size: 0.9375rem; color: var(--muted); }
|
|
27
29
|
svg { width: 100%; height: auto; display: block; }
|
|
28
30
|
svg text { fill: var(--text); font: 600 15px system-ui, sans-serif; }
|
|
29
|
-
.box { fill: var(--
|
|
31
|
+
.box { fill: var(--soft); stroke: var(--accent); stroke-width: 2; }
|
|
30
32
|
.arrow { stroke: var(--muted); stroke-width: 2.5; fill: none; marker-end: url(#tip); }
|
|
31
33
|
#tip path { fill: var(--muted); }
|
|
32
34
|
/* Try it: tap a step, or Back and Next; the line shows where you are. */
|
|
33
35
|
.track { list-style: none; margin: 0; padding: 0; display: flex; justify-content: space-between; position: relative; }
|
|
34
36
|
.track::before { content: ""; position: absolute; left: 1.5rem; right: 1.5rem; top: 50%; border-top: 2px solid var(--line); }
|
|
35
|
-
.track button { position: relative; width: 3rem; height: 3rem; border-radius: 50%; border: 2px solid var(--line); background: var(--
|
|
37
|
+
.track button { position: relative; width: 3rem; height: 3rem; border-radius: 50%; border: 2px solid var(--line); background: var(--bg); color: var(--muted); font: 700 1.0625rem system-ui, sans-serif; cursor: pointer; }
|
|
36
38
|
.track .done { border-color: var(--accent); color: var(--accent); }
|
|
37
39
|
.track .on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
38
|
-
.stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--
|
|
40
|
+
.stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--soft); padding: 1rem; display: grid; gap: 0.375rem; align-content: start; }
|
|
39
41
|
.stage .count { font-size: 0.875rem; color: var(--muted); }
|
|
40
42
|
.stage h3 { margin: 0; font-size: 1.1875rem; }
|
|
41
43
|
.nav { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
|
42
44
|
.nav button { min-height: 3rem; border-radius: 0.75rem; font: 650 1.0625rem system-ui, sans-serif; cursor: pointer; border: 2px solid var(--accent); }
|
|
43
45
|
.back { background: transparent; color: var(--accent); }
|
|
44
|
-
.next { background: var(--accent); color: var(--on-accent); }
|
|
46
|
+
.next { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
45
47
|
button:disabled { opacity: 0.4; cursor: default; }
|
|
46
48
|
button:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
|
47
49
|
summary { min-height: 3rem; display: flex; align-items: center; justify-content: space-between; font-weight: 650; cursor: pointer; list-style: none; }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"contract":1,"kind":"report","schema":2,"key":"new-sign-in","title":"How the new sign-in works","filer":"sign-off-builder","link":"https://example.com/pr/44","about":"Sign-in now asks for a code after the password, so a stolen password alone opens nothing.","filed":"2026-10-13T07:19:00Z","group":"Sign-in","decided":"Umer keeps the code step.","form":"before-after","video":"0:28","asked_by":"You asked","view":"page/index.html","preview":"page/preview.svg"}
|
|
1
|
+
{"contract":1,"kind":"report","schema":2,"key":"new-sign-in","title":"How the new sign-in works","purpose":"other","project":"Beacon","filer":"sign-off-builder","link":"https://example.com/pr/44","about":"Sign-in now asks for a code after the password, so a stolen password alone opens nothing.","filed":"2026-10-13T07:19:00Z","group":"Sign-in","decided":"Umer keeps the code step.","form":"before-after","video":"0:28","asked_by":"You asked","view":"page/index.html","preview":"page/preview.svg"}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
"schema": 2,
|
|
5
5
|
"key": "why-two-step-sign-in",
|
|
6
6
|
"title": "Why sign-in takes two steps",
|
|
7
|
+
"purpose": "other",
|
|
8
|
+
"project": "Beacon",
|
|
7
9
|
"about": "A stolen password alone no longer opens the account.\nUmer has nothing to do.",
|
|
8
10
|
"filed": "2026-10-12T23:59:59Z",
|
|
9
11
|
"view": "page/index.html",
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
"schema": 3,
|
|
5
5
|
"key": "digest-ran",
|
|
6
6
|
"title": "The weekly digest ran",
|
|
7
|
+
"purpose": "other",
|
|
8
|
+
"project": "Beacon",
|
|
7
9
|
"filed": "2026-10-19T08:00:00Z",
|
|
8
10
|
"about": "Sent to 412 readers with no failures.",
|
|
9
11
|
"understood": [{"label": "You asked", "text": "Send the weekly digest."}],
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"next": {"heading": "Say go and I wire ntfy in", "lines": ["The first slice sends one test push to Umer's phone.", "The proof lands as an update under Evidence."]},
|
|
13
13
|
"key": "push-service",
|
|
14
14
|
"title": "Which push service fits a team of two",
|
|
15
|
+
"project": "Beacon",
|
|
15
16
|
"purpose": "research",
|
|
16
17
|
"group": "Beacon",
|
|
17
18
|
"about": "Five services compared on cost, delivery on a phone and setup time. Two fit; one is free up to our size.",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"next": {"heading": "Say go and I wire ntfy in", "lines": ["The first slice sends one test push to Umer's phone.", "The proof lands as an update under Evidence."]},
|
|
13
13
|
"key": "release-stands",
|
|
14
14
|
"title": "Where the 0.6 release stands",
|
|
15
|
+
"project": "Beacon",
|
|
15
16
|
"purpose": "status",
|
|
16
17
|
"group": "Release",
|
|
17
18
|
"about": "Four of five parts are done; the last waits on one review.",
|
package/package.json
CHANGED
|
@@ -121,7 +121,7 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
|
|
|
121
121
|
- `accepts` names the frame Yes approves. The triager's verdict goes in `reviewed`. `post --help` gives the shape, and the package's fixtures/contract/design.v2 has examples.
|
|
122
122
|
- Report: an in-depth explainer, only when the user asked for an explanation, or a concept, design or trade-off must be understood. A merge, a PR outcome, a receipt, a verdict or a status note is never a report.
|
|
123
123
|
- A report is fields the board draws as one scroll, never a text card and never a page you build. Run `npx -y sign-off-axi report init .sign-off/reports/<key> --purpose <kind>`, replace every `FILL:` line in `report.json`, then `npx -y sign-off-axi post --kind report --from .sign-off/reports/<key>/report.json --filer <id>`. Text you leave as `FILL:` is refused.
|
|
124
|
-
- Every report says what it is for in `purpose`, its kind, and post
|
|
124
|
+
- Every report says what it is for in `purpose`, its kind, and `report init` and post refuse a report without it; a report posted before that is named once with `report kind <key> <kind> --by <your id>`. Pick the one that fits: `research` (research and discovery: a question, the options, sources), `plan` (implementation plan: how the work gets built, step by step), `decision` (decision and recommendation: one choice weighed), `audit` (what was checked against a standard, and what to fix), `incident` (incident and retro: what went wrong, why, what changes), `status` (where the work stands), or `other` when none fits. The board shows the kind on the report's row and head, with a line on what it is for, and the user filters Reports by it.
|
|
125
125
|
- A plan is the same, with the questions the person decides: `report init .sign-off/plans/<key> --plan`, fill `plan.json`, `post --kind plan --from <file> --filer <id>`, then `wait --filer <id>`. The person can press **Go ahead** and accept every pick you recommended, so recommend what you would do and say in `quiet` what happens if they stay quiet.
|
|
126
126
|
- 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, or the diagram a design, architecture or plan report needs), *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*.
|
|
127
127
|
- Answer first: the heading of the picture is the answer, one plain sentence.
|
|
@@ -141,9 +141,33 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
|
|
|
141
141
|
- 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.
|
|
142
142
|
- A report about design output (screens, mocks or options a model made) always carries a gallery, never a pasted picture: run `report init <folder> --purpose <kind> --gallery`, save each option's screenshots under the board file's folder (a phone frame 390 wide, a desktop frame 1280 wide, light copies not needed), and fill the gallery on the first section: per option its `label`, the `model` that made it and `ran` (UTC), and per frame its `device` (`phone` or `desktop`), `src` and a `caption` saying what it shows. Post refuses a frame with no caption or device. The board draws the options as a strip to swipe (a row on a desk) with both frames side by side and a tap to open full size.
|
|
143
143
|
- Show a report's parts with the board's own blocks, never your own HTML or CSS: add `blocks` to a section: `callout` (a note, warning or question set apart), `beats` (a stepper of pictures, each with a time, title and words), `video` (a film with its poster), `table`, `compare` (before and after) and `evidence` rows (a status of `fixed`, `progress` or `waiting`). Name the files next to the board file; post pins them. Post warns when your first paragraph repeats `about`: say something new there.
|
|
144
|
-
-
|
|
144
|
+
- Write a report with the board's kit (next chapter), never as a page of your own. A page is only for a demo the reader must tap that no block can show (see `report init <folder> --page --purpose <kind>`).
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
## Write a report with the board's kit
|
|
147
|
+
|
|
148
|
+
You hand the board words, files and rows; the board draws them in its own type and colours, light and dark, full width in one gutter. Never write HTML, CSS, a colour or a card for a report: a page of your own will not match the board, and a plan or report you want read has to look like the rest of it.
|
|
149
|
+
|
|
150
|
+
1. `npx -y sign-off-axi report init .sign-off/reports/<key> --purpose <kind>` (add `--gallery` for design output), then fill every `FILL:` line. A report with nothing in it to show needs no blocks.
|
|
151
|
+
2. Show a part with a block instead of a paragraph: add `blocks` (1 to 12, in the order given) to the section. Save each picture or film next to `report.json` and name it by path; post pins the files.
|
|
152
|
+
3. Post: `post --kind report --from .sign-off/reports/<key>/report.json --filer <id>`. Open the board at 390 px wide, light and dark, and look before you say it is there.
|
|
153
|
+
|
|
154
|
+
Pick the block by what the reader must see:
|
|
155
|
+
|
|
156
|
+
| Block | Use it for | Fields |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `callout` | one thing set apart: a caveat, a warning, a question | `tone` (`note`, `warn` or `ask`), `title` (60), `text` (280) |
|
|
159
|
+
| `beats` | a sequence the reader steps through: a film's scenes, a flow | `beats`, 2 to 12, each `image`, `time`, `title` (80), `text` (280) |
|
|
160
|
+
| `video` | a short film | `src` (.mp4 or .webm, 50 MB), `poster`, `length`, `caption` |
|
|
161
|
+
| `table` | the same measures for 2 or more options | `columns` (2 to 5), `rows` (up to 30, one cell per column, 120 each) |
|
|
162
|
+
| `compare` | before and after | `before` and `after`, each `image` and `caption` (80) |
|
|
163
|
+
| `evidence` | captures with a state for each | `rows`, each `image`, `title`, `note`, `status` (`fixed`, `progress` or `waiting`) |
|
|
164
|
+
|
|
165
|
+
- One idea per block, one block per point; a table beats three paragraphs of numbers, and a stepper beats a numbered list of screens.
|
|
166
|
+
- Say something new in each part. Post warns, and does not refuse, when a section's first paragraph repeats `about`.
|
|
167
|
+
- Options a model made are a gallery, not a row of pictures: see the design-output rule above. `gallery` and `blocks` can share a section, the gallery first.
|
|
168
|
+
- Images are .png, .jpg, .jpeg or .webp; a path has no `..`, `?`, `#` or backslash.
|
|
169
|
+
- Sentence case in every heading and label; no capitals shouted, no colour words, no markup in any field.
|
|
170
|
+
- A page of your own (`report init --page`) is held to the board's look at its first post: post refuses one that writes out three or more colours of its own or draws a card inside a card, and says to use blocks. The scaffold already draws neither; keep to the system colours (Canvas, CanvasText, GrayText). A report already on the board stays as it was filed.
|
|
147
171
|
|
|
148
172
|
## Follow your report kind's workflow
|
|
149
173
|
|
package/web/index.html
CHANGED
|
@@ -3231,7 +3231,7 @@
|
|
|
3231
3231
|
* or, filed by a tool that is itself a project on the board (its icon set with project --icon), that project. */
|
|
3232
3232
|
const projectName = (d) => d.project || d.group || (d.type === 'evidence' ? projectOf(d) : d.proof && projectOf(state.decisions.find((x) => x.key === d.proof) ?? {}))
|
|
3233
3233
|
|| (ticketOf(d) && tkProject(ticketOf(d))) || (d.by && (state.projects ?? []).find((p) => p.name.toLowerCase() === d.by.toLowerCase())?.name) || undefined;
|
|
3234
|
-
/** One project picture and tile for every list; use the pinned record, with a
|
|
3234
|
+
/** One project picture and tile for every list; use the pinned record, with a neutral project icon when the project has none. */
|
|
3235
3235
|
function projectPic(name, fallback = () => glyph('layers')) {
|
|
3236
3236
|
const p = name && (state.projects ?? []).find((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
3237
3237
|
if (!p?.icon) return fallback();
|
|
@@ -3241,7 +3241,7 @@
|
|
|
3241
3241
|
}
|
|
3242
3242
|
const projectTile = (name, fallback) => el('span', { class: 'ui-icon', role: 'img', 'aria-label': name || 'No project' }, projectPic(name, fallback));
|
|
3243
3243
|
const cardTile = (d) => {
|
|
3244
|
-
const design = d.kind === 'design' || d.type === 'design', tile = projectTile(projectName(d), () => design ? icon('designs') : d.type === 'plan' ? icon('plan') : d.kind === 'report' || d.type === 'explainer' ?
|
|
3244
|
+
const design = d.kind === 'design' || d.type === 'design', tile = projectTile(projectName(d), () => design ? icon('designs') : d.type === 'plan' ? icon('plan') : d.kind === 'report' || d.type === 'explainer' ? glyph('layers') : icon(d.type === 'evidence' ? 'evidence' : d.reply ? 'reply' : 'yesno'));
|
|
3245
3245
|
const frame = design && designFrame(d);
|
|
3246
3246
|
if (frame) { const img = el('img', { class: 'design-thumb', src: src(designUrl(d, frame)), alt: '' }); img.onerror = () => img.replaceWith(projectPic(projectName(d), () => icon('designs'))); tile.replaceChildren(img); }
|
|
3247
3247
|
return tile;
|
|
@@ -3645,15 +3645,11 @@
|
|
|
3645
3645
|
status: ['Status', 'Status', 'Where the work stands now and what comes next.', '<path d="M3 12h4l3-7 4 14 3-7h4"/>'],
|
|
3646
3646
|
other: ['Report', 'Other report', 'An explainer of one idea.', '<path d="M12 7v13M4 18a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4.5A3.5 3.5 0 0 1 12 7.5 3.5 3.5 0 0 1 15.5 4H20a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>'],
|
|
3647
3647
|
};
|
|
3648
|
-
/** A
|
|
3649
|
-
const
|
|
3650
|
-
['plan', /\b(plan|slices?|roll ?out|migrat|step by step)/i], ['status', /\b(status|stands|progress|this week|weekly|so far)\b/i],
|
|
3651
|
-
['decision', /\b(should we|recommend|decid|decision|instead of|versus|vs\.)/i], ['research', /\b(research|compar|which|survey|options|study|discover|benchmark)/i]];
|
|
3652
|
-
/** A report's kind: the one it was posted with; a plan is always an implementation plan; an older report gets the best guess from its title, what it says and its section heads. */
|
|
3653
|
-
const kindOf = (d) => (KINDS[d.purpose] ? d.purpose : d.type === 'plan' ? 'plan' : GUESS.find(([, re]) => re.test([d.title, d.about, ...(d.sections ?? []).map((s) => s.heading)].join(' ')))?.[0] ?? 'other');
|
|
3648
|
+
/** A report's kind: the one it was posted with or an agent set; a plan is always an implementation plan. A report with none shows as other. */
|
|
3649
|
+
const kindOf = (d) => (KINDS[d.purpose] ? d.purpose : d.type === 'plan' ? 'plan' : 'other');
|
|
3654
3650
|
const kindSvg = (k) => { const s = el('span', { class: 'rkg', 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24">${KINDS[k][3]}</svg>`; return s; };
|
|
3655
3651
|
/** The kind's chip: its icon and its short name on a row, its full name on a report's head. */
|
|
3656
|
-
const kindChip = (d, full) => { const k = kindOf(d); return el('span', { class: `rkc k-${k}`, 'data-kind': k }, kindSvg(k), (full ? KINDS[k][1] : KINDS[k][0])
|
|
3652
|
+
const kindChip = (d, full) => { const k = kindOf(d); return el('span', { class: `rkc k-${k}`, 'data-kind': k }, kindSvg(k), (full ? KINDS[k][1] : KINDS[k][0])); };
|
|
3657
3653
|
/** The kind's tile in the filter and its pill: its icon on the same treatment as its chip; Every kind is the filter's own drawing. */
|
|
3658
3654
|
const kindTile = (k) => (k ? el('span', { class: `rkt k-${k}` }, kindSvg(k)) : el('span', { class: 'rkt k-all' }, evGlyph('filter')));
|
|
3659
3655
|
/** One line under a report's title: what this kind of report is for. */
|
|
@@ -3718,8 +3714,8 @@
|
|
|
3718
3714
|
function report(d, i = 0) {
|
|
3719
3715
|
const n = d.steps?.length;
|
|
3720
3716
|
// Keep the kind and ticket readable on line two; read progress sits quietly below while unread.
|
|
3721
|
-
const t = ticketOf(d), ticket = d.ticket || t?.title
|
|
3722
|
-
const meta = el('span', { class: 'rp-meta' }, kindChip(d), el('span', { class: 'rp-ticket', title: t ? `${t.id}: ${t.title}` : ticket }, ticket));
|
|
3717
|
+
const t = ticketOf(d), ticket = d.ticket || t?.title;
|
|
3718
|
+
const meta = el('span', { class: 'rp-meta' }, kindChip(d), ticket ? el('span', { class: 'rp-ticket', title: t ? `${t.id}: ${t.title}` : ticket }, ticket) : '');
|
|
3723
3719
|
const face = uiRow({ tile: cardTile(d), title: plain(title(d)), priority: d.priority, after: trailBadge(d.key), meta });
|
|
3724
3720
|
if (unread(d)) face[1].append(el('span', { class: 'rp-progress', role: 'progressbar', 'aria-label': 'Report read', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(readPct(d)) },
|
|
3725
3721
|
kindOf(d) === 'plan' && kindParts(d).length ? partsStrip(d) : el('span', { class: 'ui-bar' }, el('i', { style: `width: ${readPct(d)}%` })), el('span', { class: 'pct sr-only' }, `${readPct(d)}%`)));
|
package/web/report-template.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"purpose": "FILL: what this report is for: research, plan, decision, audit, incident, status or other",
|
|
8
8
|
"filed": "FILL: UTC time",
|
|
9
9
|
"about": "FILL: why it matters, in 2 sentences",
|
|
10
|
+
"project": "FILL: the project this report is about",
|
|
10
11
|
"group": "FILL: section name",
|
|
11
12
|
"understood": [
|
|
12
13
|
{
|
package/web/report.html
CHANGED
|
@@ -10,40 +10,40 @@
|
|
|
10
10
|
<style>
|
|
11
11
|
:root {
|
|
12
12
|
color-scheme: light dark;
|
|
13
|
-
/*
|
|
14
|
-
|
|
15
|
-
--
|
|
16
|
-
|
|
17
|
-
@media (prefers-color-scheme: dark) {
|
|
18
|
-
:root { --bg: #000000; --surface: #161617; --line: #424245; --text: #F5F5F7; --muted: #86868B; --accent: #2997FF; --accent-soft: #19304A; --ok: #30D158; }
|
|
13
|
+
/* No palette of its own: the browser's system colours follow light and dark, so the page needs no colour written out. A report the board can draw
|
|
14
|
+
itself (report init without --page) needs no page at all. */
|
|
15
|
+
--bg: Canvas; --text: CanvasText; --muted: GrayText; --line: color-mix(in srgb, CanvasText 18%, Canvas);
|
|
16
|
+
--accent: CanvasText; --soft: color-mix(in srgb, CanvasText 7%, Canvas); --on-accent: Canvas;
|
|
19
17
|
}
|
|
18
|
+
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
|
20
19
|
* { box-sizing: border-box; }
|
|
21
20
|
body { margin: 0; background: var(--bg); color: var(--text); font: 17px/1.4706 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; letter-spacing: -0.022em; }
|
|
22
21
|
main { max-width: 34rem; margin: 0 auto; padding: 1rem 1rem 3rem; display: grid; gap: 1rem; }
|
|
23
|
-
section {
|
|
22
|
+
section { border-top: 1px solid var(--line); padding: 1.25rem 0; display: grid; gap: 0.75rem; }
|
|
23
|
+
section:first-child { border-top: 0; }
|
|
24
24
|
h1 { font-size: 1.625rem; line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
|
|
25
|
-
h2 { font-size: 0.
|
|
25
|
+
h2 { font-size: 0.9375rem; font-weight: 600; color: var(--muted); margin: 0; }
|
|
26
26
|
p { margin: 0; }
|
|
27
27
|
.lead { font-size: 1.125rem; }
|
|
28
28
|
.asof { font-size: 0.9375rem; color: var(--muted); }
|
|
29
29
|
svg { width: 100%; height: auto; display: block; }
|
|
30
30
|
svg text { fill: var(--text); font: 600 15px system-ui, sans-serif; }
|
|
31
|
-
.box { fill: var(--
|
|
31
|
+
.box { fill: var(--soft); stroke: var(--accent); stroke-width: 2; }
|
|
32
32
|
.arrow { stroke: var(--muted); stroke-width: 2.5; fill: none; marker-end: url(#tip); }
|
|
33
33
|
#tip path { fill: var(--muted); }
|
|
34
34
|
/* Try it: tap a step, or Back and Next; the line shows where you are. */
|
|
35
35
|
.track { list-style: none; margin: 0; padding: 0; display: flex; justify-content: space-between; position: relative; }
|
|
36
36
|
.track::before { content: ""; position: absolute; left: 1.5rem; right: 1.5rem; top: 50%; border-top: 2px solid var(--line); }
|
|
37
|
-
.track button { position: relative; width: 3rem; height: 3rem; border-radius: 50%; border: 2px solid var(--line); background: var(--
|
|
37
|
+
.track button { position: relative; width: 3rem; height: 3rem; border-radius: 50%; border: 2px solid var(--line); background: var(--bg); color: var(--muted); font: 700 1.0625rem system-ui, sans-serif; cursor: pointer; }
|
|
38
38
|
.track .done { border-color: var(--accent); color: var(--accent); }
|
|
39
|
-
.track .on { background: var(--
|
|
40
|
-
.stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--
|
|
39
|
+
.track .on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
40
|
+
.stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--soft); padding: 1rem; display: grid; gap: 0.375rem; align-content: start; }
|
|
41
41
|
.stage .count { font-size: 0.875rem; color: var(--muted); }
|
|
42
42
|
.stage h3 { margin: 0; font-size: 1.1875rem; }
|
|
43
43
|
.nav { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
|
44
44
|
.nav button { min-height: 3rem; border-radius: 0.75rem; font: 650 1.0625rem system-ui, sans-serif; cursor: pointer; border: 2px solid var(--accent); }
|
|
45
45
|
.back { background: transparent; color: var(--accent); }
|
|
46
|
-
.next { background: var(--
|
|
46
|
+
.next { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
47
47
|
button:disabled { opacity: 0.4; cursor: default; }
|
|
48
48
|
button:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
|
49
49
|
summary { min-height: 3rem; display: flex; align-items: center; justify-content: space-between; font-weight: 650; cursor: pointer; list-style: none; }
|