sign-off-axi 0.4.9 → 0.5.0

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
@@ -1,382 +1,111 @@
1
- <p align="center"><b>▶ <a href="https://umeranjum17.github.io/sign-off/">Try it live</a></b> — the real board on demo data: tap an answer and watch it save. No install, nothing leaves your browser.</p>
2
-
3
- <p align="center">
4
- <img src="docs/media/demo-tap.gif" width="300" alt="Tap Yes on a demo question, see it saved, and read the saved answer back">
5
- </p>
6
-
7
1
  <p align="center">
8
- <img src="docs/logo.svg" width="72" height="72" alt="sign-off logo">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
4
+ <img src="docs/logo.svg" width="96" height="96" alt="sign-off logo: a white check mark on a blue tile">
5
+ </picture>
9
6
  </p>
10
7
 
11
8
  <h1 align="center">sign-off</h1>
12
9
 
13
- <p align="center"><b>One calm page for every question the programs working for you need answered.</b><br>Yes or no, pick-one, mock-ups, proof photos, short videos, sign-in links: each lands on a board on your own machine, you answer with one tap, and the answer is saved for the asker to read.</p>
10
+ <p align="center"><b>One calm page for every question the programs working for you need answered.</b><br>Agents ask; you answer with one tap on your own machine; the answer is saved for the asker to read. Proof of finished work lands on the same page, so you check it instead of taking anyone's word.</p>
11
+
12
+ <p align="center"><b>▶ <a href="https://umeranjum17.github.io/sign-off/demo/">Try it live</a></b>: the real board on demo data. No install, nothing leaves your browser. · <a href="https://umeranjum17.github.io/sign-off/">Website</a></p>
14
13
 
15
14
  <p align="center">
16
15
  <picture>
17
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/hero-dark.gif">
18
- <img src="docs/media/hero.gif" width="420" alt="Ask, tap, answer: an agent raises a yes/no question, you tap Yes on your phone, the agent reads the saved answer">
16
+ <source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-today-dark.webp">
17
+ <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
18
  </picture>
20
19
  </p>
21
20
 
22
- ## One-minute quick start
23
-
24
- ```sh
25
- npm install -g sign-off-axi
26
- sign-off-axi raise \
27
- --title "Keep the weekly \
28
- digest?" \
29
- --why "The paid plan \
30
- renews Monday." \
31
- --outcome yes="It keeps \
32
- going out." \
33
- --outcome no="It stops \
34
- this week." \
35
- --recommend yes \
36
- --recommend-reason "Readers \
37
- still open it." \
38
- --default yes
39
- sign-off-axi open
40
- ```
41
-
42
- Open the printed URL, click **Yes**, then read the answer back the way an
43
- agent would:
21
+ ## Install in 3 steps
44
22
 
45
23
  ```sh
46
- sign-off-axi answers \
47
- --key keep-the-weekly-digest
24
+ npm install -g sign-off-axi # 1. install from npm
25
+ npx -y sign-off-axi init # 2. in your project: set up .sign-off/ and the agent skill
26
+ sign-off-axi open # 3. start the board and open the printed link
48
27
  ```
49
28
 
