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 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
- <!-- MARKER: report-writing and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
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>`, 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 |
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). A report 5 names what it is for in "purpose".
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> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page] [--gallery]
384
- Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
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 report that needs a page of its own (report schema 2). It is:
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/mocks/sign-in-flow
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
- // A guided report 5 names what it is for; report 4 and older have no purpose field.
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
- if (m.schema >= 5) {
570
- const set = `Set "purpose" to what this report is for, one of ${REPORT_PURPOSES.join(', ')}`;
571
- if (!Object.hasOwn(m, 'purpose'))
572
- throw bad('purpose', 'is required: the kind of report, which the board shows on its row and head', set);
573
- if (!REPORT_PURPOSES.includes(m.purpose))
574
- throw bad('purpose', `${show(m.purpose)} is not one of ${REPORT_PURPOSES.join(', ')}`, set);
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
- --bg: #F7F7F8; --surface: #FFFFFF; --line: #E2E2E6; --text: #16161A; --muted: #5C5C66;
14
- --accent: #5B5BD6; --accent-soft: #ECECFD; --on-accent: #FFFFFF; --ok: #1E7034;
15
- }
16
- @media (prefers-color-scheme: dark) {
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.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
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 { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: 1.25rem 1rem; display: grid; gap: 0.75rem; }
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.8125rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 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(--accent-soft); stroke: var(--accent); stroke-width: 2; }
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(--surface); color: var(--muted); font: 700 1.0625rem system-ui, sans-serif; cursor: pointer; }
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(--accent-soft); padding: 1rem; display: grid; gap: 0.375rem; align-content: start; }
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": "sign-in-faster",
6
6
  "title": "Sign-in now takes 2.8 minutes",
7
+ "purpose": "other",
8
+ "project": "Beacon",
7
9
  "group": "Beacon",
8
10
  "filer": "agent-41",
9
11
  "asked_by": "Release agent",
@@ -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."}],
@@ -4,6 +4,8 @@
4
4
  "schema": 4,
5
5
  "key": "sign-in-faster",
6
6
  "title": "Sign-in now takes 2.8 minutes",
7
+ "purpose": "other",
8
+ "project": "Beacon",
7
9
  "group": "Beacon",
8
10
  "filer": "agent-41",
9
11
  "ticket": "T-29",
@@ -4,6 +4,8 @@
4
4
  "schema": 4,
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": [
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
@@ -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 refuses one without it. 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.
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
- - 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`.
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
- <!-- MARKER: report-writing chapter and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
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 kind icon when the project picture is unavailable. */
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' ? kindSvg(kindOf(d)) : icon(d.type === 'evidence' ? 'evidence' : d.reply ? 'reply' : 'yesno'));
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 best guess at an older report's kind from its words, first match wins; it is never written back. */
3649
- const GUESS = [['incident', /\b(incident|outage|retro|post-?mortem|went wrong|broke|regression)/i], ['audit', /\b(audit|checked against|compliance|conform)/i],
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]) + (!KINDS[d.purpose] && d.type !== 'plan' ? ' · kind guessed' : '')); };
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 || 'No ticket';
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)}%`)));
@@ -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
- /* The board's Spacious tokens: --accent is the blue for text and lines, --action the blue a button is filled with. */
14
- --bg: #F5F5F7; --surface: #FFFFFF; --line: #D2D2D7; --text: #1D1D1F; --muted: #6E6E73;
15
- --accent: #0066CC; --action: #0071E3; --accent-soft: #E6F1FC; --on-accent: #FFFFFF; --ok: #17702F;
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 { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: 1.25rem 1rem; display: grid; gap: 0.75rem; }
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.875rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 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(--accent-soft); stroke: var(--accent); stroke-width: 2; }
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(--surface); color: var(--muted); font: 700 1.0625rem system-ui, sans-serif; cursor: pointer; }
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(--action); border-color: var(--action); color: var(--on-accent); }
40
- .stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--accent-soft); padding: 1rem; display: grid; gap: 0.375rem; align-content: start; }
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(--action); border-color: var(--action); color: var(--on-accent); }
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; }