sign-off-axi 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Guided plans and reports now offer **Simplify this** under each part (and for an
16
16
  <p align="center">
17
17
  <picture>
18
18
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-today-dark.webp">
19
- <img src="docs/media/desk-today.webp" alt="The board at desk width: Today with what needs you, what was proven and a list of waiting asks">
19
+ <img src="docs/media/desk-today.webp" alt="The board at desk width: Today with the next ask, what waits, designs to review and what was proven">
20
20
  </picture>
21
21
  </p>
22
22
 
@@ -43,25 +43,43 @@ You need Node 22.5 or newer. `init` installs the skill for each coding agent it
43
43
 
44
44
  ## The board
45
45
 
46
- Four places, in the bar at the bottom of a phone and the sidebar on a desk.
46
+ Five places, in the dock at the bottom of a phone and the sidebar on a desk: **Today**, **Decide**, **Evidence**, **Reports** and **Tickets**.
47
47
 
48
- The board installs as an app (More, then Install app; on an iPad, Share, then Add to Home Screen) and, once opened, reads offline: with the board unreachable it shows the last update under "Offline, showing the last update", and sends fail rather than queue. A phone needs the board on HTTPS or localhost to install it.
48
+ <table>
49
+ <tr>
50
+ <td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: the next ask with its answer buttons, what waits, designs to review and what was cleared and proven"></picture><br><b>Today</b><br>What needs you, what was proven.</td>
51
+ <td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-decide-dark.webp"><img src="docs/media/phone-decide.webp" alt="Decide: asks with their priority and ticket progress, and Taken without you with Change"></picture><br><b>Decide</b><br>Every ask that waits, one tap each.</td>
52
+ <td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reader-dark.webp"><img src="docs/media/phone-reader.webp" alt="A report in the reader: its answer first, and the one action bar with Agree, Disagree, Comment and Ask"></picture><br><b>Reader</b><br>One action bar on the part in view.</td>
53
+ <td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence with Previews on: each proof with its first capture and the check's result"></picture><br><b>Evidence</b><br>Proof, with its first capture inline.</td>
54
+ </tr>
55
+ </table>
49
56
 
50
57
  <table>
51
58
  <tr>
52
- <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
53
- <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-decide-dark.webp"><img src="docs/media/phone-decide.webp" alt="Decide: one card per ask that waits on you, filtered by type"></picture><br><b>Decide</b><br>Every ask that waits on you, one tap each.</td>
54
- <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence: a capture of the finished work with its result, newest first"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
55
- <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reports-dark.webp"><img src="docs/media/phone-reports.webp" alt="Reports: explainer pages you asked for"></picture><br><b>Reports</b><br>Explainers you asked for, to read.</td>
59
+ <td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-tickets-dark.webp"><img src="docs/media/phone-tickets.webp" alt="A ticket: its progress, the epic it is part of, its sub-tasks and a linked pull request"></picture><br><b>Tickets</b><br>Epics, tickets and tasks that roll up.</td>
60
+ <td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-designs-dark.webp"><img src="docs/media/phone-designs.webp" alt="Designs: the design waiting for review, with a thumbnail of its first screen"></picture><br><b>Designs</b><br>Frames to review, one question each.</td>
61
+ <td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-install-dark.webp"><img src="docs/media/phone-install.webp" alt="Today's More: the leash, Install app and the version the board runs"></picture><br><b>Install</b><br>An app on your home screen.</td>
56
62
  </tr>
57
63
  </table>
58
64
 
65
+ Every card opens to its own screen with the same parts: one **action bar** (the answer, or Agree, Disagree, Comment and Ask on a report), one **Activity** (who posted it, each answer, note, comment and reply, and how far each got), and **Previous** and **Next** through the list you opened it from (swipe on a phone, `J` and `K` on a desk). The board names its own version (`--version`, the sidebar's foot, Today's More), and a reply links the new version of the work.
66
+
67
+ The board installs as an app (More, then Install app; on an iPad, Share, then Add to Home Screen) and, once opened, reads offline: with the board unreachable it shows the last update under "Offline, showing the last update", and sends fail rather than queue. A phone needs the board on HTTPS or localhost to install it.
68
+
69
+ On a phone, larger Decide lists open quickly and **Show more** brings in the next asks, Later items or answers. The counts always include the whole board.
70
+
59
71
  ### Evidence is not a decision
60
72
 
61
- **Decide** holds only real asks: things an agent cannot do without your answer. **Evidence** holds proof (screenshots or a short recording of the running product) with the result of the check that made it: Passed, Not run, Failed, Did not finish or Could not check. A proof update is never counted as something that needs you; you can give it a verdict (Pass, Fail or Needs work) or dismiss it. **Today** counts the two separately: what needs you, and what was proven today.
73
+ **Decide** holds only real asks: things an agent cannot do without your answer. **Evidence** holds proof (screenshots or a short recording of the running product) with the result of the check that made it: Passed, Not run, Failed, Did not finish or Could not check. Each row shows its first capture and the check's word; tap one and it opens in place with every capture in a strip, and **Previews** shows every row large. A proof update is never counted as something that needs you; you can give it a verdict (Pass, Fail or Needs work) or dismiss it. **Today** counts the two separately: what needs you, and what was proven today.
74
+
75
+ ### You see how far each word got
76
+
77
+ What you send on a card (an answer, a note, a new round, a comment) shows its trail: **Sent**, **Delivered** (the agent's wait or hook got it), **Read** and **Replied**, with the agent's words and a link to the pull request or new version. It shows on the card, in its Activity, as a badge in the lists, and a reply waits in Today until you open it. An agent closes the loop with `sign-off-axi feedback <key> --reply "..." --link <url>`; `--read`, `--delivered` and `--acted --link` mark the other steps, and `--at <epoch seconds>` replays a step that happened earlier without turning a later real one back.
62
78
 
63
79
  ### Plans and reports, on their own pages
64
80
 
81
+ <!-- MARKER: report-writing and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
82
+
65
83
  <table>
66
84
  <tr>
67
85
  <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-plan-dark.webp"><img src="docs/media/phone-plan.webp" alt="A plan page: the idea in one picture, then each section to approve or change"></picture><br><b>Plan</b><br>Approve or change each section, answer each decision, send it all back at once.</td>
@@ -83,10 +101,11 @@ A board that fills up with noise stops being read, so the rules sit at the door.
83
101
 
84
102
  ### One ticket ties a task's cards together
85
103
 
86
- A ticket is one piece of work, with a title and how far along it is, and optionally its priority, its project and whether it is on hold. Tickets nest: an epic has tickets, a ticket has sub-tasks, and each is as far along as what is under it. The **Tickets** tab shows your asks (and the system's own work) done, in progress, on hold and to do. Post any card with `--ticket <id>` (an ask, a design, proof, a report or a plan) and its screen shows a **Ticket** row (on an ask, **Its ask** in the panel under the question) with the ticket's title and progress; tap it for the ticket, which lists the tickets under it and every card on it, newest first, with their Activity. It is optional and only a link: a card without one, or naming a ticket the board has not got, works exactly as before.
104
+ A ticket is one piece of work, with a title and how far along it is, and optionally its priority, its project, its release, its state and its pull requests. Tickets nest as **epics > tickets > tasks**, and each is as far along as the average of what is under it (a roll-up from the tasks up). The **Tickets** tab shows your asks (and the system's own work) done, in progress, on hold and to do; a ticket's page shows its release, its state, each linked pull request and every card on it. Post any card with `--ticket <id>` (an ask, a design, proof, a report or a plan) and its screen shows a **Ticket** row (on an ask, **Its ask** in the panel under the question) with the ticket's title and progress; tap it for the ticket, which lists the tickets under it and every card on it, newest first, with their Activity. It is optional and only a link: a card without one, or naming a ticket the board has not got, works exactly as before.
87
105
 
88
106
  ```sh
89
- sign-off-axi ticket --id T-29 --title "Ship the new settings page" --progress 40
107
+ sign-off-axi ticket --id E-1 --title "Settings, start to finish" --release 0.6.5
108
+ sign-off-axi ticket --id T-29 --title "Ship the new settings page" --parent E-1 --progress 40 --state "in progress" --pr https://github.com/owner/repo/pull/241
90
109
  sign-off-axi post --type evidence --ticket T-29 --title "Settings on a phone" --about "Umer changes the theme in one tap." --media shot.png
91
110
  ```
92
111
 
@@ -117,13 +136,7 @@ Both kinds wait under **Taken without you** in Decide until you open them, each
117
136
 
118
137
  Some asks are never answered for you, whatever the leash: an ask raised with `--guard destructive|irreversible|money|credential|security`, and an ask whose words talk of spending money, deleting, what can't be undone, passwords and keys, or access. Designs, plans and sign-ins always wait too. Such an ask says **Always waits for you** and why.
119
138
 
120
- <table>
121
- <tr>
122
- <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-decide-dark.webp"><img src="docs/media/phone-leash-decide.webp" alt="Decide: the asks that wait, then Taken without you with a default taken and an agent's pick, each with Change, and the Leash chip by the count"></picture><br><b>Taken without you</b><br>What the leash answered, with Change.</td>
123
- <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-agent-dark.webp"><img src="docs/media/phone-leash-agent.webp" alt="An ask the agent picked: Agent picked B: Friday, tell me if wrong, its reason, its trail from Picked to Read, and Change"></picture><br><b>Agent picked</b><br>On Act, then tell me: its pick, and Change.</td>
124
- <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-guard-dark.webp"><img src="docs/media/phone-leash-guard.webp" alt="An ask that spends money: Always waits for you, so no default or agent pick is ever taken"></picture><br><b>Always waits for you</b><br>Money, deletes, keys and access.</td>
125
- </tr>
126
- </table>
139
+ <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-decide-dark.webp"><img src="docs/media/desk-decide.webp" alt="Decide on a desk: the Leash chip by the count, the asks that wait, Taken without you, and a money ask that always waits for you"></picture></p>
127
140
 
128
141
  ## What you can ask
129
142
 
@@ -138,6 +151,7 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
138
151
  | 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) |
139
152
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
140
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 |
141
155
  | 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 |
142
156
  | 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 |
143
157
  | Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ 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
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';
18
- import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
18
+ import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, galleryFrames, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
19
19
  import { faults, isStill } from './evidence-text.js';
20
20
  import { copiesOf, differs } from './images.js';
21
21
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
@@ -380,7 +380,7 @@ 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]
383
+ report: `usage: sign-off-axi report init <folder> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page] [--gallery]
384
384
  Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
385
385
  Every report names what it is for in "purpose", its kind: research (research and discovery), plan (implementation plan), decision (decision and recommendation),
386
386
  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
@@ -391,6 +391,7 @@ The board draws it as one scroll: what this is (the answer and one picture), wha
391
391
  Numbers sit behind a Details tap; every part has a Go deeper button that asks you (see \`sign-off-axi deeper --help\`) and a read receipt that shows when the person read it.
392
392
  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
393
  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
+ --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.
394
395
  --page writes the older scaffold page instead, for a report that needs a page of its own (report schema 2). It is:
395
396
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
396
397
  The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
@@ -2580,12 +2581,14 @@ const kindSections = (purpose) => REPORT_PARTS[purpose].map(([part, name]) => {
2580
2581
  function report(args) {
2581
2582
  let parsed;
2582
2583
  try {
2583
- parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' } } });
2584
+ parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' } } });
2584
2585
  }
2585
2586
  catch (e) {
2586
2587
  throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
2587
2588
  }
2588
- const [sub, dir, extra] = parsed.positionals, { plan, page, purpose } = parsed.values;
2589
+ const [sub, dir, extra] = parsed.positionals, { plan, page, purpose, gallery } = parsed.values;
2590
+ if (gallery && (plan || page))
2591
+ 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');
2589
2592
  if (purpose !== undefined && (plan || page))
2590
2593
  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');
2591
2594
  if (purpose !== undefined && !REPORT_PURPOSES.includes(purpose))
@@ -2606,13 +2609,20 @@ function report(args) {
2606
2609
  const template = readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed));
2607
2610
  // With --purpose the template holds that kind's parts, one section each; with none, the general shape and purpose to fill.
2608
2611
  const kinded = purpose && { ...JSON.parse(template), purpose, ...(REPORT_PARTS[purpose].length && { sections: kindSections(purpose) }) };
2609
- writeFileSync(path, kinded ? `${JSON.stringify(kinded, null, 2)}\n` : template, { flag: 'wx' });
2612
+ // With --gallery the first section also holds a gallery to fill: one option, its phone and desktop frame.
2613
+ const doc = gallery ? { ...(kinded || JSON.parse(template)) } : kinded;
2614
+ if (gallery)
2615
+ doc.sections = doc.sections.map((sec, n) => n ? sec : { ...sec, gallery: [{ label: 'FILL: Option A', model: 'FILL: the model that made it', ran: 'FILL: UTC time it ran', frames: [
2616
+ { device: 'phone', src: 'FILL: path from the board file\'s folder, such as gallery/a-phone.png, 390 wide', caption: 'FILL: what the phone frame shows' }, { device: 'desktop', src: 'FILL: path from the board file\'s folder, such as gallery/a-desktop.png, 1280 wide', caption: 'FILL: what the desktop frame shows' }
2617
+ ] }] });
2618
+ writeFileSync(path, doc ? `${JSON.stringify(doc, null, 2)}\n` : template, { flag: 'wx' });
2610
2619
  created.push(shellPath(path));
2611
2620
  }
2612
2621
  return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2613
2622
  ...(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)`]),