50
- That is the whole loop: the agent raises, you tap, the agent reads.
51
- Every question says why it is asked, what each answer does, which one the
52
- agent recommends and why, and what happens if you stay quiet; a question
53
- that does not is refused before it reaches you (see
54
- [the question standard](docs/contract.md#ask-schema-1)). The
55
- board is the default one (`~/.sign-off/decisions.json`); name a file after
56
- any command to use another board.
57
-
58
- ## What you can ask
59
-
60
- One line on what the agent runs, one screenshot of what you see. The full
61
- reference for each kind lives in [docs/question-kinds.md](docs/question-kinds.md);
62
- the message rules are in [docs/contract.md](docs/contract.md).
63
-
64
- ### Yes or no
65
-
66
- ```sh
67
- sign-off-axi raise \
68
- --title "Keep the weekly \
69
- digest?" \
70
- --about "Open rate held \
71
- steady at about a third \
72
- for two months." \
73
- --why "The paid plan \
74
- renews Monday." \
75
- --outcome yes="It keeps \
76
- going out each Monday." \
77
- --outcome no="It stops \
78
- after this week." \
79
- --recommend yes \
80
- --recommend-reason "Readers \
81
- still open it." \
82
- --default yes \
83
- --silent-by 2026-10-19T09:00Z
84
- ```
85
-
86
- <picture>
87
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-yes-no-dark.webp">
88
- <img src="docs/media/kind-yes-no.webp" alt="A yes/no question with the recommended Yes marked">
89
- </picture>
90
-
91
- *A yes/no ask. The recommended answer is marked.*
92
-
93
- ### Pick one, with a recommendation
29
+ Then ask your first question, the way an agent would, and answer it on the board:
94
30
 
95
31
  ```sh
96
32
  sign-off-axi raise \
97
- --title "When should \
98
- the new settings \
99
- page go out?" \
100
- --about "No new \
101
- bug reports for three \
102
- weeks." \
103
- --why "Support is fully \
104
- staffed on Friday." \
105
- --option today=Today \
106
- --option friday=Friday \
107
- --option next-week="Next \
108
- week" \
109
- --outcome today="It goes \
110
- out now." \
111
- --outcome friday="It goes \
112
- out with support on hand." \
113
- --outcome next-week="It \
114
- waits a week." \
115
- --recommend friday \
116
- --recommend-reason "Support \
117
- can answer questions." \
118
- --default next-week
119
- ```
120
-
121
- <picture>
122
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-pick-one-dark.webp">
123
- <img src="docs/media/kind-pick-one.webp" alt="A pick-one question with three options and Friday recommended">
124
- </picture>
125
-
126
- *Pick one of three. Friday is recommended; silence means next week.*
127
-
128
- ### Design pick, with mocks A/B/C
129
-
130
- ```sh
131
- sign-off-axi post \
132
- --type design \
133
- --title "Pick the settings \
134
- layout" \
135
- --option a="Layout A" \
136
- --option b="Layout B" \
137
- --option c="Layout C" \
138
- --mock a=mocks/a.html \
139
- --mock b=mocks/b.html \
140
- --mock c=mocks/c.html \
141
- --tradeoff "A is fastest \
142
- to tap; B shows both \
143
- choices; C fits small \
144
- screens." \
145
- --recommend b
146
- ```
147
-
148
- <picture>
149
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-design-dark.webp">
150
- <img src="docs/media/kind-design.webp" alt="Three mock-ups side by side in phone frames with Pick buttons, Layout B recommended">
151
- </picture>
152
-
153
- *Three layouts side by side. Layout B is recommended.*
154
-
155
- ### Proof update, with Pass / Fail / Needs work
156
-
157
- ```sh
158
- sign-off-axi post \
159
- --type evidence \
160
- --title "Settings page \
161
- loads in a second" \
162
- --about "Umer opens \
163
- the new page on a phone." \
164
- --media shot-light.png \
165
- --media shot-dark.png
33
+ --title "Keep the weekly digest?" \
34
+ --why "The paid plan renews Monday." \
35
+ --outcome yes="It keeps going out." --outcome no="It stops this week." \
36
+ --recommend yes --recommend-reason "Readers still open it." --default yes
37
+ sign-off-axi answers --key keep-the-weekly-digest # read the tap back
166
38
  ```
167
39
 
168
- <picture>
169
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-evidence-dark.webp">
170
- <img src="docs/media/kind-evidence.webp" alt="Evidence update 1 with two captures and Pass, Fail, Needs work buttons">
171
- </picture>
40
+ You need Node 22.5 or newer. `init` installs the skill for each coding agent it finds on your `PATH`; `sign-off-axi --help` lists every command, and every command takes `--help`.
172
41
 
173
- *Proof of a fix, with two captures. Pass, Fail, or Needs work.*
42
+ ## The board
174
43
 
175
- Evidence lists every update, never in **Waiting on you**: a passed run shows a tick, and a plain update that links no run, a failed run or an unchecked run says its honest result, each with its verdicts and Dismiss on the update's own screen. **Proven today** counts every update filed today, each with its own result. Updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
44
+ Four places, in the bar at the bottom of a phone and the sidebar on a desk.
176
45
 
177
- ### Explainer video, with chapters
46
+ <table>
47
+ <tr>
48
+ <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>
49
+ <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>
50
+ <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, grouped by project"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
51
+ <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>
52
+ </tr>
53
+ </table>
178
54
 
179
- ```sh
180
- sign-off-axi post \
181
- --type explainer \
182
- --title "Is the new \
183
- sign-in clear?" \
184
- --about "Two steps: \
185
- password, then a code." \
186
- --media explainer.mp4 \
187
- --chapter 0:00=Start \
188
- --chapter 0:02="The tap"
189
- ```
55
+ ### Evidence is not a decision
190
56
 
191
- <picture>
192
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-explainer-dark.webp">
193
- <img src="docs/media/kind-explainer.webp" alt="An explainer card with a video player, chapter buttons and Yes/No">
194
- </picture>
57
+ **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.
195
58
 
196
- *A short video with chapters, then Yes or No.*
59
+ ### Plans and reports, on their own pages
197
60
 
198
- ### Explainer page, to read
61
+ <table>
62
+ <tr>
63
+ <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>
64
+ <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-report-dark.webp"><img src="docs/media/phone-report.webp" alt="A report page: the answer first, then a diagram to tap through"></picture><br><b>Report</b><br>An explainer page: the answer first, then a diagram and steps to tap through.</td>
65
+ <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-signin-dark.webp"><img src="docs/media/phone-signin.webp" alt="A sign-in card: the service, what it unlocks, who waits and the time left"></picture><br><b>Sign-in</b><br>An agent needs you to log in: tap Open, then it carries on.</td>
66
+ </tr>
67
+ </table>
199
68
 
200
- ```sh
201
- sign-off-axi report init \
202
- .sign-off/mocks/sign-in
203
- d=mocks/sign-in
204
- sign-off-axi post \
205
- --type report \
206
- --title "Sign-in now \
207
- takes 2 steps" \
208
- --about "Why the code \
209
- step moved first." \
210
- --view $d/index.html \
211
- --preview $d/preview.svg
212
- ```
69
+ A plan is what an agent wants to do before it starts. A report is an explanation you asked for. Both open over the board, and the board, not the agent's page, owns the answer buttons. A **sign-in card** goes to the top of Decide with the service, what it unlocks, who is waiting and the time left on the link; the agent closes it with `sign-off-axi signin done`.
213
70
 
214
- <picture>
215
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-report-dark.webp">
216
- <img src="docs/media/kind-report.webp" alt="An explainer page: the answer in one sentence, a diagram, steps to tap through, folded details">
217
- </picture>
218
-
219
- *An explainer page to tap through and read.*
220
-
221
- ### Diagram
222
-
223
- ```sh
224
- sign-off-axi post \
225
- --type diagram \
226
- --title "Is this the \
227
- sign-in flow?" \
228
- --about "Password first, \
229
- then a code, then in." \
230
- --view flow.svg \
231
- --recommend yes
232
- ```
71
+ ### It keeps only what matters
233
72
 
234
- <picture>
235
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-diagram-dark.webp">
236
- <img src="docs/media/kind-diagram.webp" alt="A question shown with a three-box flow diagram at card width">
237
- </picture>
73
+ A board that fills up with noise stops being read, so the rules sit at the door.
238
74
 
239
- *A question drawn as a three-box flow.*
75
+ - A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
76
+ - One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
77
+ - Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
78
+ - **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell).
240
79
 
241
- ### Interactive page
242
-
243
- ```sh
244
- sign-off-axi post \
245
- --type interactive \
246
- --title "How many \
247
- testers approved?" \
248
- --about "Tap the counter \
249
- to add yourself." \
250
- --view counter.html \
251
- --option yes=Yes \
252
- --option no="Needs \
253
- work" \
254
- --recommend yes
255
- ```
256
-
257
- <picture>
258
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-interactive-dark.webp">
259
- <img src="docs/media/kind-interactive.webp" alt="A question with a sandboxed counter page to tap">
260
- </picture>
261
-
262
- *A live counter to tap, in a sandboxed page.*
263
-
264
- ### Sign-in link
265
-
266
- ```sh
267
- sign-off-axi post \
268
- --type signin \
269
- --service "npm" \
270
- --link \
271
- https://example.com/abc123 \
272
- --unlocks "Publish \
273
- the release" \
274
- --waits "Umer" \
275
- --expires 15m \
276
- --key npm-login
277
- ```
278
-
279
- Later, when it worked:
280
-
281
- ```sh
282
- sign-off-axi signin done \
283
- --key npm-login
284
- ```
285
-
286
- To hide a card as the Dismiss button does, or bring it back: `sign-off-axi dismiss <key>` and `sign-off-axi restore <key>`.
287
-
288
- <picture>
289
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-signin-dark.webp">
290
- <img src="docs/media/kind-signin.webp" alt="A sign-in card on top with an Open button, what it unlocks, who waits and the time left">
291
- </picture>
292
-
293
- *A sign-in card: what it unlocks, who waits, the time left.*
294
-
295
- ### Release gate
296
-
297
- ```sh
298
- sign-off-axi post \
299
- --kind gate \
300
- --from gate.json
301
- ```
302
-
303
- A `gate.json` holds a run for the user: Approve or Fix first.
304
-
305
- <picture>
306
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-gate-dark.webp">
307
- <img src="docs/media/kind-gate.webp" alt="A gate question with Approve recommended and Fix first">
308
- </picture>
309
-
310
- *A held run: Approve it or Fix first.*
311
-
312
- ### Retro finding
313
-
314
- ```sh
315
- sign-off-axi post \
316
- --kind finding \
317
- --from finding.json
318
- ```
319
-
320
- A `finding.json` asks the user to Apply or Skip a proposed fix.
321
-
322
- <picture>
323
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-finding-dark.webp">
324
- <img src="docs/media/kind-finding.webp" alt="A finding question with Apply recommended and Skip">
325
- </picture>
326
-
327
- *A retro fix to Apply or Skip.*
328
-
329
- ## On your phone
330
-
331
- The same board on a phone, light and dark.
332
-
333
- <picture>
334
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-dark.webp">
335
- <img src="docs/media/phone-light.webp" width="320" alt="Board summary on a phone in light mode">
336
- </picture>
337
-
338
- *The summary, the waiting list, and the answers, in a phone frame.*
339
-
340
- ## On your screen
341
-
342
- The waiting list and the summary at desk width, light and dark.
80
+ ## What you can ask
343
81
 
344
- <picture>
345
- <source media="(prefers-color-scheme: dark)" srcset="docs/media/desktop-dark.webp">
346
- <img src="docs/media/desktop-light.webp" alt="Board summary at desk width in light mode">
347
- </picture>
82
+ One line on what the agent runs per kind; the full reference is [docs/question-kinds.md](docs/question-kinds.md) and the message rules are in [docs/contract.md](docs/contract.md).
348
83
 
349
- *Everything in one glance: waiting, evidence, explainers, answered.*
84
+ | Kind | The agent runs | You see |
85
+ |---|---|---|
86
+ | Yes or no, pick one | `raise --title ... --recommend ...` | buttons, the recommended one first |
87
+ | Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part |
88
+ | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
89
+ | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
90
+ | Report page | `report init <folder>`, then `post --type report --view ...` | a page to tap through |
91
+ | Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
92
+ | Plan | `post --kind plan --from plan.json` | sections to approve or change, decisions to answer |
93
+ | Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
350
94
 
351
95
  ## How agents use it
352
96
 
353
- Every item records who filed it, and that agent gets the answer as an
354
- event, by itself: the agent raises with `--filer my-agent` and blocks on
355
- `sign-off-axi wait --filer my-agent`, which returns only that filer's
356
- answers, each once, and the card shows **Seen by agent**. An agent that
357
- cannot block can be woken instead: an `on_answer:` line in the
358
- `config.yaml` beside the board names a command the board runs for every
359
- answer. A `notify:` block in that config can send an HTTP POST when a card
360
- enters Waiting on you, with its bearer token read from a named environment
361
- variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
362
- file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
363
- [docs/commands.md](docs/commands.md).
364
-
365
- Every command takes `--help` and prints compact output with next-step hints.
366
- sign-off itself calls no model and inherits the calling agent's model routing.
97
+ Every item records who filed it. The agent raises with `--filer my-agent`, then blocks on `sign-off-axi wait --filer my-agent`, which returns that filer's answers, each once, and the card shows **Seen by agent**. An agent that cannot block can be woken instead: an `on_answer:` line in the `config.yaml` beside the board names a command the board runs for every answer, and a `notify:` block can send an HTTP POST when a card enters Decide. The worked examples are in [docs/commands.md](docs/commands.md).
367
98
 
368
- ## Install
99
+ sign-off itself calls no model and inherits the calling agent's model routing. The shipped skill is deliberately thin: it tells the agent how to write a good card and defers to `sign-off-axi --help` for every flag, so an installed copy never goes stale.
369
100
 
370
- ```sh
371
- npm install -g sign-off-axi
372
- ```
101
+ ## Works alone, or with its siblings
373
102
 
374
- In a project folder, one command sets up `.sign-off/` and installs the
375
- skill for each coding agent on your `PATH`:
103
+ sign-off stands alone: the CLI and the skill need no other tool and no supervisor. They connect through one small written contract ([docs/contract.md](docs/contract.md)):
376
104
 
377
- ```sh
378
- npx -y sign-off-axi init
379
- ```
105
+ - **done-done** (verification) posts its proof as Evidence and holds a run for your Approve or Fix first as a gate.
106
+ - **learn-from-mistakes** (retros) posts each proposed change as a finding for you to Apply or Skip.
107
+ - **first-draft** (design) writes its review links into a design card, shown as real links to open the mock-up.
108
+ - **An agent supervisor**, such as Firstmate, or a lone agent in a terminal: both post the same asks, plans and reports and read the same answers.
380
109
 
381
110
  ## Docs and reference
382
111
 
@@ -397,12 +126,14 @@ npm pack
397
126
  ```
398
127
 
399
128
  - `npm test`: full journeys
400
- - `npm run build:skill`: skill stub
401
- - `npm run build:demo`: demo site (Try it live)
129
+ - `npm run build:skill`: writes the skill from `src/skill.ts`
130
+ - `npm run build:demo`: the website (`site/`) and the demo board at `/demo/`, built into `demo-dist/`
402
131
  - `npm pack`: tarball
403
132
 
404
133
  A release is a merged version-bump PR: CI publishes the new version to npm.
405
134
 
135
+ Change what a user sees or runs, and update this README, the skill (`src/skill.ts`), the website (`site/index.html`) and any screenshot they show in the same PR.
136
+
406
137
  ### Test runs never touch your board
407
138
 
408
139
  With no board named, a bare command uses the nearest `.sign-off/` that
package/dist/cli.js CHANGED
@@ -28,8 +28,8 @@ const AGENTS = {
28
28
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
29
29
  };
30
30
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
31
- commands[20]:
32
- (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, doctor, notify, validate, help
31
+ commands[21]:
32
+ (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
33
33
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
34
34
  flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
35
35
  examples:
@@ -176,7 +176,7 @@ Runs the board in the foreground until Ctrl-C; --port 0 picks a free port, and i
176
176
  examples:
177
177
  sign-off-axi serve --port 0
178
178
  `,
179
- post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
179
+ post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--link <value>=<url>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
180
180
  sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--link <commit or URL>] [--detail <text>] [--filer <id>]
