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 +31 -17
- package/dist/cli.js +32 -7
- package/dist/contract.js +60 -1
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +12 -8
- package/web/index.html +113 -10
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
53
|
-
<td align="center" width="
|
|
54
|
-
<td align="center" width="
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
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
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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) =>
|
|
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
|
|
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() {
|