2614
2623
  `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' : ''}`,
2615
2624
  `${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)`,
2625
+ ...(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'] : []),
2616
2626
  'Keep each line short: the board shows it in one scroll, the numbers behind a Details tap and the long answer behind Go deeper',
2617
2627
  `Run \`sign-off-axi post --kind ${kind} --from ${shellPath(path)}\` to put it on the board`,
2618
2628
  ]);
@@ -3236,6 +3246,13 @@ const mockTypes = { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'imag
3236
3246
  const attachTypes = { '.apk': 'application/vnd.android.package-archive', '.zip': 'application/zip', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv', '.gz': 'application/gzip' };
3237
3247
  const assetTypes = { ...mediaTypes, ...mockTypes, '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.gif': 'image/gif', '.woff2': 'font/woff2' };
3238
3248
  const MOCK_BYTES = 5_000_000;
3249
+ /** The lighter copy of a gallery frame's image, made once per file and kept for the board's life: the frame shows at most 1280 px wide, so a larger still is sent smaller. */
3250
+ const lights = new Map();
3251
+ const galleryLight = (key, bytes, ext) => {
3252
+ if (!lights.has(key))
3253
+ lights.set(key, copiesOf(bytes, ext).light);
3254
+ return lights.get(key);
3255
+ };
3239
3256
  /** A sandboxed mock frame sends no cookie, so beyond loopback its URLs carry this pass in the path: made from the token, it opens mock folders only. */
3240
3257
  const mockPass = (token) => sha256(Buffer.from(`mock ${token}`));
3241
3258
  /** Every served file is its own sandboxed page, even opened full size: no fetch, no remote files, no frames, and no reach into the dashboard or its tab. */
@@ -3344,7 +3361,9 @@ async function route(req) {
3344
3361
  // A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
3345
3362
  const dgs = d?.guided ? diagramViews(d) : [];
3346
3363
  const diagram = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))] : undefined;