181
181
  sign-off-axi post [board.json] --from <board file> --replace [--filer <id>]
182
182
  sign-off-axi post [board.json] --kind <kind> --from <message.json|-> [--filer <id>]
@@ -197,6 +197,7 @@ Listing --option needs --recommend <value>, on every type: the option you would
197
197
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
198
198
  --type design is a pick between up to 6 options shown side by side, each with an optional board-relative mock file
199
199
  (.html, .svg, .png, .jpg, .jpeg, .webp, 5 MB or less), a --tradeoff line and a --recommend badge.
200
+ One --link <value>=<url> per option (http or https) draws an Open link under that option's mock, outside its sandboxed frame; the option needs a --mock.
200
201
  One .html page can hold every variant: --mock a=mocks/<key>/index.html?variant=a and so on; the page reads ?variant= to show one.
201
202
  An .html mock or view loads the css, scripts, images and fonts beside it by relative name, from its own folder only.
202
203
  After the user picks, answers --key <key> --json holds the pick and note; "rounds" holds an ask for another round.
@@ -218,8 +219,9 @@ and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --ch
218
219
  --view is the page and --preview its picture on the card, beside it, both from the board file's folder; \`sign-off-axi report init <folder>\` makes them.
219
220
  The page must be filled (no FILL: line left), with a viewport tag, dark mode, a picture, something to tap, its detail folded, nothing from the web, and no block over about five lines on a phone.
220
221
  Merges, PR outcomes, receipts and status notes are not reports: post proof of a change as evidence.