3347
- const mock = diagram ? diagram.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
3364
+ // A gallery frame is served the same way, pinned to the file posted; an image can be asked for as ?v=light, a copy that is no wider than the board shows it.
3365
+ const frame = value?.startsWith('gallery.') && d?.guided ? galleryFrames(d)[Number(value.slice(8))] : undefined;
3366
+ const mock = diagram || frame ? (diagram ?? frame).src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
3348
3367
  try {
3349
3368
  const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
3350
3369
  if (!mock || !type)
@@ -3354,8 +3373,14 @@ async function route(req) {
3354
3373
  return [404, { error: 'Mock is unavailable' }];
3355
3374
  const bytes = readFileSync(actual);
3356
3375
  // A diagram is its one pinned page: the file the plan posted, or nothing. A plan posted before the pin carries none and is served as it was.
3357
- if (diagram?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== diagram.bytes || sha256(bytes) !== diagram.sha256))
3376
+ const pinned = diagram ?? frame;
3377
+ if (pinned?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== pinned.bytes || sha256(bytes) !== pinned.sha256))
3358
3378
  return [404, { error: 'Diagram file changed since it was posted' }];
3379
+ if (frame && new URL(req.url, 'http://localhost').searchParams.get('v') === 'light' && type.startsWith('image/')) {
3380
+ const light = galleryLight(pinned.sha256 ?? sha256(bytes), bytes, extname(path));
3381
+ if (light)
3382
+ return [200, { media: light.bytes, type: mediaTypes[light.ext] }];
3383
+ }
3359
3384
  // Every HTML view gets the bridge: document readers need its size handshake too. A diagram also gets its way back.
3360
3385
  return [200, { media: bytes, type, point: type === 'text/html', ...(diagram && type === 'text/html' && d && { page: diagramPage(`/#${d.type === 'plan' && !reading(d) ? 'waiting' : 'reports'}/${d.key}`, `Back to the ${d.type === 'plan' ? 'plan' : 'report'}`) }) }];
3361
3386
  }
package/dist/contract.js CHANGED
@@ -46,6 +46,9 @@ const ACTIONS = {
46
46
  gate: { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' },
47
47
  finding: { apply: 'Apply', skip: 'Skip' },
48
48
  };
49
+ /** The screens a gallery frame shows, and the files it may be. */
50
+ export const GALLERY_DEVICES = ['phone', 'desktop'];
51
+ const GALLERY_FILE = /\.(png|jpe?g|webp|html?)$/i;
49
52
  export const MEDIA_BYTES = 50_000_000;
50
53
  /** The command that makes a report's page, named in every refusal of a report without one. */
51
54
  export const REPORT_INIT = 'sign-off-axi report init <folder>';
@@ -230,6 +233,32 @@ export function checkMessage(m, want, flags = false, fresh = false) {
230
233
  if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
231
234
  throw bad(`${field}[${n}].src`, `${show(o.src)} must be a relative .html path with no .., ?, # or \\`, 'Draw the diagram as inline SVG in an .html page (by hand or with the archify skill), then set "src" to that file next to the message');
232
235
  });
236
+ /** A gallery: 1 to 6 options of a design output, each with its model, the time it ran and 1 or 2 frames (a phone's, a desktop's), every frame an image or page with the device it shows and a caption. Post pins each frame's file like a diagram's. */
237
+ const gallery = (field, v) => list(field, 1, 6, ['label', 'model', 'ran', 'frames'], '{"label":"Option A","model":"Haiku 5.5","ran":"2026-10-24T09:30:00Z","frames":[{"device":"phone","src":"gallery/a-phone.png","caption":"Sign-in on a phone"}]}', v).forEach((o, n) => {
238
+ const at = `${field}[${n}]`;
239
+ text(`${at}.label`, o.label, 40, 'the option\'s name, such as "Option A", in 40 characters or fewer');
240
+ text(`${at}.model`, o.model, 40, 'the model that made it, such as "Haiku 5.5", in 40 characters or fewer');
241
+ const ran = typeof o.ran === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(o.ran) ? new Date(o.ran) : undefined;
242
+ if (!ran || Number.isNaN(ran.getTime()) || ran.toISOString().replace('.000', '') !== o.ran)
243
+ throw bad(`${at}.ran`, Object.hasOwn(o, 'ran') ? `${show(o.ran)} is not a UTC time such as 2026-10-13T07:19:00Z` : 'is required', 'Set "ran" to when the model made it, exactly YYYY-MM-DDTHH:MM:SSZ in UTC');
244
+ list(`${at}.frames`, 1, 2, ['device', 'src', 'caption', 'sha256', 'bytes'], '{"device":"phone","src":"gallery/a-phone.png","caption":"Sign-in on a phone"}', o.frames).forEach((f, k) => {
245
+ const fa = `${at}.frames[${k}]`, DEVICE = 'Set "device" to "phone" (390 wide) or "desktop" (1280 wide)';
246
+ if (!Object.hasOwn(f, 'device'))
247
+ throw bad(`${fa}.device`, 'is required: the screen the frame shows', DEVICE);
248
+ if (!GALLERY_DEVICES.includes(f.device))
249
+ throw bad(`${fa}.device`, `${show(f.device)} is not one of ${GALLERY_DEVICES.join(', ')}`, DEVICE);
250
+ if (o.frames.slice(0, k).some((p) => p.device === f.device))
251
+ throw bad(`${fa}.device`, `repeats ${f.device}`, 'Give an option one phone frame and one desktop frame at most');
252
+ text(`${fa}.caption`, f.caption, 120, 'what the frame shows, in 120 characters or fewer');
253
+ text(`${fa}.src`, f.src, 256, 'an image or page path from the board file\'s folder, such as "gallery/a-phone.png"');
254
+ if (/^([\\/]|[A-Za-z]:)/.test(f.src) || f.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(f.src) || !GALLERY_FILE.test(f.src))
255
+ throw bad(`${fa}.src`, `${show(f.src)} must be a relative .png, .jpg, .jpeg, .webp or .html path with no .., ?, # or \\`, 'Set "src" to the frame\'s file next to the message, such as "gallery/a-phone.png"');
256
+ if (Object.hasOwn(f, 'sha256') && !/^[a-f0-9]{64}$/.test(f.sha256))
257
+ throw bad(`${fa}.sha256`, 'must be 64 lowercase hex digits', 'Leave "sha256" and "bytes" out; post pins the file');
258
+ if (Object.hasOwn(f, 'bytes') && !(Number.isInteger(f.bytes) && f.bytes >= 0))
259
+ throw bad(`${fa}.bytes`, 'must be a whole number of bytes', 'Leave "sha256" and "bytes" out; post pins the file');
260
+ });
261
+ });
233
262
  /** A report 5's parts: each part of its kind once, in a section naming it, with what that part needs (a source's link, a finding's confidence, a slice's size, a design's diagram). */