222
+ A proof is never an ask: a new ask that offers Pass and Fail is refused. sign-off-axi refile <key> --media <file> re-files an old one as evidence.
221
223
  --type signin asks the user to sign in, at the top of what waits: the service, one Open button for the http(s) --link, what it unlocks,
222
- who waits, and the time left until --expires (an ISO date-time, or 90s, 15m, 2h, 1d from now). After that time the card says the link expired.
224
+ who waits, and the time left until --expires (an ISO date-time, or 90s, 15m, 2h, 1d from now). After that time the card leaves the board on its own.
223
225
  No output prints the link. Post again with the same --key and a new --link or --expires to put a new link on the same card; it waits again.
224
226
  When the sign-in is done, run \`sign-off-axi signin done [board.json] --key <key>\`: the card leaves the open list and shows as done.
225
227
  For a link that expires fast, give --start, the command that prints a new sign-in link, as a JSON list such as '["npm", "login", "--auth-type=web"]'.
@@ -256,6 +258,11 @@ restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list
256
258
  examples:
257
259
  sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
258
260
  sign-off-axi restore ship-the-new-sign-in-screen-this-week
261
+ `,
262
+ refile: `usage: sign-off-axi refile <key> [board.json] --media <file>... [--link <commit or URL>]
263
+ Re-files a proof that was posted as a Pass/Fail ask as an evidence update, which never waits on the user: posts --type evidence with the ask's title and note and your capture, then dismisses the ask.
264
+ examples:
265
+ sign-off-axi refile sign-in-looks-right --media sign-in.png --link https://github.com/acme/app/pull/12
259
266
  `,
260
267
  restore: `usage: sign-off-axi restore <key> [board.json]
261
268
  Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
@@ -808,11 +815,15 @@ const mockFile = (path) => path.split('?')[0];
808
815
  /** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
809
816
  function post(args, cmd = 'post') {
810
817
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
811
- media: { type: 'string', multiple: true }, link: { type: 'string' }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
818
+ media: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
812
819
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
813
820
  const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
814
821
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
815
822
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
823
+ // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
824
+ const optionLinks = values.link ?? [];
825
+ if (values.type !== 'design' && optionLinks.length)
826
+ values.link = optionLinks.at(-1);
816
827
  if (values.kind !== undefined) {
817
828
  const odd = [...given, ...(values.replace ? ['replace'] : [])][0];
818
829
  if (odd)
@@ -844,8 +855,8 @@ function post(args, cmd = 'post') {
844
855
  throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
845
856
  if (type === 'evidence' || type === 'explainer')
846
857
  return postMedia(file, named, on, values, type, filer);
847
- if (values.media !== undefined || values.link !== undefined || values.chapter !== undefined)
848
- throw fail('--media, --link and --chapter need --type evidence or explainer', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
858
+ if (values.media !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
859
+ throw fail('--media, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
849
860
  if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
850
861
  throw fail('--tradeoff and --mock need --type design', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
851
862
  const view = type === 'diagram' || type === 'interactive';
@@ -897,8 +908,12 @@ function post(args, cmd = 'post') {
897
908
  if (info && (!info.isFile() || info.size > MOCK_BYTES)) {
898
909
  throw fail(`--mock ${path} must be a file of 5 MB or less`, 'VALIDATION_ERROR', 'Shrink the mock, or point --mock at the screenshot of the variant');
899
910
  }
900
- return { value: m.slice(0, at), path };
911
+ const value = m.slice(0, at), link = optionLinks.find((l) => l.startsWith(`${value}=`));
912
+ return { value, path, ...(link && { link: link.slice(value.length + 1) }) };
901
913
  });
914
+ const lost = optionLinks.find((l) => !mocks?.some((m) => l.startsWith(`${m.value}=`)));
915
+ if (type === 'design' && lost !== undefined)
916
+ throw fail(`--link ${lost} must be <value>=<url> with a --mock for that value`, 'VALIDATION_ERROR', 'sign-off-axi post --type design --title "<question>" --option a=A --option b=B --mock a=mocks/a.html --link a=https://example.com/a --recommend a');
902
917
  return storeIncoming(file, named, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
903
918
  }
904
919
  const SIGNIN_FIELDS = ['service', 'unlocks', 'waits', 'expires', 'start'];
@@ -999,6 +1014,21 @@ function dismiss(restoring) {
999
1014
  });
1000
1015
  };
1001
1016
  }
1017
+ /** refile <key> [board.json] --media <file>...: an old proof posted as a Pass/Fail ask becomes an evidence update (title and note kept), and the ask is dismissed. */
1018
+ async function refile(args) {
1019
+ const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
1020
+ if (at < 0)
1021
+ throw fail('refile needs a card key', 'VALIDATION_ERROR', 'sign-off-axi refile <key> --media shot.png');
1022
+ const { file, named, on, values } = parse('refile', args.filter((_, i) => i !== at), { media: { type: 'string', multiple: true }, link: { type: 'string' }, filer: { type: 'string' } });
1023
+ const old = board(file).find((x) => x.key === key);
1024
+ if (!old)
1025
+ throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
1026
+ if (!waits(old) || !['pass', 'fail'].every((v) => old.options.some((o) => o.value === v)))
1027
+ throw fail(`${key} is not a waiting Pass/Fail ask`, 'VALIDATION_ERROR', 'refile takes a proof still waiting on the user as a Pass/Fail ask');
1028
+ const posted = await postMedia(file, named, on, { title: old.title, about: old.about?.trim() || old.title, media: values.media, link: values.link ?? old.link }, 'evidence', filerOf(values, true));
1029
+ locked(`${file}.lock`, () => appendDismissed(file, [key], true));
1030
+ return posted;
1031
+ }
1002
1032
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
1003
1033
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
1004
1034
  function replace(file, named, on, from, filer) {
@@ -1406,6 +1436,10 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1406
1436
  return locked(`${file}.lock`, () => {
1407
1437
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
1408
1438
  checkMerges(incoming, [...current.filter((c) => !incoming.some((d) => d.key === c.key)), ...incoming], current);
1439
+ // Proof is evidence: a new ask that offers Pass and Fail would sit in Waiting on you as an ask, so it is refused.
1440
+ const proof = incoming.find((d) => d.type === 'decision' && !d.kind && !current.some((c) => c.key === d.key) && ['pass', 'fail'].every((v) => d.options.some((o) => o.value === v)));
1441
+ if (proof)
1442
+ throw fail(`key ${proof.key} offers Pass and Fail: a proof is not an ask and would wait on you`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --type evidence --title "<what this shows>" --about "<short note>" --media <file>');
1409
1443
  const raised = incoming.map((d, i) => {
1410
1444
  const had = current.find((c) => c.key === d.key);
1411
1445
  // A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
@@ -2218,6 +2252,9 @@ async function exportBoard(args) {
2218
2252
  return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
2219
2253
  }
2220
2254
  const page = readFileSync(new URL('../web/index.html', import.meta.url));
2255
+ /** The board's favicon, home-screen icon and install manifest, served beside the page; see web/icon.svg. */
2256
+ const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2257
+ .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
2221
2258
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2222
2259
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2223
2260
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -2374,10 +2411,22 @@ function under(root, rel) {
2374
2411
  throw new EvidenceError('mock path escapes the board folder');
2375
2412
  return actual;
2376
2413
  }
2377
- function contained(path, missing = 'A picture or recording of the proof is missing.') {
2414
+ /** The folder holding a run's record: --evidence-root, else the app folder done-done keeps it in (~/.done-done/evidence/<app>); '' when no folder holds it. */
2415
+ function rootOf(runId) {
2416
+ if (evidenceRoot)
2417
+ return evidenceRoot;
2418
+ const store = join(homedir(), '.done-done', 'evidence');
2419
+ try {
2420
+ return readdirSync(store).map((app) => join(store, app)).find((dir) => existsSync(join(dir, '.done-done', 'runs', `${runId}.json`))) ?? '';
2421
+ }
2422
+ catch {
2423
+ return '';
2424
+ }
2425
+ }
2426
+ function contained(evidence, path, missing = 'A picture or recording of the proof is missing.') {
2378
2427
  let root, actual;
2379
2428
  try {
2380
- root = realpathSync(evidenceRoot);
2429
+ root = realpathSync(evidence);
2381
2430
  actual = realpathSync(resolve(root, path));
2382
2431
  }
2383
2432
  catch {
@@ -2388,12 +2437,13 @@ function contained(path, missing = 'A picture or recording of the proof is missi
2388
2437
  return actual;
2389
2438
  }
2390
2439
  /** A linked run as the board checked it; the reason a check failed is a plain sentence for the card.
2391
- * With no --evidence-root the board cannot check a run at all, so the card shows no result rather than one that blames the run. */
2440
+ * With no --evidence-root the board looks in done-done's own folder for the app; a run it finds nowhere cannot be checked, so the card shows no result rather than one that blames the run. */
2392
2441
  function evidenceCard(link) {
2393
- if (!evidenceRoot)
2442
+ const root = rootOf(link.run_id);
2443
+ if (!root)
2394
2444
  return null;
2395
2445
  try {
2396
- const recordPath = contained(`.done-done/runs/${link.run_id}.json`, 'The proof for this run is missing.');
2446
+ const recordPath = contained(root, `.done-done/runs/${link.run_id}.json`, 'The proof for this run is missing.');
2397
2447
  if (statSync(recordPath).size > 1_000_000)
2398
2448
  throw new EvidenceError('The proof for this run is too large to read.');
2399
2449
  const run = JSON.parse(readFileSync(recordPath, 'utf8'));
@@ -2408,7 +2458,7 @@ function evidenceCard(link) {
2408
2458
  const type = mediaTypes[extname(f.path ?? '').toLowerCase()];
2409
2459
  if (!type)
2410
2460
  return [];
2411
- const path = contained(f.path);
2461
+ const path = contained(root, f.path);
2412
2462
  if (statSync(path).size > 20_000_000)
2413
2463
  throw new EvidenceError('A picture or recording of the proof is too large.');
2414
2464
  const bytes = readFileSync(path);
@@ -2471,7 +2521,7 @@ async function route(req) {
2471
2521
  const card = evidenceCard(decision.evidence);
2472
2522
  if (card?.status !== 'verified' || !card.media.some((m) => m.path === path))
2473
2523
  return [404, { error: 'Evidence is unavailable' }];
2474
- const actual = contained(path), type = mediaTypes[extname(path).toLowerCase()];
2524
+ const actual = contained(rootOf(runId), path), type = mediaTypes[extname(path).toLowerCase()];
2475
2525
  return [200, { media: readFileSync(actual), type }];
2476
2526
  }
2477
2527
  if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
@@ -2778,6 +2828,9 @@ const server = createServer(async (req, res) => {
2778
2828
  if (given !== null)
2779
2829
  return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
2780
2830
  }
2831
+ const icon = req.method === 'GET' ? ICONS[req.url ?? ''] : undefined;
2832
+ if (icon)
2833
+ return res.writeHead(200, { 'content-type': icon.type, 'cache-control': 'public, max-age=86400' }).end(icon.bytes);
2781
2834
  if (req.method === 'GET' && req.url === '/favicon.ico')
2782
2835
  return res.writeHead(204).end();
2783
2836
  if (req.method === 'GET' && req.url === '/') {
@@ -2817,7 +2870,7 @@ await runAxiCli({
2817
2870
  topLevelHelp: TOP_HELP,
2818
2871
  getCommandHelp: (cmd) => HELP[cmd],
2819
2872
  home: axi('list', list),
2820
- commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2873
+ commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2821
2874
  if (rest.length || (name && !HELP[name]))
2822
2875
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2823
2876
  return name ? HELP[name] : TOP_HELP;
package/dist/store.js CHANGED
@@ -26,6 +26,8 @@ const STORED = {
26
26
  };
27
27
  export const CHAPTERS_MAX = 12;
28
28
  const isText = (v) => typeof v === 'string';
29
+ /** An http(s) link of up to 500 characters with no spaces: an item's `link` (besides a commit hash) and a design option's. */
30
+ const HTTP_LINK = /^https?:\/\/\S{1,500}$/u;
29
31
  export const isKey = (v) => isText(v) && /^[A-Za-z0-9._-]{1,128}$/.test(v);
30
32
  const ONE_OF = { close: ['done', 'release'], area: ['waiting', 'evidence', 'reports', 'answered'] };
31
33
  /** What a step uses or can stop on: an AI model, a store, a gate, a hard fail. */
@@ -135,7 +137,7 @@ export function loadDecisions(path, board) {
135
137
  if (d.type === 'evidence' && !(Number.isInteger(d.number) && d.number >= 1))
136
138
  throw bad('number', 'must be a whole number from 1');
137
139
  const signin = d.type === 'signin';
138
- if (d.link !== undefined && !(isText(d.link) && /^(https?:\/\/\S{1,500}|[a-f0-9]{7,40})$/u.test(d.link)) && !signin)
140
+ if (d.link !== undefined && !(isText(d.link) && (HTTP_LINK.test(d.link) || /^[a-f0-9]{7,40}$/u.test(d.link))) && !signin)
139
141
  throw bad('link', 'must be an http(s) link or a commit hash');
140
142
  // A sign-in is a link to open, what it unlocks and who waits; its answer is the `signin done` its poster saves, so the user gets no buttons.
141
143
  if (signin) {
@@ -205,6 +207,8 @@ export function loadDecisions(path, board) {
205
207
  || !d.mocks.every((m) => isKey(m?.value) && isText(m?.path) && m.path.trim() && m.path.length <= 256 && /^[^?#]+(\.html\?variant=[A-Za-z0-9._-]{1,64})?$/i.test(m.path))) {
206
208
  throw bad('mocks', 'must list up to 6 items, each an option value and a board-relative mock path (an .html page may end in ?variant=<name>)');
207
209
  }
210
+ if (!d.mocks.every((m) => m.link === undefined || (isText(m.link) && HTTP_LINK.test(m.link))))
211
+ throw bad('mocks', 'a link must be an http(s) link with no spaces');
208
212
  const targets = d.mocks.map((m) => m.value);
209
213
  if (new Set(targets).size < targets.length)
210
214
  throw bad('mocks', 'option values must be unique');
@@ -216,7 +220,7 @@ export function loadDecisions(path, board) {
216
220
  key: d.key, title: d.title, about: d.about, detail: d.detail, options: options.map((o) => ({ value: o.value, label: o.label, ...(o.outcome && { outcome: o.outcome }) })),
217
221
  ...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }), ...(d.proof && { proof: d.proof }),
218
222
  recommend_value: d.recommend_value, default_if_silent: d.default_if_silent, close: d.close ?? 'done', evidence: d.evidence,
219
- type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path })),
223
+ type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path, ...(m.link !== undefined && { link: m.link }) })),
220
224
  ...(views && { view: d.view }), ...(report && views && { preview: d.preview }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
221
225
  ...(signin && { unlocks: d.unlocks, waits: d.waits, ...(d.expires && { expires: d.expires }), ...(d.start && { start: [...d.start] }) }),
222
226
  ...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
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" },
7
7
  "files": ["dist", "!dist/skill.js", "web", "fixtures/contract"],
8
8
  "engines": { "node": ">=22.5" },
9
9
  "license": "MIT",
10
+ "homepage": "https://umeranjum17.github.io/sign-off/",
10
11
  "repository": { "type": "git", "url": "git+https://github.com/umeranjum17/sign-off.git" },
11
12
  "keywords": ["axi", "agent", "cli", "toon", "decisions", "review"],
12
13
  "scripts": {
Binary file
Binary file
Binary file
Binary file
package/web/icon.svg ADDED
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
2
+ <defs>
3
+ <linearGradient id="l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6A93FF"/><stop offset="1" stop-color="#2C48D3"/></linearGradient>
4
+ <linearGradient id="d" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2C2C2E"/><stop offset="1" stop-color="#0B0B0C"/></linearGradient>
5
+ <linearGradient id="h" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".28"/><stop offset=".35" stop-color="#fff" stop-opacity="0"/></linearGradient>
6
+ </defs>
7
+ <path d="M1024,512L1024,646L1024,688L1023,719L1022,745L1021,766L1019,785L1018,802L1016,818L1014,832L1011,845L1009,858L1006,869L1003,880L999,890L995,900L991,909L987,918L982,926L977,934L972,941L967,948L961,955L955,961L948,967L941,972L934,977L926,982L918,987L909,991L900,995L890,999L880,1003L869,1006L858,1009L845,1011L832,1014L818,1016L802,1018L785,1019L766,1021L745,1022L719,1023L688,1024L646,1024L512,1024L378,1024L336,1024L305,1023L279,1022L258,1021L239,1019L222,1018L206,1016L192,1014L179,1011L166,1009L155,1006L144,1003L134,999L124,995L115,991L106,987L98,982L90,977L83,972L76,967L69,961L63,955L57,948L52,941L47,934L42,926L37,918L33,909L29,900L25,890L21,880L18,869L15,858L13,845L10,832L8,818L6,802L5,785L3,766L2,745L1,719L0,688L0,646L0,512L0,378L0,336L1,305L2,279L3,258L5,239L6,222L8,206L10,192L13,179L15,166L18,155L21,144L25,134L29,124L33,115L37,106L42,98L47,90L52,83L57,76L63,69L69,63L76,57L83,52L90,47L98,42L106,37L115,33L124,29L134,25L144,21L155,18L166,15L179,13L192,10L206,8L222,6L239,5L258,3L279,2L305,1L336,0L378,0L512,0L646,0L688,0L719,1L745,2L766,3L785,5L802,6L818,8L832,10L845,13L858,15L869,18L880,21L890,25L900,29L909,33L918,37L926,42L934,47L941,52L948,57L955,63L961,69L967,76L972,83L977,90L982,98L987,106L991,115L995,124L999,134L1003,144L1006,155L1009,166L1011,179L1014,192L1016,206L1018,222L1019,239L1021,258L1022,279L1023,305L1024,336L1024,378Z" fill="url(#l)"/>
8
+ <path d="M1024,512L1024,646L1024,688L1023,719L1022,745L1021,766L1019,785L1018,802L1016,818L1014,832L1011,845L1009,858L1006,869L1003,880L999,890L995,900L991,909L987,918L982,926L977,934L972,941L967,948L961,955L955,961L948,967L941,972L934,977L926,982L918,987L909,991L900,995L890,999L880,1003L869,1006L858,1009L845,1011L832,1014L818,1016L802,1018L785,1019L766,1021L745,1022L719,1023L688,1024L646,1024L512,1024L378,1024L336,1024L305,1023L279,1022L258,1021L239,1019L222,1018L206,1016L192,1014L179,1011L166,1009L155,1006L144,1003L134,999L124,995L115,991L106,987L98,982L90,977L83,972L76,967L69,961L63,955L57,948L52,941L47,934L42,926L37,918L33,909L29,900L25,890L21,880L18,869L15,858L13,845L10,832L8,818L6,802L5,785L3,766L2,745L1,719L0,688L0,646L0,512L0,378L0,336L1,305L2,279L3,258L5,239L6,222L8,206L10,192L13,179L15,166L18,155L21,144L25,134L29,124L33,115L37,106L42,98L47,90L52,83L57,76L63,69L69,63L76,57L83,52L90,47L98,42L106,37L115,33L124,29L134,25L144,21L155,18L166,15L179,13L192,10L206,8L222,6L239,5L258,3L279,2L305,1L336,0L378,0L512,0L646,0L688,0L719,1L745,2L766,3L785,5L802,6L818,8L832,10L845,13L858,15L869,18L880,21L890,25L900,29L909,33L918,37L926,42L934,47L941,52L948,57L955,63L961,69L967,76L972,83L977,90L982,98L987,106L991,115L995,124L999,134L1003,144L1006,155L1009,166L1011,179L1014,192L1016,206L1018,222L1019,239L1021,258L1022,279L1023,305L1024,336L1024,378Z" fill="none" stroke="url(#h)" stroke-width="6" transform="translate(512 512) scale(.994) translate(-512 -512)"/>
9
+ <g fill="none" stroke="#FFFFFF" stroke-width="96" stroke-linecap="round" stroke-linejoin="round"><path d="M281 520 L443 682 L743 342"/></g>
10
+ </svg>
package/web/index.html CHANGED
@@ -4,6 +4,11 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <title>Dashboard · sign-off</title>
7
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
8
+ <link rel="apple-touch-icon" href="apple-touch-icon.png">
9
+ <link rel="manifest" href="manifest.webmanifest">
10
+ <meta name="theme-color" content="#F5F5F7" media="(prefers-color-scheme: light)">
11
+ <meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
7
12
  <style>
8
13
  /* Spacious, Apple grade. A phone reads white tiles on a grey grouped page; a desk reads grey tiles on a white page (the 60rem block swaps them).
9
14
  Dark is its own scale: a black page with #161617 tiles. --accent is the blue for text, links and focus; --action is the blue a button is filled with. */
@@ -553,6 +558,8 @@
553
558
  .choices.many button { flex: 1 1 auto; max-width: 100%; padding: 0.5rem 1rem; }
554
559
  .note label { display: block; color: var(--muted); font-size: 0.875rem; margin-top: 0.5rem; }
555
560
  .tradeoff { margin: 0; max-width: 70ch; }
561
+ .option-link { display: inline-flex; align-items: center; min-height: 2.75rem; font-size: 0.9375rem; color: var(--accent); }
562
+ .fd-links { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0 1rem; font-size: 0.9375rem; color: var(--muted); } .fd-links a { min-height: 2.75rem; display: inline-flex; align-items: center; color: var(--accent); }
556
563
  /* Dismiss: a quiet round x at the corner of a card or the end of a row. */
557
564
  .x { flex: none; display: grid; place-items: center; width: 2.75rem; min-height: 2.75rem; padding: 0; margin: -0.5rem 0 -0.5rem auto; border: 0; border-radius: 50%;
558
565
  background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
@@ -636,7 +643,8 @@
636
643
  grid-auto-columns: min(85%, calc(var(--tall) * 9 / 19.5)); gap: 0.5rem 0.875rem; margin: -0.25rem; padding: 0.25rem 0.25rem 0.5rem;
637
644
  overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding: 0 0.25rem; }
638
645
  .mock { display: grid; grid-row: span 3; grid-template-rows: subgrid; margin: 0; min-width: 0; scroll-snap-align: start; }
639
- .mock > button { grid-row: 3; }
646
+ .mock > button, .mock > .under { grid-row: 3; }
647
+ .mock .under { display: grid; align-content: start; justify-items: start; } .mock .under button { justify-self: stretch; }
640
648
  .mock:has([aria-pressed="true"]) .shot { border-color: var(--accent); }
641
649
  /* A frame draws the mock at about phone width (1.7 times the frame) at that fraction, cropped from the top. */
642
650
  .shot { position: relative; display: block; overflow: hidden; aspect-ratio: 9 / 19.5; background: #fff;
@@ -975,7 +983,7 @@
975
983
  const merged = !saved && !state.dismissed?.includes(d.key) ? (d.merges ?? []).filter((k) => state.decisions.some((x) => x.key === k) && !answerOf(k)).length : 0;
976
984
  if (design) {
977
985
  return el('article', { class: 'row design', id: `item-${d.key}` }, lead,
978
- d.tradeoff ? el('p', { class: 'tradeoff', dir: 'auto' }, el('strong', {}, 'Trade-off '), d.tradeoff) : '',
986
+ firstDraft(d).text ? el('p', { class: 'tradeoff', dir: 'auto' }, el('strong', {}, 'Trade-off '), firstDraft(d).text) : '', firstDraftLinks(d),
979
987
  mockCards(d, ro ? [] : buttons), annotations(d), silent ? el('p', { class: 'silent' }, silent) : '', ro ? '' : other(d, saved, err), err);
980
988
  }
981
989
  // Yes/No rows keep the two fixed buttons they always had; any other choice set wraps; answers that say what they do stack.
@@ -1260,6 +1268,16 @@
1260
1268
  return el('div', { class: 'rung' }, body, el('a', { class: 'meta', ...open, 'aria-label': `Open the ${name} full size, ${d.title}` }, 'Open full size'));
1261
1269
  }
1262
1270
 
1271
+ /** first-draft's `post` ends a design's trade-off with `Open in first-draft: <label> <url> · <label> <url>`: those urls, and the text before them. */
1272
+ const firstDraft = (d) => {
1273
+ const at = (d.tradeoff ?? '').search(/(?:^|\s)Open in first-draft: /), tail = at < 0 ? '' : d.tradeoff.slice(at).replace(/^\s*Open in first-draft: /, '');
1274
+ const links = tail.split(' · ').map((p) => /^(.*?)\s*(https?:\/\/\S+)$/.exec(p.trim())).filter(Boolean).map(([, label, href]) => ({ label: label || href, href }));
1275
+ return links.length ? { text: d.tradeoff.slice(0, at).trim(), links } : { text: d.tradeoff, links: [] };
1276
+ };
1277
+ /** Real links on the card itself: a link inside a mock is blocked by the frame's sandbox. */
1278
+ const firstDraftLinks = (d) => { const { links } = firstDraft(d);
1279
+ return links.length ? el('p', { class: 'fd-links' }, 'Open in first-draft ', ...links.map((l) => el('a', { href: l.href, target: '_blank', rel: 'noopener' }, l.label))) : ''; };
1280
+
1263
1281
  /** One phone frame per option, the recommended one first: its label and Recommended badge, the mock (tap opens it full size) and its Pick button. */
1264
1282
  function mockCards(d, buttons) {
1265
1283
  const rec = (i) => d.options[i].value === d.recommend_value, order = d.options.map((_, i) => i).sort((a, b) => rec(b) - rec(a));
@@ -1274,7 +1292,9 @@
1274
1292
  el('figcaption', {}, el('span', { class: 'label', dir: 'auto' }, o.label), rec(i) ? el('span', { class: 'badge' }, 'Recommended') : ''),
1275
1293
  // A mock file that is gone has a null hash: the option says so in place of an empty frame.
1276
1294
  m && m.hash !== null ? el('a', { class: 'shot', href: url, target: '_blank', rel: 'noopener', 'aria-label': `Open mock ${o.label} full size` }, view)
1277
- : el('p', { class: 'shot none' }, 'No mock for this option'), buttons[i] ?? '');
1295
+ : el('p', { class: 'shot none' }, 'No mock for this option'),
1296
+ // The option's own link sits on the card, where a tap opens a tab: a link inside the sandboxed frame cannot.
1297
+ m?.link ? el('div', { class: 'under' }, el('a', { class: 'option-link', href: m.link, target: '_blank', rel: 'noopener', 'aria-label': `Open ${o.label} live` }, 'Open live'), buttons[i] ?? '') : buttons[i] ?? '');
1278
1298
  }));
1279
1299
  }
1280
1300
 
@@ -1371,6 +1391,8 @@
1371
1391
  const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
1372
1392
  // A design with only screen notes (answer 'screens') still waits for its design-wide verdict.
1373
1393
  const isAnswered = (d) => Object.hasOwn(state.answers, d.key) && !(d.type === 'design' && state.answers[d.key].answer === 'screens');
1394
+ /** A sign-in whose link has expired has nothing left to open: it leaves the board until its poster posts a new link on the same key. */
1395
+ const lapsed = (d) => d.type === 'signin' && !d.start && !!d.expires && Date.parse(d.expires) <= Date.now() && !isAnswered(d);
1374
1396
  /** Each item lives in one area: answered items, then the area the item names, then evidence updates (never an ask, whatever their result), then what is only to read (explainers, pages with nothing to answer), then the open asks.
1375
1397
  * A plan has no options but waits for its send-back. */
1376
1398
  const area = (d) => {
@@ -1428,7 +1450,7 @@
1428
1450
  }
1429
1451
  /** Today at a glance, in a few short sentences: what needs you and what was proven in full colour, when the soonest ask closes and the new reports quieter. */
1430
1452
  function glance(by) {
1431
- const n = by.waiting.length, proven = filedToday(by.evidence).length, fresh = filedToday(by.reports).length;
1453
+ const n = by.waiting.length, proven = by.provenToday.length, fresh = filedToday(by.reports).length;
1432
1454
  const soonest = by.waiting.map((d) => d.silent_by).filter(Boolean).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
1433
1455
  const lines = [[n ? `${n} ${n === 1 ? 'decision needs' : 'decisions need'} you.` : 'Nothing needs you.', 1], [soonest && `The soonest closes at ${when(soonest)}.`],
1434
1456
  [proven && `${proven} ${proven === 1 ? 'update' : 'updates'} filed.`, 1], [fresh && `${fresh} new ${fresh === 1 ? 'report is' : 'reports are'} waiting.`]];
@@ -1472,8 +1494,7 @@
1472
1494
  }
1473
1495
 
1474
1496
  /** Proven today on Today: a swipeable row of today's updates, each with its own result and capture; a wide screen sets them three across. */
1475
- function proven(items) {
1476
- const fresh = filedToday(items);
1497
+ function proven(items, fresh) {
1477
1498
  return el('section', { class: 'i-evidence wide' }, el('div', { class: 'sec-head' }, el('h2', {}, 'Proven today'), items.length ? el('a', { href: '#evidence' }, 'See all') : ''),
1478
1499
  fresh.length ? el('div', { class: 'ev-scroll-row' }, ...fresh.slice(0, 3).map((d) => withX(d, evCard(d)))) : el('p', { class: 'empty' }, 'Nothing proven today yet.'));
1479
1500
  }
@@ -2002,12 +2023,12 @@
2002
2023
  const quiet = opts.find((o) => o.value === d.default_if_silent);
2003
2024
  const silent = quiet ? [icon('clock'), el('span', {}, el('b', {}, 'If you stay quiet:'), ` ${quiet.outcome ?? quiet.label}${d.silent_by ? ` At ${when(d.silent_by)}. ${left(d.silent_by)}.` : ''}`)] : '';
2004
2025
  const head = el('div', { class: 'lead' }, badge(d), el('h2', { dir: 'auto' }, headline(d)));
2005
- const why = unchecked(d) || d.why || d.tradeoff || d.about;
2026
+ const why = unchecked(d) || d.why || (d.tradeoff && firstDraft(d).text) || d.about;
2006
2027
  const back = el('a', { class: 'later', href: '#waiting' }, 'Later'), gone = xBtn(d, 'Dismiss');
2007
2028
  if (gone) gone.addEventListener('click', () => (location.hash = to));
2008
2029
  const page = el('section', { class: 'decide' }, hero || (preview ? el('div', { class: 'decide-preview' }, preview) : ''),
2009
2030
  ...(d.mocks?.length ? [annotations(d)] : []), head,
2010
- why ? el('p', { class: 'why', dir: 'auto' }, why) : '',
2031
+ why ? el('p', { class: 'why', dir: 'auto' }, why) : '', firstDraftLinks(d),
2011
2032
  ...(live && opts.length ? [el('p', { class: 'pick-label' }, typeOf(d) === 'yes-no' ? 'YES OR NO' : 'PICK ONE'),
2012
2033
  el('ul', { class: 'pick-list', 'aria-label': d.title }, ...optionButtons.map((b) => el('li', {}, b)))] : []),
2013
2034
  err,
@@ -2035,9 +2056,11 @@
2035
2056
  const gone = new Set(state.dismissed ?? []), open = state.decisions.filter((d) => !gone.has(d.key) && !isAnswered(d));
2036
2057
  const openKeys = new Set(open.map((d) => d.key));
2037
2058
  const merged = new Set(open.flatMap((d) => d.merges ?? []).filter((k) => openKeys.has(k)));
2038
- for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key)) by[area(d)].push(d);
2059
+ for (const d of [...state.decisions].reverse()) if (!gone.has(d.key) && !merged.has(d.key) && !lapsed(d)) by[area(d)].push(d);
2039
2060
  by.evidence.sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
2040
2061
  by.designs = [...state.decisions].reverse().filter((d) => d.kind === 'design' && !gone.has(d.key));
2062
+ // Proven today counts every update filed today that is not dismissed, answered or not: a verdict moves it off Evidence, not out of today's count.
2063
+ by.provenToday = filedToday(state.decisions.filter((d) => d.type === 'evidence' && !gone.has(d.key))).sort((x, y) => (Date.parse(y.filed) || 0) - (Date.parse(x.filed) || 0));
2041
2064
  // Waiting on you reads newest first unless the viewer chose oldest first. A sign-in blocks someone now, so sign-ins come first either way.
2042
2065
  let latest = -Infinity;
2043
2066
  const inFileOrder = by.waiting.slice().reverse().map((d, i) => { const t = Date.parse(d.filed); if (!Number.isNaN(t)) latest = t; return { d, t: latest, i }; });
@@ -2086,8 +2109,8 @@
2086
2109
  : at === 'designs' ? [...designGroups(by.designs)] : at === 'evidence' ? evidenceGroups(by.evidence) : at === 'reports' && !state.readonly ? [by.reports.some((d) => d.steps) ? el('p', { class: 'card legend' }, ...Object.keys(MARKS).map((m) => el('span', {}, el('span', { class: `mk mk-${m}` }), MARKS[m]))) : '',
2087
2110
  ...(by.reports.length ? reportGroups(by.reports) : [el('p', { class: 'empty' }, EMPTY.reports)]), notes(by.notes)] : at === 'answered' && by.answered.length ? done(by.answered) : at ? [at === 'waiting' ? filters(by.waiting) : '', by[at].length ? el('div', { class: `list in-${at}${state.readonly ? ' readonly' : ''}` }, ...(at === 'waiting' ? shown : by[at]).map(row))
2088
2111
  : el('p', { class: 'empty' }, at === 'answered' && state.decisions.some((d) => gone.has(d.key) && isAnswered(d)) ? 'All done items cleared.' : EMPTY[at])] : [search(), glance(by),
2089
- el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence', filedToday(by.evidence).length, 'Proven today'), tile('reports', filedToday(by.reports).length, 'New report')),
2090
- el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.evidence), newReport(by.reports)), ...(state.leash ? leash() : []),
2112
+ el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence', by.provenToday.length, 'Proven today'), tile('reports', filedToday(by.reports).length, 'New report')),
2113
+ el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.evidence, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
2091
2114
  ...(FROZEN || state.readonly || state.answers_only ? [] : [ask()])]));
2092
2115
  readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || at === 'evidence' && d.type === 'evidence' || d.type === 'plan' && area(d) === at)) : undefined, at);
2093
2116
  if (at === 'decide') document.querySelector('#board .decide')?.prepend(bulkBar(bulk));
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "sign-off",
3
+ "short_name": "sign-off",
4
+ "description": "One calm page for every question the programs working for you need answered.",
5
+ "start_url": "./",
6
+ "display": "standalone",
7
+ "background_color": "#F5F5F7",
8
+ "theme_color": "#0071E3",
9
+ "icons": [
10
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
11
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" },
12
+ { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
13
+ ]
14
+ }