234
263
  const kindParts = (parts) => {
235
264
  const init = `Run \`${REPORT_INIT.replace('<folder>', '<new folder>')} --purpose ${m.purpose}\` for a template with every part, or add the section`;
@@ -302,7 +331,7 @@ export function checkMessage(m, want, flags = false, fresh = false) {
302
331
  diagrams('visual.diagram', v.diagram);
303
332
  // A report 5 is built of its kind's parts: up to 10 sections, each naming the part it is, and items that carry a source's link, a finding's confidence or a slice's size.
304
333
  const v5 = m.kind === 'report' && m.schema >= 5, parts = v5 ? REPORT_PARTS[m.purpose] ?? [] : [];
305
- const ids = list('sections', minSections, v5 ? 10 : 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram', ...(v5 ? ['part'] : [])], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
334
+ const ids = list('sections', minSections, v5 ? 10 : 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram', 'gallery', ...(v5 ? ['part'] : [])], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
306
335
  const at = `sections[${n}]`;
307
336
  if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
308
337
  throw bad(`${at}.id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "steps"');
@@ -335,6 +364,8 @@ export function checkMessage(m, want, flags = false, fresh = false) {
335
364
  details(`${at}.details`, o.details);
336
365
  if (Object.hasOwn(o, 'diagram'))
337
366
  diagrams(`${at}.diagram`, o.diagram);
367
+ if (Object.hasOwn(o, 'gallery'))
368
+ gallery(`${at}.gallery`, o.gallery);
338
369
  return o.id;
339
370
  });
340
371
  const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
@@ -817,6 +848,8 @@ export function mediaFiles(m, base) {
817
848
  }
818
849
  /** The diagram views of a guided plan or report, in the order the board counts them for `diagram.<n>`: the picture's, then each section's and its items'. */
819
850
  export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
851
+ /** The gallery frames of a guided report or plan, in the order the board counts them for `gallery.<n>`: each section's options, each option's frames. */
852
+ export const galleryFrames = (m) => (m?.sections ?? []).flatMap((s) => (s.gallery ?? []).flatMap((o) => o.frames));
820
853
  /** The text size a diagram must keep on a phone: its smallest text, drawn as wide as Full size draws it on a 390 px screen (the chrome-free page, 8 px of margin each side). */
821
854
  const DIAGRAM_MIN_PX = 12;
822
855
  const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
@@ -863,6 +896,32 @@ export function diagramPages(m, base) {
863
896
  v.sha256 = createHash('sha256').update(file).digest('hex');
864
897
  v.bytes = file.length;
865
898
  }
899
+ // A gallery frame is an image or a page of 5 MB or less, inside the folder; it is pinned the same way.
900
+ for (const f of galleryFrames(m)) {
901
+ const bad = (rule, help) => new ContractError(`${at}gallery frame ${show(f.src)} ${rule}`, help);
902
+ let real;
903
+ try {
904
+ real = realpathSync(resolve(root, f.src));
905
+ }
906
+ catch {
907
+ throw bad('is not there', `Put the file at ${f.src} from the board file's folder, or fix the path`);
908
+ }
909
+ const rel = relative(root, real);
910
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
911
+ throw bad('leads outside the board file\'s folder', 'Copy the file into the board file\'s folder, such as under gallery/');
912
+ const info = statSync(real);
913
+ if (!info.isFile() || info.size > 5_000_000)
914
+ throw bad('must be a file of 5 MB or less', 'Save the screenshot again at the device\'s own width (390 or 1280)');
915
+ try {
916
+ accessSync(real, constants.R_OK);
917
+ }
918
+ catch {
919
+ throw bad('cannot be read', 'Make the file readable, then post again');
920
+ }
921
+ const file = readFileSync(real);
922
+ f.sha256 = createHash('sha256').update(file).digest('hex');
923
+ f.bytes = file.length;
924
+ }
866
925
  }
867
926
  /** The longest a block of plain text on a report page may run: about five lines on a phone. */
868
927
  const BLOCK_CHARS = 240;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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" },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sign-off-axi
3
- description: "Ask the user yes/no, pick-one or A/B/C design questions on a local review board and read back their answers, using the sign-off-axi CLI. Use when you need a yes/no decision, a choice between a few options, a design pick between mocks, or approval from the user before going on, want the user to review or sign off on a plan or on proof of finished work (screenshots or a recording), need to read back the answers the user already gave, must explain a concept, design or trade-off to the user as an interactive report, or need the user to sign in to a service."
3
+ description: "Ask the user yes/no, pick-one or A/B/C design questions on a local review board and read back their answers, using the sign-off-axi CLI. Use when you need a yes/no decision, a choice between a few options, a design pick between mocks, or approval from the user before going on, want the user to review or sign off on a plan or on proof of finished work (screenshots or a recording), need to read back the answers the user already gave, must explain a concept, design or trade-off to the user as an interactive report, want to track work as tickets the user can follow, or need the user to sign in to a service."
4
4
  license: MIT
5
5
  metadata:
6
6
  author: umeranjum17
@@ -23,7 +23,8 @@ metadata:
23
23
 
24
24
  sign-off-axi puts your questions on one local page (127.0.0.1) where the user answers each with one click, and saves the answers for you to read back.
25
25
  Every question is an item: post it, wait, read the answer back.
26
- The board has four places: Today (what needs the user and what was proven today), Decide (every ask that waits), Evidence (proof, never an ask) and Reports (explainers).
26
+ The board has five places: Today (what needs the user, what was proven today and your replies), Decide (every ask that waits, and Designs to review; Show more on a phone reveals the next rows without changing the counts), Evidence (proof, never an ask), Reports (explainers) and Tickets (the work, as epics, tickets and tasks).
27
+ Every card opens to its own screen with one action bar (the answer, or Agree, Disagree, Comment and Ask on a report), one Activity (who posted it, each answer, note, comment and reply, and how far each got) and Previous and Next through the list it was opened from. The board names its own version (`--version`, the sidebar's foot, Today's More), and it installs as an app: More, then Install app, on HTTPS or localhost.
27
28
 
28
29
  With no board file named, a command uses the nearest `.sign-off/` board that `npx -y sign-off-axi init` set up in the project (looking up to the repo root), else `~/.sign-off/decisions.json`. Name a board file after the command only to use another one. Tests and scratch runs must never reach the user's own board: set `SIGN_OFF_HOME` to a temporary folder.
29
30
  When you start a throwaway server with `open <board>` or foreground `serve <board>`, stop it before finishing with `npx -y sign-off-axi stop <board>` (the same board file). Do not shell-kill the printed PID or leave the server running for a timeout: stop checks that the recorded server serves that board and leaves other boards alone.
@@ -46,7 +47,7 @@ The answer comes to you; nobody has to notice it for you.
46
47
  1. Pick one filer id for yourself: your agent session id, or any short name (1-128 of `A-Z a-z 0-9 . _ -`). With none given, a post saves the Claude Code session id when there is one. The card's Activity shows a short name as who posted it and picked up the answer; a bare session id reads Agent there.
47
48
  2. Post every item with `--filer <id>`, or export `SIGN_OFF_FILER=<id>` once for every command.
48
49
  3. Right after posting an ask, a plan or a guided report (an evidence post never waits), run `npx -y sign-off-axi wait --filer <id>` (same board file as the post). It returns every answer to your items you have not taken yet, even one given before you started waiting, and the board then shows it **Read** on that card. It also returns a **Go deeper** tap on your guided plan or report (answer it as the report section below says). Run it again after each reply and after a plan is sent back; guided pages keep listening even when nothing needs an answer. The person can change an answer later: it comes back as a new answer with `replaces` (the answer it replaces, which you may have acted on); act on the new one in its place. `TIMEOUT` means run it again, `NOT_FOUND` means none of your items wait or supports Go deeper. Only a filer-bound wait takes Go deeper; a board-wide answer wait leaves it queued. This needs a running listener: it does not wake a dormant agent session.
49
- 4. Once you act on an answer, a note, design change notes, a new round or a comment, tell the person: `npx -y sign-off-axi feedback <key> --reply "<what you did, one or two lines>" --link <the pull request or new version>` (or `--acted --link <url>` with no words). The card then reads **Replied** with your words and the link, and the reply waits in their Today. Read it but not acting yet: `feedback <key> --read`. Posting the card again marks what they sent as acted on.
50
+ 4. Once you act on an answer, a note, design change notes, a new round or a comment, tell the person: `npx -y sign-off-axi feedback <key> --reply "<what you did, one or two lines>" --link <the pull request or new version>` (or `--acted --link <url>` with no words). Whatever the person sends on a card (an answer, a note, a new round, a comment) shows its trail: **Sent**, **Delivered** (your wait or `on_answer` hook got it), **Read** and **Replied** with your words and the link; the reply waits in their Today. Read it but not acting yet: `feedback <key> --read`; handed over only: `--delivered`. `--at <epoch seconds>` records a step that happened earlier (a replay): a step the card already shows is left alone, so run it twice safely. Posting the card again marks what they sent as acted on.
50
51
  5. For standalone Go deeper while your Claude Code session is idle, register its **actual** UUID when authoring: run `npx -y sign-off-axi owner <board> --filer <id> --host claude --session <actual UUID> --cwd <absolute project folder>` (optional `--model <model id>`). Use the host-provided session identity, never invent an id or register another session. If the host has not supplied your UUID, say registration is unavailable. A separate local process runs `npx -y sign-off-axi owner <board> --dispatch --timeout 3600`; restart it when it exits. Do not also consume that owner's requests with wait. Only Claude Code is supported; no Firstmate is needed. On wake, inspect the request and original document, acknowledge the exact id with `owner <board> --ack <id>`, then send one final `deeper <board> --id <id> --text "<answer>"` without --more. Retries preserve the request and never append to a completed answer. `owner <board>` shows queued failures separately from delivered and owner-read; never supply a substitute answer. Existing active listeners still support streamed pieces.
51
52
  6. If you cannot block, the board owner can set `on_answer: <command>` in the `config.yaml` beside the board: it runs for each answer with the answer as JSON on its stdin, with no shell (so `|` and `>` do nothing; name a script instead), and `npx -y sign-off-axi doctor` checks it can run.
52
53
 
@@ -56,7 +57,7 @@ The user often reads the board on a phone. Each card must make sense in 10 secon
56
57
 
57
58
  - Title: the result or the question, in one short sentence: "You can now sign in with a passkey", "Ship the new sign-in this week?". Never a tool name, run id, version label, file path or error code.
58
59
  - `--about`: what changed for the user, in 1 or 2 short sentences. Write in ASD-STE100 Simplified Technical English: short sentences, active voice, common words.
59
- - Show the proof on the card: post a result to check as `--type evidence` with a screenshot or recording (`--media`); the user picks Pass, Fail or Needs work. When a picture explains more than words, add a diagram or an interactive page (see below).
60
+ - Show the proof on the card: post a result to check as `--type evidence` with a screenshot or recording (`--media`); the user picks Pass, Fail or Needs work. Evidence rows show the first capture and the check's result (Passed, Failed, Not run) beside the title, and a tap opens the row in place with every capture in a swipeable strip: put the capture that proves the claim first, and write the title as the result. When a picture explains more than words, add a diagram or an interactive page (see below).
60
61
  - Give the user a file to download from the card, such as an Android build to install: add `--attach <file>` (up to 5 files, any type, 1 GB each by default; `SIGN_OFF_ATTACH_MAX_MB` changes the limit). The user taps Download on the card, on a phone or tablet. The file is copied beside the board and streamed, never read into memory; the card needs a `--media` capture or only the file.
61
62
  - Ask for one decision per card, and recommend the answer you would pick (see the question standard below).
62
63
  - Put the pull request or page in `--link`. In a contract evidence message with a `run`, the `outcome` line heads the card: write it as the result the user can see.
@@ -66,7 +67,7 @@ The user often reads the board on a phone. Each card must make sense in 10 secon
66
67
  When the work belongs to a ticket and you know its id, pass `--ticket <id>` on every `raise` and `post` for it (any `--type` or `--kind`, or `"ticket"` in the message or `--from` item). The card then shows a Ticket row, the ticket's title and progress, that opens the ticket with every card on it.
67
68
 
68
69
  - It is optional and only a reference: leave it out when you know no ticket, and never invent an id. A card whose ticket is not on the board shows no row, and works as before.
69
- - A ticket stands alone: `npx -y sign-off-axi ticket --id <id> --title "<the work>" --progress <0-100>` makes it (`--parent <id>` puts it under another: an epic's tickets, a ticket's sub-tasks), and `--progress` again as the work moves. A ticket with tickets under it takes their average, so move the progress of the ones with nothing under them. Add `--priority P0-P3` and `--project <name>` when you know them, `--hold yes` while the work waits (`--hold no` when it moves again), and `--asked yes` when the user asked for the work. Write only your own tickets. `ticket --id <id>` shows a ticket and its cards; `ticket` lists them all.
70
+ - A ticket stands alone: `npx -y sign-off-axi ticket --id <id> --title "<the work>" --progress <0-100>` makes it, and `--progress` again as the work moves. Tickets nest as epics > tickets > tasks: `--parent <id>` puts one under another, and one with others under it is as far along as their average, so move the progress of the ones with nothing under them. Add `--priority P0-P3` and `--project <name>` when you know them, `--hold yes` while the work waits (`--hold no` when it moves again), `--asked yes` when the user asked for the work, `--release <label>` for the release it ships in, `--state queued|"in progress"|"on hold"|done` to set where it is listed (it wins over progress and hold) and `--pr <url>` (repeat or comma-separate) for its pull requests: the ticket's page shows the release, the state and each linked PR, and `--pr` replaces the list, so send them all. An empty value clears a field. Write only your own tickets. `ticket --id <id>` shows a ticket and its cards; `ticket` lists them all.
70
71
 
71
72
  ## Say how urgent a card is, and give your project its icon
72
73
 
@@ -86,7 +87,7 @@ sign-off refuses a question that breaks these rules, naming the field and one fi
86
87
  - `--recommend` the answer you would pick, with `--recommend-reason` in one line.
87
88
  - `--default`: what happens if the user stays quiet, and `--silent-by <ISO UTC time>` when. The user's leash decides what happens then: on When it matters (the default) the open board takes `--default` at `--silent-by`; on Act, then tell me your `raise` takes your `--recommend` at once and says `picked <value>`; on Ask me first nothing is taken. An answer taken this way reaches your `wait` with `taken: default` or `taken: agent`: go ahead with it, and keep waiting, because the user can still change it (the change comes with `replaces`). Never pick for the user yourself.
88
89
  - `--guard destructive|irreversible|money|credential|security` on an ask that deletes or overwrites, can't be undone, spends money, touches a password or key, or changes who can get in: no default or pick is ever taken for it, whatever the leash. The board also guards an ask whose words say so, but name the guard whenever it applies.
89
- - A card you posted by mistake: `npx -y sign-off-axi dismiss <key>` hides it, `restore <key>` brings it back. When the user discards a card (Not relevant, Replaced by newer or Done elsewhere), your `wait --filer` hands it over 10 seconds later under `discarded` with the reason (an `--events` line `"event":"discarded"`): stop the work it asked for. `answers` lists it under `discarded` too.
90
+ - A card you posted by mistake: `npx -y sign-off-axi dismiss <key>` hides it, `restore <key>` brings it back. When the user taps **Discard** on a card and picks a reason (Not relevant, Replaced by newer or Done elsewhere), it moves to **Archived**; **Undo** brings it back for 10 seconds, and then your `wait --filer` hands it over under `discarded` with the reason (an `--events` line `"event":"discarded"`): stop the work it asked for. `answers` lists it under `discarded` too. To discard one yourself with the same effect: `dismiss <key> --reason not-relevant|replaced|done-elsewhere`.
90
91
  - When your task ends (merged, dropped, or answered some other way), run `npx -y sign-off-axi dismiss --filer <id>`: every card you posted that still waits leaves Decide. Reports leave the main view by themselves a day after they were filed; they wait under Archived on their screen and `restore <key>` brings one back. Evidence stays on the Evidence screen until dismissed, and posting it again under the same key with a changed proof replaces the card. Nothing is deleted.
91
92
 
92
93
  A good yes/no question:
@@ -113,11 +114,11 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
113
114
 
114
115
  - Evidence: proof that a thing works, screens or a clip of the real product (`--type evidence`). It lives in Evidence and is never an ask, so never post proof as a question; a text-only or placeholder picture is refused.
115
116
  - Plan: what you mean to do, for the user to approve before you start (`post --kind plan --from plan.json`; `post --help` and the contract in the package docs give the shape). The user approves or changes each section, answers each decision and sends it all back once: wait for it like an ask and read it with `answers --json` (a note per section, a value per decision).
116
- - Design review: `post --kind design` (schema 2) goes under Designs for Main to review, never to the user's Decide; Main promotes the card to Decide. Never run `promote` yourself. A design card is one plain question the reader answers with Yes or Change:
117
+ - Design review: `post --kind design` (schema 2) goes under Designs (on a phone, Decide's second list) for the person who triages designs to review, never straight to the user's Decide; that person's `promote <key>` puts the card in Decide. Never run `promote` yourself. A design card is one plain question the reader answers with Yes or Change:
117
118
  - The title is that question, in 12 words or fewer, such as "Approve this new phone end-of-list for Reports?". Never a task or branch name, a PR, a slice, an agent's message id or a file name, there or in `about`, a screen title or a caption: post refuses them.
118
119
  - One screen per state that changed (8 at most; not the same state at three widths), each with its own picture per frame: `today` (what ships now), `built` or `proposed` (the new look) and `target` (the approved design) only when it differs. Drop a frame that looks like another: post refuses two frames that are under 0.1% apart. Never one picture holding several columns: the board labels and lines up the frames itself.
119
120
  - On every frame, outline what changed with `area` (fractions of the picture) and say it in a one-line `caption`. Capture the state that shows the change: a fade or a moved line that does not show at rest needs the scrolled state.
120
- - `accepts` names the frame Yes approves. Main's verdict goes in `reviewed`. `post --help` gives the shape, and the package's fixtures/contract/design.v2 has examples.
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.
121
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.
122
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.
123
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.
@@ -138,8 +139,11 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
138
139
  - New guided defaults are plan schema 3 and report schema 5 (contract 1), with your required filer. Older schemas still load. On a plan every part, and on a report its one bar for the part in view, offers **Ask a question**, **Go deeper**, **Simplify this**, **Show an example**, **Why?**, plus free text and follow-ups. Ask a question focuses the section composer; text and Send are required. Other presets send immediately. Question/custom need nonblank text (at most 500 code points); free text and follow-ups use custom. Answer the exact action and question in its section: explain, give detail, use simpler words, give an example, or explain why. All use one engagement path: version 1 carries action, immutable owner, document revision and target, with an optional selection (authored field, code-point start/end, exact quote): when present, answer about those quoted words in their place in the document. Read these fields with `deeper --json --all`; answer the requested action from the original document, never rewrite text or change plan decisions. Active replies need `--filer <your id>`; registered sessions acknowledge the exact id first. Stale targets and other owners are refused. The answer expands in place and stays New across reload until visibly read. Keep the owner dispatcher/listener alive after reports and sent plans, and after every reply.
139
140
  - The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. A person's Agree, Disagree or Comment on a part reaches the same wait as an event with the part and the words; reply to it with the same `deeper --id <id> --text` and change the page if they are right. Never answer with a long essay: say less, they can ask a follow-up.
140
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
+ - 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.
141
143
  - 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`.
142
144
 
145
+ <!-- MARKER: report-writing chapter and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
146
+
143
147
  ## Follow your report kind's workflow
144
148
 
145
149
  Each kind of report has the parts strong teams write for that kind of document, in order. Give each part one section with `"part": "<id>"` (up to 10 sections); post refuses a report 5 with a part missing, repeated or not of its kind, and names the fix. `report init <folder> --purpose <kind>` writes every part for you to fill. The board draws the parts as the report's strip, filled as the person reads each one.
package/web/index.html CHANGED
@@ -120,11 +120,12 @@
120
120
  .ui-top > .ui-rb { min-height: 0; padding: 0; }
121
121
  .ui-top > .ui-rb .glyph { width: 1.25rem; height: 1.25rem; stroke-width: 1.6; }
122
122
  /* Under the tiles, one quiet line opens More. */
123
- .t-more-line { display: flex; align-items: center; justify-content: center; gap: 0.25rem; width: 100%; min-height: 2.75rem; margin-top: 1rem; padding: 0 1rem; border: 0; border-radius: 1.375rem;
123
+ .t-more-line, .decide-more { display: flex; align-items: center; justify-content: center; gap: 0.25rem; width: 100%; min-height: 2.75rem; margin-top: 1rem; padding: 0 1rem; border: 0; border-radius: 1.375rem;
124
124
  background: none; color: var(--tk-text-tertiary); font: inherit; font-size: var(--tk-fs-meta); line-height: 1.25rem; -webkit-tap-highlight-color: transparent; }
125
125
  .t-more-line > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
126
- .t-more-line .glyph { width: 1rem; height: 1rem; }
127
- .t-more-line:active { background: var(--tk-inset); }
126
+ .t-more-line .glyph, .decide-more .glyph { width: 1rem; height: 1rem; }
127
+ .t-more-line:active, .decide-more:active { background: var(--tk-inset); }
128
+ .decide-more { color: var(--tk-text-secondary); }
128
129
  /* Cards, a run's result mark and its check badge (Decide's answered ask), and the small segmented control (the full screen's sizes). */
129
130
  .card { background: var(--surface); border-radius: var(--r-card); }
130
131
  .card { padding: var(--tk-sp-4); }
@@ -731,6 +732,29 @@
731
732
  .pf-ref { margin-left: 0; }
732
733
  .pf-name { display: inline-flex; } .pf-size .glyph { display: none; } .pf-size button { padding: 0 0.75rem; }
733
734
  }
735
+ /* A gallery of design output: options as a swipeable strip on a phone and a row on a desk, each a card with its phone and desktop frame side by side and a caption under each; a tap opens it full size. */
736
+ .gl-strip { display: flex; gap: 0.75rem; margin: 0.25rem -0.25rem 1rem; padding: 0.25rem 0.25rem 0.5rem; list-style: none; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding: 0 0.25rem; }
737
+ .gl-opt { display: grid; flex: 0 0 min(92%, 26rem); align-content: start; gap: 0.625rem; min-width: 0; padding: 0.75rem; border: 1px solid var(--hair2); border-radius: 1rem; background: var(--tile); scroll-snap-align: start; }
738
+ @media (min-width: 43.75rem) { .gl-opt { flex: 1 1 21rem; min-width: 21rem; max-width: 30rem; } }
739
+ .gl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
740
+ .gl-name { min-width: 0; overflow: hidden; font-size: 1rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
741
+ .gl-n { flex: none; color: var(--muted); font-size: 0.8125rem; }
742
+ .gl-pair { display: grid; grid-template-columns: 5fr 7fr; gap: 0.625rem; align-items: start; }
743
+ .gl-pair.one { grid-template-columns: 1fr; } .gl-pair.one .gl-f:has(.phone) { max-width: 55%; }
744
+ .gl-f { min-width: 0; margin: 0; }
745
+ .gl-frame { position: relative; display: block; width: 100%; min-height: 0; aspect-ratio: var(--ar); padding: 0; overflow: hidden; border: 1px solid var(--hair2); border-radius: 0.625rem; background: #fff; cursor: zoom-in; }
746
+ .gl-frame img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; }
747
+ .gl-frame iframe { position: absolute; top: 0; left: 0; width: var(--vw); height: var(--vh); border: 0; background: #fff; transform: scale(var(--s, 0.2)); transform-origin: 0 0; pointer-events: none; }
748
+ .gl-f figcaption { display: grid; gap: 0.125rem; margin-top: 0.375rem; font-size: 0.875rem; line-height: 1.25; }
749
+ .gl-cap { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; color: var(--text); }
750
+ .gl-f small { color: var(--muted); font-size: 0.8125rem; }
751
+ .gl-nav { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 999px; background: var(--sunk); color: var(--text); }
752
+ .gl-nav:disabled { opacity: 0.4; }
753
+ .gl-count { flex: none; min-width: 3.5rem; text-align: center; color: var(--muted); font-size: 0.875rem; font-variant-numeric: tabular-nums; }
754
+ .gl-full .pf-tools { justify-content: flex-start; }
755
+ .gl-full .pf-size { margin-left: auto; }
756
+ .gl-full-cap { display: grid; gap: 0.125rem; margin: 0; padding: 0.625rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); background: var(--card); border-top: 0.5px solid var(--hairline); font-size: 1.0625rem; }
757
+ .gl-full-cap small { color: var(--muted); font-size: 0.9375rem; }
734
758
  /* The asks dismissed from Waiting on you: one muted row that opens to a list, each with Restore. */
735
759
  .gone-asks { margin-top: 0.5rem; } .gone-asks summary { color: var(--muted); }
736
760
  .gone-asks .rows { padding: 0; } .gone-asks li { justify-content: space-between; gap: 0.75rem; min-height: 2.75rem; }
@@ -4397,6 +4421,67 @@
4397
4421
  paint();
4398
4422
  return fig;
4399
4423
  }
4424
+ // ---- galleries: a design output's options, each with its phone and desktop frame. glAll counts the frames in the order the server's ~gallery.<n> route does.
4425
+ const glAll = sections.flatMap((s) => (s.gallery ?? []).flatMap((o) => o.frames));
4426
+ const glUrl = (f, light) => src(`${mockUrl(d.key, `gallery.${glAll.indexOf(f)}`, f.src)}${light ? '?v=light' : ''}`);
4427
+ const DEVICES = { phone: [390, 844, 'Phone'], desktop: [1280, 800, 'Desktop'] };
4428
+ const stamp = (o) => `${o.model} · ${day(o.ran)} ${hhmm(o.ran)}`;
4429
+ const frameView = (f, title, light) => /\.html?$/i.test(f.src) ? el('iframe', { src: glUrl(f), title, sandbox: 'allow-scripts', loading: 'lazy', tabindex: '-1' })
4430
+ : el('img', { src: glUrl(f, light), alt: title, loading: 'lazy', decoding: 'async' });
4431
+ /** The options as a swipeable strip (a row on a desk), each with its frames side by side and a caption under each; a tap opens the sequence full size at that option. */
4432
+ function galleryFig(opts) {
4433
+ const fit = new ResizeObserver((rows) => rows.forEach((r) => r.target.style.setProperty('--s', r.contentRect.width / r.target.dataset.vw)));
4434
+ return E('ul', 'gl-strip', ...opts.map((o, i) => E('li', 'gl-opt', E('div', 'gl-head', E('b', 'gl-name', o.label), E('span', 'gl-n', `${i + 1} of ${opts.length}`)),
4435
+ E('div', `gl-pair${o.frames.length === 1 ? ' one' : ''}`, ...[...o.frames].sort((a, b) => (b.device === 'phone') - (a.device === 'phone')).map((f) => {
4436
+ const [w, h, name] = DEVICES[f.device], page = /\.html?$/i.test(f.src);
4437
+ const b = el('button', { class: `gl-frame ${f.device}`, type: 'button', 'aria-label': `Open ${o.label}, ${name.toLowerCase()}, full size`, 'data-gl': `${d.key}~${glAll.indexOf(f)}`, style: `--ar:${w} / ${h};--vw:${w}px;--vh:${h}px` }, frameView(f, `${o.label}, ${name.toLowerCase()}: ${f.caption}`, true));
4438
+ if (page) { b.dataset.vw = w; fit.observe(b); }
4439
+ b.onclick = () => openGallery(opts, i, f.device, b.dataset.gl);
4440
+ return E('figure', 'gl-f', b, E('figcaption', '', E('span', 'gl-cap', f.caption), E('small', '', stamp(o))));
4441
+ })))));
4442
+ }
4443
+ /** The options full screen, one at a time: Previous and Next (or a swipe, or the arrow keys) move between options, Phone and Desktop switch the frame. `seq` is the order to walk, so another list can plug in. */
4444
+ function openGallery(seq, start, device, from) {
4445
+ let at = start, dev = device;
4446
+ const frameOf = () => seq[at].frames.find((f) => f.device === dev) ?? seq[at].frames[0];
4447
+ const title = el('h2', { id: 'gl-title', tabindex: '-1', autofocus: '', dir: 'auto' }), count = el('span', { class: 'gl-count', 'aria-live': 'polite' }), cap = el('p', { class: 'gl-full-cap' });
4448
+ const close = el('button', { class: 'pf-close', 'aria-label': 'Close full size' }, glyph('x'), 'Close');
4449
+ const prev = el('button', { class: 'gl-nav', 'aria-label': 'Previous option' }, glyph('left')), next = el('button', { class: 'gl-nav', 'aria-label': 'Next option' }, glyph('right'));
4450
+ const sizes = Object.entries(DEVICES).map(([k, [w, , name]]) => el('button', { 'aria-pressed': 'false', 'aria-label': `${name}, ${w} wide`, 'data-size': k }, glyph(k), el('span', { class: 'pf-name', 'aria-hidden': 'true' }, name, el('span', { class: 'pf-w' }, String(w)))));
4451
+ const port = el('div', { class: 'pf-port' }), stage = el('div', { class: 'pf-stage', 'data-mode': 'one' }, el('figure', { class: 'pf-frame pf-main framed' }, port));
4452
+ const dialog = el('dialog', { class: 'proto-full gl-full', 'aria-labelledby': 'gl-title' },
4453
+ el('header', { class: 'pf-bar' }, el('div', { class: 'pf-top' }, close, title), el('div', { class: 'pf-tools' }, prev, count, next, el('div', { class: 'seg pf-size', role: 'group', 'aria-label': 'Size' }, ...sizes))), stage, cap);
4454
+ const lay = () => {
4455
+ const [w, h] = DEVICES[frameOf().device], box = stage.getBoundingClientRect(), pad = 16, scale = Math.min(1, (box.width - pad * 2) / w, (box.height - pad * 2) / h);
4456
+ Object.entries({ '--w': w * scale, '--h': h * scale, '--vw': w, '--vh': h, '--s': scale }).forEach(([k, v]) => port.style.setProperty(k, k === '--s' ? v : `${v}px`));
4457
+ };
4458
+ const paint = () => {
4459
+ const o = seq[at], f = frameOf();
4460
+ dev = f.device;
4461
+ title.textContent = o.label; count.textContent = `${at + 1} of ${seq.length}`;
4462
+ cap.replaceChildren(el('span', {}, f.caption), el('small', {}, stamp(o)));
4463
+ const t = `${o.label}: ${f.caption}`;
4464
+ port.replaceChildren(/\.html?$/i.test(f.src) ? frameView(f, t) : el('div', { class: 'pf-img' }, frameView(f, t)));
4465
+ prev.disabled = at === 0; next.disabled = at === seq.length - 1;
4466
+ sizes.forEach((b) => { b.setAttribute('aria-pressed', String(b.dataset.size === dev)); b.disabled = !o.frames.some((x) => x.device === b.dataset.size); });
4467
+ lay();
4468
+ };
4469
+ const go = (n) => { if (n >= 0 && n < seq.length) { at = n; paint(); } };
4470
+ prev.onclick = () => go(at - 1); next.onclick = () => go(at + 1);
4471
+ sizes.forEach((b) => (b.onclick = () => { dev = b.dataset.size; paint(); }));
4472
+ dialog.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') go(at - 1); else if (e.key === 'ArrowRight') go(at + 1); });
4473
+ let x0, y0;
4474
+ stage.addEventListener('pointerdown', (e) => { x0 = e.clientX; y0 = e.clientY; });
4475
+ stage.addEventListener('pointerup', (e) => { const dx = e.clientX - x0, dy = e.clientY - y0; if (x0 !== undefined && Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) go(at + (dx < 0 ? 1 : -1)); x0 = undefined; });
4476
+ const fit = new ResizeObserver(lay);
4477
+ const shut = () => {
4478
+ if (!dialog.isConnected) return;
4479
+ fit.disconnect(); dialog.close(); dialog.remove();
4480
+ document.querySelector(`[data-gl="${CSS.escape(from)}"]`)?.focus({ preventScroll: true });
4481
+ };
4482
+ close.onclick = shut; dialog.onclose = shut;
4483
+ document.body.append(dialog); dialog.showModal(); fit.observe(stage); paint();
4484
+ }
4400
4485
  function visual(v) {
4401
4486
  let body;
4402
4487
  if (v.type === 'bars') {
@@ -4475,7 +4560,7 @@
4475
4560
  b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); body.hidden = !open; };
4476
4561
  return E('li', '', b, ...srcLink(it), body);
4477
4562
  });
4478
- return card(s.id, s.stage, head(s.heading, s.body, undefined, s.id, 'heading', 'body'), ...(s.diagram ? [diagramFig(s.diagram)] : []), ...(cmp ? [cmpTable(s.items, cmp)] : items.length ? [E('ul', 'gd-rows', ...items)] : []), ...acts(s.id, s.details), rc(s.id));
4563
+ return card(s.id, s.stage, head(s.heading, s.body, undefined, s.id, 'heading', 'body'), ...(s.diagram ? [diagramFig(s.diagram)] : []), ...(s.gallery ? [galleryFig(s.gallery)] : []), ...(cmp ? [cmpTable(s.items, cmp)] : items.length ? [E('ul', 'gd-rows', ...items)] : []), ...acts(s.id, s.details), rc(s.id));
4479
4564
  };
4480
4565
 
4481
4566
  const optBtns = new Map();
@@ -5088,6 +5173,8 @@
5088
5173
  };
5089
5174
  return b;
5090
5175
  }
5176
+ // Phone lists grow only when requested; desk lists keep every row for J/K navigation.
5177
+ const decideLimit = { now: 24, later: 24, done: 24 };
5091
5178
  /** The leash as a chip by how many wait on Decide: what agents may do without asking. A tap opens the Leash in a sheet to change it. */
5092
5179
  const leashSheet = sheetOf('l-sheet');
5093
5180
  const fillLeash = () => leashSheet.replaceChildren(el('div', {}, sheetHead('l-sheet', 'Leash'), ...leash()));
@@ -5100,13 +5187,18 @@
5100
5187
  /** Decide's list: Newest or By priority, the hero (how many wait on you, how many you answered today, how many are for later, the leash), the asks in one card,
5101
5188
  * then Taken without you, Later and Answered. */
5102
5189
  function decideList(items, hidden, done = [], today = 0) {
5103
- const rows = { now: [], later: [] };
5104
- decideOrder(items).forEach((d, i) => !leaving.has(d.key) || leaving.get(d.key).how === 'later' ? rows[sideOf(d)].push(decideRow(d, i)) : '');
5190
+ const rows = { now: [], later: [] }, totals = { now: 0, later: 0 };
5191
+ decideOrder(items).forEach((d, i) => {
5192
+ if (leaving.has(d.key) && leaving.get(d.key).how !== 'later') return;
5193
+ const group = sideOf(d);
5194
+ if (!phone() || totals[group] < decideLimit[group]) rows[group].push(decideRow(d, i));
5195
+ totals[group]++;
5196
+ });
5105
5197
  for (const [, g] of leaving) {
5106
5198
  const list = rows[g.group], at = list.findIndex((r) => r.dataset.key === g.before && !r.classList.contains('leaving'));
5107
5199
  list.splice(at < 0 ? list.length : at, 0, decideRow(g.d, 0, g));
5108
5200
  }
5109
- const count = (list) => list.filter((r) => !r.classList.contains('leaving')).length, n = count(rows.now), later = count(rows.later);
5201
+ const n = totals.now, later = totals.later;
5110
5202
  const fresh = !risen;
5111
5203
  risen = true;
5112
5204
  const seg = el('div', { class: 'ui-seg', role: 'group', 'aria-label': 'Order' }, ...[['new', 'Newest'], ['priority', 'By priority']].map(([v, name]) => {
@@ -5119,16 +5211,27 @@
5119
5211
  if (leashSheet.open) fillLeash();
5120
5212
  const sec = (name, k) => el('h2', { class: 'ui-sec' }, name, k === undefined ? '' : el('span', { class: 'n' }, String(k)));
5121
5213
  const card = (name, list, bare) => el('ul', { class: `${bare ? '' : 'ui-card '}ui-list dl-list`, 'aria-label': name }, ...list);
5214
+ const more = (group, name, total) => {
5215
+ if (!phone() || total <= decideLimit[group]) return '';
5216
+ const b = el('button', { class: 'decide-more', id: `decide-more-${group}` }, `Show more ${name.toLowerCase()} · ${total - decideLimit[group]} left`, glyph('down'));
5217
+ b.onclick = () => {
5218
+ const before = decideLimit[group];
5219
+ decideLimit[group] += 24;
5220
+ render();
5221
+ document.querySelectorAll(`.decide-list ul[aria-label="${name}"] .dl-face`)[before]?.focus();
5222
+ };
5223
+ return b;
5224
+ };
5122
5225
  // On a phone the asks' head sits over their card; on a desk, where the hero is hidden, the card carries its head and count inside it, as the prototype's list card.
5123
5226
  const desk = deskSel !== null, asks = rows.now.length ? card('Asks', rows.now, desk)
5124
5227
  : el('div', { class: `${desk ? '' : 'ui-card '}dl-clear` }, el('b', {}, 'All clear'), el('span', {}, `Nothing is waiting on you${state.name ? `, ${state.name}` : ''}.`));
5125
5228
  // Answers taken without the person sit under the asks until opened, each with its Change; then they join Answered.
5126
5229
  const took = done.filter((d) => takenOf(state.answers[d.key]) && unopened(d.key)), given = done.filter((d) => !took.includes(d));
5127
- return el('div', { class: `waiting-page decide-list${fresh ? ' rise' : ''}` }, phone() ? decideSeg('asks') : seg, hero, ...(desk ? [el('section', { class: 'ui-card dl-asks' }, sec('Asks', n), asks)] : [sec('Asks'), asks]),
5230
+ return el('div', { class: `waiting-page decide-list${fresh ? ' rise' : ''}` }, phone() ? decideSeg('asks') : seg, hero, ...(desk ? [el('section', { class: 'ui-card dl-asks' }, sec('Asks', n), asks)] : [sec('Asks'), asks, more('now', 'Asks', n)]),
5128
5231
  ...(took.length ? desk ? [el('section', { class: 'ui-card dl-asks' }, sec('Taken without you', took.length), card('Taken without you', took.map(tookRow), true))] : [sec('Taken without you', took.length), card('Taken without you', took.map(tookRow))] : []),
5129
- ...(rows.later.length ? [sec('Later', later), card('Later', rows.later)] : []),
5232
+ ...(rows.later.length ? [sec('Later', later), card('Later', rows.later), more('later', 'Later', later)] : []),
5130
5233
  // Every answer given, newest first.
5131
- ...(given.length ? [sec('Answered', given.length), card('Answered', given.map(doneRow))] : []), items.length ? decideMoreLine(items) : '', archived(hidden));
5234
+ ...(given.length ? [sec('Answered', given.length), card('Answered', given.slice(0, phone() ? decideLimit.done : given.length).map(doneRow)), more('done', 'Answered', given.length)] : []), items.length ? decideMoreLine(items) : '', archived(hidden));
5132
5235
  }
5133
5236
  /** After a redraw, a row on its way out keeps its height for a moment, then folds to nothing; a redraw midway picks up where it was. */
5134
5237
  function fold() {