sign-off-axi 0.4.0 → 0.4.2

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/dist/cli.js CHANGED
@@ -1129,7 +1129,8 @@ async function postKind(file, on, values, built) {
1129
1129
  let sources = [];
1130
1130
  try {
1131
1131
  m ??= parseMessage(bytes);
1132
- checkMessage(m, built ? 'report' : kind);
1132
+ const existingAsk = !built && kind === 'ask' && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
1133
+ checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existingAsk);
1133
1134
  // Media paths read from the message file's folder, or from the current folder for stdin.
1134
1135
  if (m.kind === 'evidence')
1135
1136
  sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
@@ -2399,6 +2400,8 @@ const server = createServer(async (req, res) => {
2399
2400
  if (given !== null)
2400
2401
  return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
2401
2402
  }
2403
+ if (req.method === 'GET' && req.url === '/favicon.ico')
2404
+ return res.writeHead(204).end();
2402
2405
  if (req.method === 'GET' && req.url === '/') {
2403
2406
  return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'" }).end(page);
2404
2407
  }
package/dist/contract.js CHANGED
@@ -160,7 +160,7 @@ export function checkMessage(m, want, flags = false) {
160
160
  throw bad(`${at}.outcome`, 'is required', `Add ${flags ? `--outcome ${o.value}=<text>` : `"outcome" to option ${o.value}`}: ${OUTCOME}`);
161
161
  text(`${at}.outcome`, o.outcome, 120, OUTCOME);
162
162
  }
163
- else if (outcome)
163
+ else if (outcome === 'plan' || (outcome === 'legacy-ask' && Object.hasOwn(o, 'outcome')))
164
164
  text(`${at}.outcome`, o.outcome, 200, 'what picking it leads to, in 200 characters or fewer');
165
165
  });
166
166
  /** The option the poster would pick, `field` on `o`: required, and one of the option values, which it returns. */
@@ -298,9 +298,9 @@ export function checkMessage(m, want, flags = false) {
298
298
  text('detail', m.detail, 1000, 'the longer note, or leave it out');
299
299
  // An ask is read on a phone with no other context: it says why it is asked, what each answer does, why one is recommended and what silence does.
300
300
  const ask = m.kind === 'ask', WHY = 'what it is about and why it needs an answer now, in 1 or 2 short lines (200 characters or fewer)';
301
- if (ask && !Object.hasOwn(m, 'why'))
301
+ if (ask && flags && !Object.hasOwn(m, 'why'))
302
302
  throw bad('why', 'is required', `Add ${named('why', flags)}: ${WHY}`);
303
- if (ask)
303
+ if (ask && flags)
304
304
  text('why', m.why, 200, WHY);
305
305
  // A gate offers 2 or 3 of done-done's actions and a finding both of apply and skip, in any order, each with its own label.
306
306
  const options = m.options, actions = ACTIONS[m.kind], max = actions ? Object.keys(actions).length : 6;
@@ -308,24 +308,24 @@ export function checkMessage(m, want, flags = false) {
308
308
  const listed = !actions ? (flags ? 'Give 2 to 6 --option flags, such as --option yes="Yes" --option no="No"' : 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes"}') : max === 3 ? `Set "options" to 2 or 3 of ${actionList}` : `Set "options" to ${actionList}`;
309
309
  if (!Array.isArray(options) || options.length < 2 || options.length > max)
310
310
  throw bad('options', Object.hasOwn(m, 'options') ? `must list ${{ 2: '2', 3: '2 or 3' }[max] ?? '2 to 6'} options` : 'is required', listed);
311
- checkOptions('options', options, ask ? 'ask' : false);
311
+ checkOptions('options', options, ask ? (flags ? 'ask' : 'legacy-ask') : false);
312
312
  const odd = actions && options.find((o) => !Object.hasOwn(actions, o.value) || actions[o.value] !== o.label);
313
313
  if (odd)
314
314
  throw bad('options', `${show(odd)} is not a ${m.kind} action: ${Object.keys(actions).join(', ')}, each with its label`, listed);
315
315
  const values = recommend('recommend_value', m, options);
316
316
  const REASON = `why you recommend ${m.recommend_value}, in one line (160 characters or fewer)`;
317
- if (ask && !Object.hasOwn(m, 'recommend_reason'))
317
+ if (ask && flags && !Object.hasOwn(m, 'recommend_reason'))
318
318
  throw bad('recommend_reason', 'is required', `Add ${named('recommend_reason', flags)}: ${REASON}`);
319
- if (ask)
319
+ if (ask && flags)
320
320
  text('recommend_reason', m.recommend_reason, 160, REASON);
321
- if (ask && !Object.hasOwn(m, 'default_if_silent'))
321
+ if (ask && flags && !Object.hasOwn(m, 'default_if_silent'))
322
322
  throw bad('default_if_silent', 'is required', `Add ${named('default_if_silent', flags)}: the option that happens if the person stays quiet, one of ${values.join(', ')}`);
323
323
  if (Object.hasOwn(m, 'default_if_silent') && !values.includes(m.default_if_silent)) {
324
324
  throw bad('default_if_silent', `${show(m.default_if_silent)} is not an option value`, `Set ${named('default_if_silent', flags)} to one of ${values.join(', ')}${ask ? '' : ', or leave it out'}`);
325
325
  }
326
326
  // A real UTC time to the minute, seconds optional: Date.parse would roll 2026-02-30 over to March.
327
327
  const utc = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$/.test(v) && !Number.isNaN(Date.parse(v)) && new Date(v).toISOString().startsWith(v.slice(0, -1));
328
- if (Object.hasOwn(m, 'silent_by') && !utc(m.silent_by))
328
+ if (Object.hasOwn(m, 'silent_by') && (!ask || flags) && !utc(m.silent_by))
329
329
  throw bad('silent_by', `${show(m.silent_by)} is not a UTC time such as 2026-10-17T18:00:00Z`, `Set ${named('silent_by', flags)} to when the silent default happens, as YYYY-MM-DDTHH:MM:SSZ in UTC, or leave it out`);
330
330
  if (Object.hasOwn(m, 'close') && m.close !== 'done' && m.close !== 'release')
331
331
  throw bad('close', 'must be done or release', 'Set "close" to "done" or "release", or leave it out');
@@ -342,7 +342,7 @@ export function checkMessage(m, want, flags = false) {
342
342
  throw bad(`merges[${n}]`, `repeats ${k}`, 'List each key once');
343
343
  });
344
344
  }
345
- if (ask)
345
+ if (ask && flags)
346
346
  question(m, bad, flags);
347
347
  // A gate's run is its own rule (section 5), so it comes after the field rules of section 4.
348
348
  if (m.kind === 'gate') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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" },
package/web/index.html CHANGED
@@ -2,33 +2,46 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <title>Dashboard · sign-off</title>
7
7
  <style>
8
+ /* 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
+ 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. */
8
10
  :root { color-scheme: light dark;
9
- --bg: #F6F3EE; --surface: #FFFFFF; --sunk: #EFEBE4; --line: #E6E0D6; --text: #1F1B16; --muted: #6A6157;
10
- --border: #B8AE9F; --accent: #C2410C; --on-accent: #FFFFFF; --danger: #B42318; --bezel: #1F1B16;
11
- --c-waiting: #E8590C; --c-evidence: #0C8599; --c-reports: #5B5BD6; --c-answered: #2B8A3E; --c-gate: #E8A100; --ok: #1E7034; --warn: #8A5A00;
12
- --shadow: 0 1px 2px rgb(60 40 20 / 0.06), 0 6px 18px rgb(60 40 20 / 0.06); }
11
+ --page: #F5F5F7; --tile: #FFFFFF; --desk: #FFFFFF; --desk-tile: #F5F5F7; --bar: #FAFAFC; --sunk: #E8E8ED; --line: #D2D2D7;
12
+ --text: #1D1D1F; --muted: #6E6E73; --border: #8E8E93; --accent: #0066CC; --action: #0071E3; --on-accent: #FFFFFF; --danger: #C00011; --bezel: #1D1D1F;
13
+ --c-waiting: #0071E3; --c-evidence: #17702F; --c-reports: #A34700; --c-answered: #6E6E73; --c-gate: #8A6100; --ok: #17702F; --warn: #A34700;
14
+ --bg: var(--page); --surface: var(--tile); }
13
15
  @media (prefers-color-scheme: dark) { :root {
14
- --bg: #15120F; --surface: #211D18; --sunk: #1B1814; --line: #342E26; --text: #F3EEE6; --muted: #A99F91;
15
- --border: #6E6458; --accent: #FB923C; --on-accent: #1A1009; --danger: #FF8A80; --bezel: #4A4238;
16
- --c-waiting: #FF8A4C; --c-evidence: #3BC9DB; --c-reports: #9491FF; --c-answered: #51CF66; --c-gate: #FCC419; --ok: #51CF66; --warn: #FCC419;
17
- --shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 8px 24px rgb(0 0 0 / 0.25); } }
16
+ --page: #000000; --tile: #161617; --desk: #000000; --desk-tile: #161617; --bar: #161617; --sunk: #2C2C2E; --line: #424245;
17
+ --text: #F5F5F7; --muted: #86868B; --border: #636366; --accent: #2997FF; --danger: #FF6961; --bezel: #424245;
18
+ --c-waiting: #0A84FF; --c-evidence: #30D158; --c-reports: #FF9F0A; --c-answered: #98989D; --c-gate: #FFD60A; --ok: #30D158; --warn: #FF9F0A; } }
18
19
  * { box-sizing: border-box; }
19
- body { margin: 0 0 3rem; background: var(--bg); color: var(--text); overflow-wrap: anywhere; -webkit-font-smoothing: antialiased;
20
- font: 1rem/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
20
+ /* A phone keeps room at the bottom for the tab bar. */
21
+ body { margin: 0; padding-bottom: calc(5.125rem + env(safe-area-inset-bottom)); background: var(--bg); color: var(--text); overflow-wrap: anywhere;
22
+ -webkit-font-smoothing: antialiased; font: 1.0625rem/1.4706 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; letter-spacing: -0.022em; }
21
23
  /* The four areas each have a colour, used on icons, dots and marks. */
22
24
  .i-waiting { --area: var(--c-waiting); } .i-evidence { --area: var(--c-evidence); } .i-reports { --area: var(--c-reports); } .i-answered { --area: var(--c-answered); }
23
- main { max-width: 72rem; margin: 0 auto; padding: 0 1rem; }
25
+ main { max-width: 72rem; margin: 0 auto; padding: 0 max(1.25rem, env(safe-area-inset-right)) 0 max(1.25rem, env(safe-area-inset-left)); }
24
26
  .side { display: none; }
27
+ /* A phone moves between the areas with a tab bar, solid so no page text shows through it. */
28
+ .tabs { position: fixed; z-index: 2; left: 0; right: 0; bottom: 0; display: flex; padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
29
+ background: var(--bar); border-top: 0.5px solid var(--line); }
30
+ .tabs a { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.125rem; min-height: 4.125rem; color: var(--muted);
31
+ font-size: 1rem; line-height: 1.125; letter-spacing: -0.01em; text-decoration: none; }
32
+ .tabs a[aria-current] { color: var(--accent); }
33
+ .tabs a:focus-visible { outline-offset: -2px; border-radius: 0.75rem; }
34
+ /* The areas' icons in the bar and the sidebar are bare outlines. */
35
+ .tabs .icon, .side .icon { width: 1.5rem; height: 1.5rem; background: none; }
36
+ .tabs .icon svg, .side .icon svg { width: 1.5rem; height: 1.5rem; stroke: currentColor; stroke-width: 1.75; }
37
+ .side .icon { color: var(--accent); }
25
38
  .banner { margin: 1rem 0 0; padding: 0.75rem 1rem; border: 1px solid var(--danger); border-radius: 0.5rem; color: var(--danger); }
26
- .top { position: relative; padding: 1rem 0 0.75rem; }
39
+ .top { position: relative; padding: 1.5rem 0 1.25rem; }
27
40
  /* The person who answers: a round mark beside the Summary title. The board has no name to show, so it is a plain figure. */
28
- .top > .i-you { position: absolute; top: 2.4rem; right: 0; width: 2.5rem; height: 2.5rem; border-radius: 50%; --area: var(--accent); }
41
+ .top > .i-you { position: absolute; top: 2.4rem; right: 0; width: 2.5rem; height: 2.5rem; border-radius: 50%; --area: var(--action); }
29
42
  .top > .i-you svg { width: 1.375rem; height: 1.375rem; }
30
43
  .date { margin: 0; color: var(--muted); font-size: 0.9375rem; font-weight: 600; }
31
- .top h1 { margin: 0; font-size: 2.125rem; line-height: 1.2; letter-spacing: -0.01em; outline: none; }
44
+ .top h1 { margin: 0; font-size: 2.125rem; line-height: 1.2059; font-weight: 700; letter-spacing: 0.011em; outline: none; }
32
45
  .lede { margin: 0.375rem 0 0; color: var(--muted); }
33
46
  .back, .sec-head a, .more { display: inline-flex; align-items: center; min-height: 2.75rem; color: var(--accent); font-weight: 600; text-decoration: none; }
34
47
  h2 { font-size: 1.375rem; line-height: 1.25; margin: 0 0 0.75rem; }
@@ -36,14 +49,14 @@
36
49
  .icon svg { width: 1.125rem; height: 1.125rem; fill: none; stroke: var(--surface); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
37
50
  /* Summary: a tile per area (each opens its screen), what needs you first, the day so far, then a short list per area. */
38
51
  .tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
39
- .card, .tile { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: var(--shadow); }
52
+ .card, .tile { background: var(--surface); border-radius: 1rem; }
40
53
  .card { padding: 1rem; }
41
54
  .tile { position: relative; display: grid; grid-template-columns: auto auto minmax(0, 1fr); align-content: start; gap: 0.25rem 0.5rem; padding: 0.625rem 0.875rem; color: var(--text); text-decoration: none; }
42
55
  .tile::after { content: "›" / ""; position: absolute; top: 0.5rem; right: 0.875rem; color: var(--muted); font-size: 1.5rem; line-height: 1.2; }
43
56
  .tile .name { grid-column: 1 / -1; font-size: 0.875rem; font-weight: 600; color: var(--muted); line-height: 1.3; }
44
57
  .tile .detail { grid-column: 1 / -1; }
45
58
  .tile .n { grid-column: 1 / 3; font-size: 2.125rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; white-space: nowrap; }
46
- .tile .detail { font-size: 0.8125rem; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
59
+ .tile .detail { font-size: 0.875rem; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
47
60
  /* The marks give way to the count: one line at the right, and a mark with no room left wraps out of sight. */
48
61
  .vis { align-self: center; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 0.25rem; height: 2.25rem; overflow: hidden; }
49
62
  .vis::before { content: ""; height: 100%; }
@@ -71,6 +84,7 @@
71
84
  .focus .since { margin: -0.375rem 0 1rem; font-weight: 600; } .needs > .since { margin: -0.25rem 0 0.5rem; font-weight: 600; }
72
85
  .focus .row h3 { font-size: 1.75rem; line-height: 1.2; letter-spacing: -0.01em; }
73
86
  .focus .choices button, .needs .choices .rec { min-height: 4rem; border-radius: 1rem; font-size: 1.125rem; font-weight: 600; }
87
+ @media (min-width: 60rem) { .focus .choices button, .needs .choices .rec { min-height: 2.75rem; } }
74
88
  /* On Summary the recommended answer is the large button, and the others are pills under it. */
75
89
  .needs .choices button { font-weight: 600; } .needs .choices button:not(.rec) { justify-self: start; border-radius: 999px; }
76
90
  .needs .choices button[aria-pressed="true"] { padding-right: 2.75rem; }
@@ -86,10 +100,10 @@
86
100
  .report { display: grid; gap: 0.75rem; scroll-margin-top: 4.5rem; }
87
101
  .report .about, .report .decided { margin: 0; }
88
102
  .st { display: inline-flex; flex: none; align-items: center; gap: 0.375rem; padding: 0.125rem 0.625rem; border-radius: 999px; background: var(--sunk);
89
- font-size: 0.8125rem; font-weight: 650; white-space: nowrap; }
103
+ font-size: 0.875rem; font-weight: 650; white-space: nowrap; }
90
104
  .st::before { content: ""; width: 0.4375rem; height: 0.4375rem; border-radius: 50%; background: currentColor; }
91
105
  .st.ok { color: var(--ok); } .st.new { color: var(--c-reports); } .st.bad { color: var(--danger); } .st.warn { color: var(--warn); }
92
- .decided .lbl { display: block; color: var(--muted); font-size: 0.8125rem; font-weight: 650; }
106
+ .decided .lbl { display: block; color: var(--muted); font-size: 0.875rem; font-weight: 650; }
93
107
  .sec-h { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; margin: 1.5rem 0 0.75rem; }
94
108
  .sec-h h2, .sec-h .meta { margin: 0; }
95
109
  .legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0 0 0.75rem; padding: 0.625rem 1rem; color: var(--muted); font-size: 0.875rem; }
@@ -110,7 +124,7 @@
110
124
  .flow li { position: relative; display: grid; justify-items: center; align-content: start; gap: 0.375rem; }
111
125
  .flow li + li::before { content: ""; position: absolute; top: 0.8125rem; right: 50%; width: 100%; border-top: 2px solid var(--line); }
112
126
  .num { position: relative; z-index: 1; display: grid; place-items: center; width: 1.75rem; height: 1.75rem; border: 2px solid var(--muted); border-radius: 50%;
113
- background: var(--surface); font-size: 0.8125rem; font-weight: 700; }
127
+ background: var(--surface); font-size: 0.875rem; font-weight: 700; }
114
128
  .steps { gap: 0.75rem; }
115
129
  .steps .mk { width: 1.75rem; height: 1.75rem; border-radius: 0.5rem; }
116
130
  .steps .mk::before { width: 0.75rem; height: 0.75rem; }
@@ -135,6 +149,58 @@
135
149
  #reader .close { display: inline-flex; flex: none; align-items: center; min-height: 3rem; margin-left: -0.5rem; padding: 0 0.5rem; border-radius: 0.625rem; color: var(--accent); font-weight: 650; text-decoration: none; }
136
150
  #reader h1 { margin: 0; min-width: 0; overflow: hidden; font-size: 1rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
137
151
  #reader iframe { flex: 1; width: 100%; border: 0; background: var(--bg); }
152
+ #reader h1:not(:focus-visible) { outline: none; }
153
+ #reader h1:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
154
+ .i-plan { --area: var(--c-waiting); }
155
+ #reader .plan-scroll { flex: 1; min-height: 0; overflow: auto; padding: 1.25rem 1.25rem 9rem; }
156
+ .plan-layout { max-width: 72rem; margin: auto; display: grid; gap: 2rem; }
157
+ .plan-main { min-width: 0; }
158
+ .plan-main > h2 { margin: 2rem 0 0.75rem; }
159
+ .plan-main > h2:first-of-type { font-size: 3.5rem; line-height: 1.15; margin: 0.5rem 0; }
160
+ .plan-main > p { margin: 0.5rem 0 1.5rem; }
161
+ .plan-summary { display: flex; gap: 0.75rem; color: var(--muted); font-size: 1.125rem; line-height: 1.5; margin: 1.5rem 0 2rem; }
162
+ #reader .plan-view { width: 100%; height: 22rem; flex: none; border-radius: 1rem; }
163
+ .plan-section, .plan-decision, .plan-explain { background: var(--surface); border-radius: 1.125rem; padding: 1rem; margin: 1rem 0; scroll-margin-top: 1rem; }
164
+ .plan-section h3, .plan-decision h3 { font-size: 1.375rem; line-height: 1.3; margin: 0 0 0.75rem; }
165
+ .plan-section p { white-space: pre-line; }
166
+ .plan-verdicts { display: flex; gap: 0.625rem; }
167
+ .plan-verdicts button { flex: 1; border: 0; border-radius: 980px; background: var(--sunk); font-weight: 600; }
168
+ .plan-verdicts button[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px currentColor; background: var(--surface); }
169
+ .plan-verdicts button[aria-pressed="true"]::before, .plan-decision button[aria-pressed="true"]::before { content: none; }
170
+ .plan-verdicts .approve[aria-pressed="true"] { color: var(--ok); }
171
+ .plan-verdicts .change[aria-pressed="true"] { color: var(--warn); }
172
+ .plan-section label { display: grid; gap: 0.5rem; margin-top: 1rem; color: var(--muted); }
173
+ .plan-section label[hidden] { display: none; }
174
+ .plan-section textarea { width: 100%; min-height: 7rem; padding: 0.75rem; background: var(--sunk); }
175
+ .plan-decision .choices { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.625rem; }
176
+ .plan-decision .choices button { text-align: left; border-radius: 0.875rem; padding: 0.875rem; gap: 0.25rem; }
177
+ .plan-decision .choices button .tag { margin: 0; text-transform: none; letter-spacing: normal; color: var(--accent); }
178
+ .plan-decision .choices button .tag::before { content: none; }
179
+ .plan-decision .choices button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--text); border: 2px solid var(--accent); }
180
+ .plan-decision .tag { display: block; color: var(--accent); font-size: 0.875rem; }
181
+ .plan-explain .foot { justify-content: space-between; }
182
+ .plan-send { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.625rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); background: var(--bar); border-top: 1px solid var(--line); }
183
+ .plan-send p { margin: 0 0 0.5rem; }
184
+ .plan-send button { width: 100%; }
185
+ .plan-next-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
186
+ .plan-next { width: auto !important; padding: 0; border: 0; background: none; color: var(--accent); font-weight: 600; }
187
+ .plan-send .err:empty { display: none; }
188
+ .plan-progress button { width: 100%; padding: 0.5rem 0; display: flex; justify-content: space-between; gap: 1rem; background: none; text-align: left; border-radius: 0.5rem; font-size: 1rem; }
189
+ .plan-progress span { color: var(--muted); }
190
+ @media (max-width: 59.999rem) { #reader .plan-scroll { margin-bottom: calc(9rem + env(safe-area-inset-bottom)); } #reader .plan-scroll.sent { margin-bottom: 0; } .plan-send h2, .plan-progress { display: none; } .plan-send p { font-size: 1rem; } }
191
+ #reader .plan-sent .back { display: inline-flex; }
192
+ .plan-sent { text-align: center; padding-top: 2rem; }
193
+ .plan-sent h2 { color: var(--ok); font-size: 2.125rem; }
194
+ .plan-sent .icon { margin: auto; background: var(--ok); }
195
+ .plan-layout.sent { grid-template-columns: minmax(0, 42.5rem); justify-content: center; }
196
+ .plan-send[hidden] { display: none; }
197
+ @media (min-width: 60rem) {
198
+ #reader .plan-scroll { padding: 2rem 3rem; }
199
+ .plan-layout { grid-template-columns: minmax(0, 42.5rem) minmax(15rem, 1fr); }
200
+ .plan-send { position: sticky; align-self: start; top: 0; border: 0; border-radius: 1.125rem; padding: 1.25rem; }
201
+ .plan-next { display: none; }
202
+ .plan-section, .plan-decision { padding: 1.5rem; }
203
+ }
138
204
  body.reading { overflow: hidden; }
139
205
  .foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; }
140
206
  .foot .meta { margin: 0 auto 0 0; }
@@ -143,15 +209,17 @@
143
209
  /* Tall enough for the tallest column, six dots under its count, so no count reaches the caption above. */
144
210
  .day { position: relative; height: 6.5rem; border-bottom: 1px solid var(--border); }
145
211
  .col { position: absolute; bottom: 0.25rem; display: flex; flex-direction: column-reverse; align-items: center; gap: 0.1875rem; transform: translateX(-50%);
146
- font-size: 0.8125rem; font-weight: 700; line-height: 1.2; overflow-wrap: normal; }
212
+ font-size: 0.875rem; font-weight: 700; line-height: 1.2; overflow-wrap: normal; }
147
213
  .dot { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--c-answered); }
148
- .hours { display: flex; justify-content: space-between; color: var(--muted); font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
214
+ .hours { display: flex; justify-content: space-between; color: var(--muted); font-size: 0.875rem; font-variant-numeric: tabular-nums; }
149
215
  .events { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.25rem; font-size: 0.9375rem; }
150
216
  .events time, .sec-head .meta { font-weight: 700; font-variant-numeric: tabular-nums; margin-right: 0.5rem; }
151
217
  .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
152
218
  .sec-head h2 { margin: 0; overflow-wrap: normal; } /* A long count beside a section name wraps itself, never the name mid-word. */ .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
153
- .rows { list-style: none; margin: 0; padding: 0 1rem; }
154
- .rows > li + li { border-top: 1px solid var(--line); }
219
+ /* A grouped list: hairlines between rows, inset from the left as on iOS. */
220
+ .rows { list-style: none; margin: 0; padding: 0 0 0 1rem; }
221
+ .rows > li { padding-right: 0.5rem; }
222
+ .rows > li + li { border-top: 0.5px solid var(--line); }
155
223
  .compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
156
224
  .compact .t { display: grid; flex: 1; min-width: 0; }
157
225
  /* The answer sits under the title, never beside it: a long answer beside the title squeezed it to a letter a line. */
@@ -169,13 +237,13 @@
169
237
  /* A read-only board is for reading: its pages get most of the screen. */
170
238
  .readonly .rung iframe { height: 80vh; }
171
239
  .row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.875rem; padding: 1rem; scroll-margin-top: 4.5rem;
172
- background: var(--surface); border: 1px solid var(--line); border-radius: 0.875rem; box-shadow: var(--shadow); }
173
- /* Waiting cards carry the accent stripe and the shadow; answered ones sit flat and quiet. */
174
- .in-waiting .row { box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
240
+ background: var(--surface); border-radius: 0.875rem; }
241
+ /* Waiting cards carry the accent stripe; answered ones sit flat and quiet. */
242
+ .in-waiting .row { border-left: 3px solid var(--accent); padding-left: calc(1rem - 3px); }
175
243
  .in-answered .row { background: transparent; box-shadow: none; }
176
244
  .in-answered .row h3 { color: var(--muted); font-weight: 500; }
177
245
  /* A sign-in: one big Open for its link, off once the link expired. */
178
- .open-link { display: grid; place-items: center; min-height: 3.5rem; border-radius: 1rem; background: var(--accent); color: var(--on-accent);
246
+ .open-link { display: grid; place-items: center; min-height: 3.5rem; border-radius: 980px; background: var(--action); color: var(--on-accent);
179
247
  font-size: 1.125rem; font-weight: 700; text-decoration: none; }
180
248
  button.open-link:disabled { border: 1px solid var(--line); background: var(--sunk); color: var(--muted); cursor: not-allowed; }
181
249
  .signin .line { margin: 0.375rem 0 0; } .signin .expiry { margin: 0.5rem 0 0; font-weight: 700; } .signin .expiry.gone { color: var(--danger); }
@@ -185,15 +253,15 @@
185
253
  .about { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
186
254
  .row:has(details:not(.note, .other, .tech)[open]) .about { display: none; }
187
255
  .about.full { display: block; }
188
- summary { cursor: pointer; color: var(--accent); font-size: 0.875rem; margin-top: 0.25rem; width: fit-content; }
189
- .meta { color: var(--muted); font-size: 0.8125rem; margin: 0.5rem 0 0; }
256
+ summary { cursor: pointer; color: var(--accent); font-size: 0.875rem; margin-top: 0.25rem; width: fit-content; min-height: 2.75rem; }
257
+ .meta { color: var(--muted); font-size: 0.875rem; margin: 0.5rem 0 0; }
190
258
  /* Every card reads top down in one order: a short status line, the result as its headline, what changed, the proof, then the one decision.
191
259
  The headline keeps the card's whole width: the dismiss x floats at the corner, never in a row that squeezes the title. */
192
260
  .lead > .x { float: right; margin: -0.625rem -0.625rem -0.75rem 0.5rem; }
193
- .eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.625rem; margin: 0 0 0.375rem; color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
261
+ .eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.625rem; margin: 0 0 0.375rem; color: var(--muted); font-size: 0.875rem; font-weight: 600; }
194
262
  .ask-line { grid-column: 1 / -1; margin: 0 0 -0.375rem; font-weight: 650; }
195
263
  .why { grid-column: 1 / -1; margin: 0; color: var(--warn); font-size: 0.875rem; font-weight: 600; }
196
- .tech { grid-column: 1 / -1; font-size: 0.8125rem; } .tech summary { margin: 0; color: var(--muted); font-size: inherit; } .tech p { margin: 0.25rem 0 0; }
264
+ .tech { grid-column: 1 / -1; font-size: 0.875rem; } .tech summary { margin: 0; color: var(--muted); font-size: inherit; } .tech p { margin: 0.25rem 0 0; }
197
265
  /* Each capture keeps its own shape: the frame is the shot, bounded in height, never letterboxed; two or more share the row. */
198
266
  .media { display: flex; flex-wrap: wrap; align-items: flex-start; gap: .75rem; }
199
267
  .media a { display: block; max-width: 100%; color: var(--accent); cursor: zoom-in; }
@@ -203,7 +271,7 @@
203
271
  .proof { grid-column: 1 / -1; } .proof:has(> :nth-child(2)) { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); max-width: 26rem; }
204
272
  /* An explainer's chapters: one tap jumps the video to that part and plays it. */
205
273
  .chapters { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.375rem; margin: -0.25rem 0 0; padding: 0; list-style: none; }
206
- .chapters button { min-height: 2.5rem; padding: 0 0.75rem; border-radius: 999px; border-color: var(--line); font-size: 0.875rem; }
274
+ .chapters button { min-height: 2.75rem; padding: 0 0.75rem; border-radius: 999px; border-color: var(--line); font-size: 0.875rem; }
207
275
  .chapters .at { margin-right: 0.375rem; color: var(--muted); font-variant-numeric: tabular-nums; }
208
276
  .meta a { color: var(--accent); }
209
277
  .err:empty { display: none; }
@@ -211,7 +279,7 @@
211
279
  .choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
212
280
  /* The quick answers come first and are big enough for a thumb; typing is last, behind Other answer. */
213
281
  /* Every card's answers read from the left, with the Recommended label above the answer and a picked answer's check on the right. */
214
- .choices button { position: relative; display: grid; justify-items: start; align-content: center; min-height: 3rem; padding: 0.625rem 1.25rem; text-align: left; }
282
+ .choices button { position: relative; display: grid; justify-items: start; align-content: center; min-height: 3.125rem; padding: 0.625rem 1.25rem; text-align: left; }
215
283
  .choices .tag { order: -1; margin: 0 0 0.125rem; font-size: 0.875rem; letter-spacing: 0; text-transform: none; }
216
284
  .choices button[aria-pressed="true"]::before { position: absolute; right: 1rem; top: 50%; translate: 0 -50%; margin: 0; }
217
285
  .row > .note, .row > .err { grid-column: 1 / -1; }
@@ -223,23 +291,24 @@
223
291
  .other[open] > summary::after { margin-top: 0.25rem; transform: rotate(-135deg); }
224
292
  .actions .quick { border-radius: 999px; }
225
293
  /* After a tap: what was saved, and an Undo for a few seconds. */
226
- #toast { position: fixed; z-index: 3; left: 1rem; right: 1rem; bottom: max(1rem, env(safe-area-inset-bottom)); max-width: 30rem; margin: 0 auto;
227
- display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0.375rem 0.375rem 1rem; border-radius: 0.75rem;
228
- background: var(--text); color: var(--bg); box-shadow: var(--shadow); }
294
+ #toast { position: fixed; z-index: 3; left: 1rem; right: 1rem; bottom: calc(5.125rem + env(safe-area-inset-bottom)); max-width: 30rem; margin: 0 auto;
295
+ display: flex; align-items: center; gap: 0.875rem; padding: 0.5rem 0.5rem 0.5rem 1.125rem; border-radius: 0.875rem;
296
+ background: var(--text); color: var(--bg); font-size: 1rem; }
229
297
  #toast[hidden] { display: none; }
230
298
  #toast span { flex: 1; min-width: 0; }
231
- #toast button { flex: none; background: transparent; color: inherit; border-color: currentColor; font-weight: 600; }
299
+ #toast button { flex: none; min-height: 3.125rem; padding: 0 0.875rem; background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; font-weight: 600; }
232
300
  #toast button:focus-visible { outline-color: var(--bg); }
233
- button { min-height: 2.75rem; padding: 0 1.25rem; border-radius: 0.5rem; border: 1px solid var(--border); cursor: pointer;
234
- background: var(--surface); color: var(--text); font: inherit; font-weight: 500; }
301
+ /* Buttons are pills: grey for the plain ones, filled blue once picked, solid grey when off. 50px on a phone, 44px on a desk. */
302
+ button { min-height: 3.125rem; padding: 0 1.25rem; border: 0; border-radius: 980px; cursor: pointer;
303
+ background: var(--sunk); color: var(--text); font: inherit; font-weight: 500; }
235
304
  /* Recommended is a dashed accent outline and a tag; picked is filled with a check. They never look alike. */
236
305
  button.rec { border: 1.5px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
237
306
  .tag::before { content: "★ " / ""; }
238
- .tag { display: inline-block; white-space: nowrap; margin-left: 0.375rem; color: var(--accent); font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
239
- button[aria-pressed="true"] { background: var(--accent); border: 1px solid var(--accent); color: var(--on-accent); font-weight: 600; }
307
+ .tag { display: inline-block; white-space: nowrap; margin-left: 0.375rem; color: var(--accent); font-size: 0.875rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
308
+ button[aria-pressed="true"] { background: var(--action); color: var(--on-accent); font-weight: 600; }
240
309
  button[aria-pressed="true"]::before { content: "✓" / ""; margin-right: 0.375rem; font-weight: 700; }
241
310
  button[aria-pressed="true"] .tag { color: inherit; }
242
- button:disabled { opacity: 0.6; cursor: progress; }
311
+ button:disabled { cursor: progress; } button:disabled:not([aria-pressed="true"]) { background: var(--sunk); color: var(--muted); }
243
312
  .choices.many { display: flex; flex-wrap: wrap; }
244
313
  /* An ask that says what each answer does: one answer per line on a phone, its outcome and the reason for the recommended one under its label. */
245
314
  .choices.told { grid-template-columns: minmax(0, 1fr); }
@@ -250,10 +319,10 @@
250
319
  .ask-why, .silent { margin: 0.375rem 0 0; max-width: 70ch; font-size: 1rem; }
251
320
  .silent { color: var(--muted); }
252
321
  .choices.many button { flex: 1 1 auto; max-width: 100%; padding: 0.5rem 1rem; }
253
- .note label { display: block; color: var(--muted); font-size: 0.8125rem; margin-top: 0.5rem; }
322
+ .note label { display: block; color: var(--muted); font-size: 0.875rem; margin-top: 0.5rem; }
254
323
  .tradeoff { margin: 0; max-width: 70ch; }
255
324
  /* Dismiss: a quiet round x at the corner of a card or the end of a row. */
256
- .x { flex: none; display: grid; place-items: center; width: 2.75rem; padding: 0; margin: -0.5rem 0 -0.5rem auto; border: 0; border-radius: 50%;
325
+ .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%;
257
326
  background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
258
327
  .x:hover { background: var(--sunk); color: var(--text); }
259
328
  .rows > li { display: flex; align-items: center; }
@@ -277,11 +346,11 @@
277
346
  /* Waiting on you: one row of type chips, and the sort beside them (or below them on a phone). */
278
347
  .filters { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 0.75rem; margin: 0 0 0.75rem; }
279
348
  .chips { display: flex; flex-wrap: wrap; gap: 0.375rem; }
280
- .chips button { min-height: 2.25rem; padding: 0 0.75rem; border-radius: 999px; font-size: 0.875rem; font-weight: 600; }
349
+ .chips button { min-height: 2.75rem; padding: 0 0.75rem; border-radius: 999px; font-size: 0.875rem; font-weight: 600; }
281
350
  .sort { display: flex; align-items: center; gap: 0.5rem; color: var(--muted); font-size: 0.875rem; font-weight: 600; }
282
- .sort select { min-height: 2.25rem; padding: 0 0.5rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--surface); color: var(--text); font: inherit; }
351
+ .sort select { min-height: 2.75rem; padding: 0 0.5rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--surface); color: var(--text); font: inherit; }
283
352
  .chip { padding: 0 0.5rem; border: 1px solid currentColor; border-radius: 999px; color: var(--accent);
284
- font-size: 0.6875rem; font-weight: 600; line-height: 1.25rem; letter-spacing: 0.04em; text-transform: uppercase; }
353
+ font-size: 0.875rem; font-weight: 600; line-height: 1.25rem; letter-spacing: 0.04em; text-transform: uppercase; }
285
354
  /* Each option is a tall phone frame, side by side; on a phone the row swipes, one frame at a time, and the next one peeks in.
286
355
  Every option shares the label, frame and button rows, so a label that wraps never pushes one frame below the rest.
287
356
  The row keeps 0.25rem of room on each side for focus rings, given back by the margin. */
@@ -314,7 +383,7 @@
314
383
  .rung iframe { display: block; width: 100%; height: min(18rem, 50vh); border: 0; }
315
384
  .rung .gone { color: var(--danger); margin: 0; }
316
385
  .rung > .meta { color: var(--accent); margin: 0; }
317
- .badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; color: var(--accent); border: 1.5px solid var(--accent);
386
+ .badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.875rem; font-weight: 600; color: var(--accent); border: 1.5px solid var(--accent);
318
387
  background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
319
388
  .round { margin-top: 0.5rem; }
320
389
  .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
@@ -328,6 +397,7 @@
328
397
  @media (min-width: 45rem) {
329
398
  main { padding: 0 2rem; }
330
399
  .row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
400
+ .in-waiting .row { padding-left: calc(1.25rem - 3px); }
331
401
  .row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
332
402
  /* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
333
403
  .mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
@@ -344,13 +414,21 @@
344
414
  }
345
415
  /* A wide screen keeps the areas in a sidebar; the Summary puts its tiles in one row and its lists in two columns. */
346
416
  @media (min-width: 60rem) {
347
- body { display: grid; grid-template-columns: 19rem minmax(0, 1fr); }
348
- .side { display: flex; flex-direction: column; gap: 0.25rem; position: sticky; top: 0; height: 100vh; padding: 1.5rem 1rem;
349
- border-right: 1px solid var(--line); background: var(--sunk); }
350
- .side a { display: flex; align-items: center; gap: 0.75rem; min-height: 2.75rem; padding: 0 0.625rem; border-radius: 0.625rem; color: var(--text); text-decoration: none; }
351
- .side a[aria-current] { background: var(--surface); font-weight: 600; }
352
- .side .n { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
353
- .brand { margin: 0 0 1rem 0.625rem; font-weight: 700; }
417
+ :root { --bg: var(--desk); --surface: var(--desk-tile); }
418
+ body { display: grid; grid-template-columns: 16.25rem minmax(0, 1fr); padding-bottom: 3rem; }
419
+ .tabs { display: none; }
420
+ .side { display: flex; flex-direction: column; gap: 0.125rem; position: sticky; top: 0; height: 100vh; padding: 1.25rem 0.75rem;
421
+ border-right: 1px solid var(--line); background: var(--surface); }
422
+ .side a { display: flex; align-items: center; gap: 0.75rem; min-height: 2.75rem; padding: 0 0.75rem; border-radius: 0.625rem; color: var(--text); text-decoration: none; }
423
+ .side a:hover { background: color-mix(in srgb, var(--sunk) 50%, transparent); }
424
+ .side a[aria-current] { background: var(--sunk); }
425
+ .side .n { margin-left: auto; color: var(--muted); font-size: 1rem; font-variant-numeric: tabular-nums; }
426
+ .brand { margin: 0.25rem 0.75rem 1.25rem; font-weight: 600; }
427
+ .top { padding: 3.5rem 0 1.5rem; }
428
+ .top h1 { font-size: 3rem; line-height: 1.0833; letter-spacing: -0.003em; }
429
+ button { min-height: 2.75rem; }
430
+ #toast button { min-height: 2.75rem; }
431
+ #toast { bottom: 1.75rem; }
354
432
  main { width: 100%; padding: 0 3rem; }
355
433
  .back { display: none; }
356
434
  .tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@@ -359,13 +437,13 @@
359
437
  .tri { display: grid; grid-template-columns: minmax(16rem, 2fr) minmax(0, 3fr); gap: 2rem; align-items: start; }
360
438
  .queue { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
361
439
  .queue button { display: grid; width: 100%; padding: 0.75rem 1rem; border-color: transparent; background: none; text-align: left; }
362
- .queue button[aria-current] { border-color: var(--line); background: var(--surface); box-shadow: var(--shadow); }
440
+ .queue button[aria-current] { border-color: var(--line); background: var(--surface); }
363
441
  .queue .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
364
442
  .hint, .upnext { display: none; }
365
443
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
366
444
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
367
445
  .rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
368
- .rows.i-answered > li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); }
446
+ .rows.i-answered > li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); }
369
447
  .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
370
448
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
371
449
  .list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
@@ -376,6 +454,7 @@
376
454
  <body>
377
455
  <nav class="side" id="nav" aria-label="Board areas"></nav>
378
456
  <main><p class="banner" id="banner" role="alert" hidden></p><div id="board"></div></main>
457
+ <nav class="tabs" id="tabs" aria-label="Board areas"></nav>
379
458
  <div id="reader" role="dialog" aria-modal="true" aria-labelledby="reader-title" hidden></div>
380
459
  <div id="toast" role="status" hidden><span id="toast-text"></span><button id="undo" hidden>Undo</button></div>
381
460
  <script>
@@ -545,7 +624,7 @@
545
624
  const update = d.type === 'evidence', card = d.evidenceCard, result = RESULT[card?.result];
546
625
  const brow = [update && area(d) !== 'waiting' ? `Update ${d.number}` : kind(d), result ? el('span', { class: `st ${result[0]}` }, result[1]) : '',
547
626
  update && d.filed ? (isToday(d.filed) ? el('span', { class: 'st new' }, 'New') : day(d.filed)) : '', seen].filter(Boolean);
548
- const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...brow) : '', el('h3', { dir: 'auto' }, name));
627
+ const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...(d.type === 'plan' ? [icon('plan')] : []), ...brow) : '', el('h3', { dir: 'auto' }, name));
549
628
  if (d.type === 'signin') return signin(d, lead, saved, err);
550
629
  if (d.type === 'plan') return plan(d, lead);
551
630
  const quiet = !ro && d.options.find((o) => o.value === d.default_if_silent);
@@ -834,9 +913,15 @@
834
913
  }
835
914
 
836
915
  const AREAS = { waiting: 'Waiting on you', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
916
+ // The sidebar and the tab bar name the areas short enough for one line.
917
+ const NAV = { ...AREAS, reports: 'Reports' }, TAB = { ...NAV, waiting: 'Waiting' };
837
918
  const EMPTY = { waiting: 'Nothing is waiting on you.', evidence: 'No evidence updates.', reports: 'No reports or explainers.', answered: 'Nothing answered yet.' };
838
- const ICONS = { summary: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z', waiting: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3 2',
839
- evidence: 'M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15 9h.01', reports: 'M7 3h7l4 4v14H7zM10 12h5M10 16h5', answered: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM8 12l3 3 5-6',
919
+ const ICONS = { summary: 'M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
920
+ waiting: 'M22 12h-6l-2 3h-4l-2-3H2M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z',
921
+ evidence: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1zM9 12l2 2 4-4',
922
+ reports: 'M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
923
+ answered: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM9 12l2 2 4-4',
924
+ plan: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v6h6M8 13h8M8 17h8M8 9h2',
840
925
  you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
841
926
  // The drawings are fixed strings from this page, never text from the board file.
842
927
  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; };
@@ -900,7 +985,7 @@
900
985
  /** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
901
986
  const compact = (d, a) => {
902
987
  const s = state.answers[d.key];
903
- return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
988
+ return [thumb(d.type === 'plan' ? 'plan' : a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
904
989
  ? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label), el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at)))
905
990
  : el('span', { class: 'meta', dir: 'auto' }, sub(d)))];
906
991
  };
@@ -981,18 +1066,110 @@
981
1066
  return box;
982
1067
  }
983
1068
 
1069
+ /** Plan answers stay on the trusted board, never in the agent's sandboxed page. Drafts survive refresh until the server saves them. */
1070
+ function planReview(d, url) {
1071
+ const saved = answerOf(d.key), ro = !!saved || state.readonly || !!FROZEN, draftKey = `plan:${d.key}`;
1072
+ let draft;
1073
+ try { draft = JSON.parse(drafts.get(draftKey) ?? '{}'); } catch { draft = {}; }
1074
+ const notes = new Map((saved?.notes ?? draft.notes ?? []).filter((n) => d.sections.some((s) => s.id === n.section)).map((n) => [n.section, n]));
1075
+ const picks = new Map(Object.entries(saved?.decisions ?? draft.decisions ?? {}).filter(([key, value]) => d.decisions.some((q) => q.key === key && q.options.some((o) => o.value === value))));
1076
+ const remember = () => { drafts.set(draftKey, JSON.stringify({ notes: [...notes.values()], decisions: Object.fromEntries(picks) })); keep(); };
1077
+ const main = el('div', { class: 'plan-main' }), panel = el('aside', { class: 'plan-send' });
1078
+ const status = el('p', { role: 'status' }), err = el('p', { class: 'err', role: 'alert' }), progress = el('div', { class: 'plan-progress' });
1079
+ const send = el('button', { class: 'open-link' }, 'Send back'), next = el('button', { class: 'plan-next' }, 'Go to next');
1080
+ const complete = (s) => { const n = notes.get(s.id); return n?.verdict === 'approve' || (n?.verdict === 'change' && n.note?.trim()); };
1081
+ next.onclick = () => {
1082
+ const section = d.sections.find((s) => !complete(s)), decision = d.decisions.find((q) => !q.options.some((o) => o.value === picks.get(q.key)));
1083
+ document.getElementById(section ? `plan-section-${section.id}` : `plan-decision-${decision?.key}`)?.scrollIntoView({ block: 'start' });
1084
+ };
1085
+ const update = () => {
1086
+ const left = d.sections.filter((s) => !complete(s)).length, decisions = d.decisions.filter((q) => !q.options.some((o) => o.value === picks.get(q.key))).length;
1087
+ status.textContent = saved ? 'Sent back. Your answers are saved.' : left || decisions ? `${left} section${left === 1 ? '' : 's'} and ${decisions} decision${decisions === 1 ? '' : 's'} left` : 'Ready. Every part has an answer.';
1088
+ send.disabled = ro || !!left || !!decisions;
1089
+ next.hidden = !left && !decisions;
1090
+ progress.replaceChildren(...d.sections.map((s) => {
1091
+ const verdict = notes.get(s.id)?.verdict, b = el('button', {}, s.heading, el('span', {}, verdict === 'approve' ? 'Approved' : verdict === 'change' ? 'Change' : 'To review'));
1092
+ b.onclick = () => document.getElementById(`plan-section-${s.id}`).scrollIntoView(); return b;
1093
+ }), d.decisions.length ? el('p', { class: 'meta' }, `${d.decisions.length - decisions} of ${d.decisions.length} decisions answered`) : '');
1094
+ };
1095
+ if (saved) {
1096
+ main.append(el('div', { class: 'plan-sent', role: 'status' }, icon('answered'), el('h2', {}, 'Sent back'), el('p', {}, 'Your section notes and decisions are saved for the agent.'),
1097
+ el('a', { class: 'back', href: '#answered' }, 'See it in Answered')));
1098
+ }
1099
+ main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
1100
+ if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
1101
+ el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length} sections to approve or change, and ${d.decisions.length} decisions to make. Nothing is built until you send back.`));
1102
+ main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note.'));
1103
+ d.sections.forEach((s, i) => {
1104
+ const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
1105
+ const approve = el('button', { class: 'approve', 'aria-label': `Approve, ${s.heading}` }, 'Approve');
1106
+ const change = el('button', { class: 'change', 'aria-label': `Change, ${s.heading}` }, 'Change');
1107
+ const field = el('textarea', { id: `plan-note-${s.id}`, maxlength: '500', placeholder: 'Write your note for the agent' });
1108
+ field.value = notes.get(s.id)?.note ?? '';
1109
+ field.disabled = ro;
1110
+ const note = el('label', { for: field.id }, `What should change in ${s.heading.toLowerCase()}?`, field);
1111
+ const draw = () => {
1112
+ const verdict = notes.get(s.id)?.verdict;
1113
+ card.dataset.verdict = verdict ?? '';
1114
+ approve.setAttribute('aria-pressed', verdict === 'approve');
1115
+ approve.textContent = verdict === 'approve' ? '✓ Approved' : 'Approve';
1116
+ change.setAttribute('aria-pressed', verdict === 'change');
1117
+ approve.disabled = change.disabled = ro;
1118
+ note.hidden = verdict !== 'change';
1119
+ update();
1120
+ };
1121
+ [approve, change].forEach((b, n) => b.onclick = () => {
1122
+ notes.set(s.id, { section: s.id, verdict: n ? 'change' : 'approve', ...(n && { note: field.value }) });
1123
+ remember(); draw(); if (n) field.focus();
1124
+ });
1125
+ field.oninput = () => { notes.set(s.id, { section: s.id, verdict: 'change', note: field.value }); remember(); update(); };
1126
+ card.append(el('h3', { dir: 'auto' }, `${i + 1}. ${s.heading}`), el('p', { dir: 'auto' }, s.body), el('div', { class: 'plan-verdicts', role: 'group', 'aria-label': s.heading }, approve, change), note);
1127
+ main.append(card); draw();
1128
+ });
1129
+ if (d.decisions.length) main.append(el('h2', {}, 'Decisions for you'));
1130
+ d.decisions.forEach((q) => {
1131
+ const choices = el('div', { class: 'choices', role: 'group', 'aria-label': q.question });
1132
+ q.options.forEach((o) => {
1133
+ const b = el('button', { 'aria-pressed': picks.get(q.key) === o.value, ...(ro && { disabled: '' }) }, el('strong', {}, o.label),
1134
+ o.value === q.recommend ? el('span', { class: 'tag' }, 'Recommended') : '', el('span', { class: 'out', dir: 'auto' }, o.outcome));
1135
+ b.onclick = () => { picks.set(q.key, o.value); remember(); [...choices.children].forEach((b, i) => b.setAttribute('aria-pressed', q.options[i].value === o.value)); update(); };
1136
+ choices.append(b);
1137
+ });
1138
+ main.append(el('section', { class: 'plan-decision', id: `plan-decision-${q.key}` }, el('h3', { dir: 'auto' }, q.question), el('p', { class: 'meta', dir: 'auto' }, q.why), choices));
1139
+ });
1140
+ if (d.steps?.length && !saved) {
1141
+ let step = 0;
1142
+ const text = el('p', { 'aria-live': 'polite' }), prev = el('button', { 'aria-label': 'Previous step' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next step' }, 'Next ›');
1143
+ const draw = () => { text.textContent = `Step ${step + 1} of ${d.steps.length}: ${d.steps[step].title}`; prev.disabled = !step; next.disabled = step === d.steps.length - 1; };
1144
+ prev.onclick = () => { step--; draw(); }; next.onclick = () => { step++; draw(); }; draw();
1145
+ main.append(el('section', { class: 'plan-explain' }, el('h3', {}, 'How it works'), text, el('div', { class: 'foot' }, prev, next), el('p', { class: 'meta' }, 'The direction page above explains these steps.')));
1146
+ }
1147
+ send.onclick = () => {
1148
+ send.disabled = true; err.textContent = '';
1149
+ call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key,
1150
+ notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries(picks) }) }, () => {
1151
+ drafts.delete(draftKey); keep(); location.hash = `#answered/${d.key}`;
1152
+ }).catch((e) => { err.textContent = `Couldn't send back: ${e.message}`; update(); });
1153
+ };
1154
+ panel.append(el('h2', {}, 'Your answers'), progress, el('div', { class: 'plan-next-row' }, status, next), send, err); panel.hidden = !!saved; update();
1155
+ return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
1156
+ }
1157
+
984
1158
  /** The report a #reports/<key> link opens (or the plan a #waiting/<key> link opens), over the whole board: a bar with the way back and its title, then its page in a sandboxed frame.
985
1159
  * The frame is made again only for another page, so a redraw never resets a demo halfway through. */
986
1160
  const reader = document.getElementById('reader');
987
1161
  function readPage(d, from = 'reports') {
988
1162
  document.body.classList.toggle('reading', !!d);
989
- for (const n of [document.querySelector('main'), document.getElementById('nav')]) n.inert = !!d;
1163
+ for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
990
1164
  reader.hidden = !d;
991
1165
  const url = d ? src(mockUrl(d.key, '', d.view)) : '';
992
- if (reader.dataset.url === url) return d && (reader.querySelector('h1').textContent = d.title);
1166
+ const answer = d?.type === 'plan' ? JSON.stringify(state.answers[d.key] ?? null) : '';
1167
+ const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
1168
+ if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
1169
+ Object.assign(reader.dataset, { answer, plan });
993
1170
  Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
994
1171
  reader.replaceChildren(...(d ? [el('header', {}, el('a', { class: 'close', href: `#${from}` }, `‹ ${from === 'reports' ? 'Reports' : AREAS[from]}`), el('h1', { id: 'reader-title', tabindex: '-1', dir: 'auto' }, d.title)),
995
- el('iframe', { src: url, title: `${d.type === 'plan' ? 'Plan' : 'Report'}: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1172
+ d.type === 'plan' ? planReview(d, url) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
996
1173
  }
997
1174
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && !reader.hidden) location.hash = `#${reader.dataset.from}`; });
998
1175
 
@@ -1099,7 +1276,9 @@
1099
1276
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
1100
1277
  const [, at, key] = /^#(waiting|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1101
1278
  const link = (a, name, n) => el('a', { href: `#${a}`, ...(a === (at ?? '') && { 'aria-current': 'page' }) }, icon(a || 'summary'), name, n === undefined ? '' : el('span', { class: 'n' }, String(n)));
1102
- document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, AREAS[a], by[a].length)));
1279
+ document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
1280
+ document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
1281
+ ...(a === at && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1103
1282
  const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
1104
1283
  const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: '#' }, '‹ Summary')
1105
1284
  : el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
@@ -1142,7 +1321,8 @@
1142
1321
  // An interactive page may post its own height, `{ height: <px> }`, and its frame then fits it with no blank below.
1143
1322
  // Only a frame's own message sizes that frame, and never past 10000px.
1144
1323
  addEventListener('message', (e) => {
1145
- const frame = [...document.querySelectorAll('.rung iframe')].find((f) => f.contentWindow === e.source), h = e.data?.height;
1324
+ const frame = [...document.querySelectorAll('.rung iframe, iframe.plan-view')].find((f) => f.contentWindow === e.source), h = e.data?.height;
1325
+ if (frame?.classList.contains('plan-view') && e.data?.['sign-off'] === 'ready') frame.contentWindow.postMessage({ 'sign-off': 'size' }, '*');
1146
1326
  if (frame && Number.isFinite(h) && h > 0) frame.style.height = `${Math.ceil(Math.min(h, 10_000))}px`;
1147
1327
  });
1148
1328
  // A design mock page may point (web/point.js): the board keeps the last element named per mock, and turns pointing on for one mock frame.
package/web/point.js CHANGED
@@ -31,6 +31,10 @@
31
31
  };
32
32
  addEventListener('message', (m) => {
33
33
  if (m.source === parent && m.data?.['sign-off'] === 'point') on = m.data.on === true;
34
+ if (m.source === parent && m.data?.['sign-off'] === 'size') {
35
+ const size = () => parent.postMessage({ height: document.documentElement.scrollHeight }, '*');
36
+ new ResizeObserver(size).observe(document.body); size();
37
+ }
34
38
  });
35
39
  for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) addEventListener(type, (m) => {
36
40
  if (!on) return;
package/web/report.html CHANGED
@@ -10,18 +10,19 @@
10
10
  <style>
11
11
  :root {
12
12
  color-scheme: light dark;
13
- --bg: #F7F7F8; --surface: #FFFFFF; --line: #E2E2E6; --text: #16161A; --muted: #5C5C66;
14
- --accent: #5B5BD6; --accent-soft: #ECECFD; --on-accent: #FFFFFF; --ok: #1E7034;
13
+ /* The board's Spacious tokens: --accent is the blue for text and lines, --action the blue a button is filled with. */
14
+ --bg: #F5F5F7; --surface: #FFFFFF; --line: #D2D2D7; --text: #1D1D1F; --muted: #6E6E73;
15
+ --accent: #0066CC; --action: #0071E3; --accent-soft: #E6F1FC; --on-accent: #FFFFFF; --ok: #17702F;
15
16
  }
16
17
  @media (prefers-color-scheme: dark) {
17
- :root { --bg: #111114; --surface: #1C1C21; --line: #2E2E36; --text: #EDEDF0; --muted: #A4A4AE; --accent: #9491FF; --accent-soft: #26264A; --on-accent: #111114; --ok: #51CF66; }
18
+ :root { --bg: #000000; --surface: #161617; --line: #424245; --text: #F5F5F7; --muted: #86868B; --accent: #2997FF; --accent-soft: #19304A; --ok: #30D158; }
18
19
  }
19
20
  * { box-sizing: border-box; }
20
- body { margin: 0; background: var(--bg); color: var(--text); font: 17px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
21
+ body { margin: 0; background: var(--bg); color: var(--text); font: 17px/1.4706 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif; letter-spacing: -0.022em; }
21
22
  main { max-width: 34rem; margin: 0 auto; padding: 1rem 1rem 3rem; display: grid; gap: 1rem; }
22
23
  section { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: 1.25rem 1rem; display: grid; gap: 0.75rem; }
23
24
  h1 { font-size: 1.625rem; line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
24
- h2 { font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0; }
25
+ h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0; }
25
26
  p { margin: 0; }
26
27
  .lead { font-size: 1.125rem; }
27
28
  .asof { font-size: 0.9375rem; color: var(--muted); }
@@ -35,14 +36,14 @@
35
36
  .track::before { content: ""; position: absolute; left: 1.5rem; right: 1.5rem; top: 50%; border-top: 2px solid var(--line); }
36
37
  .track button { position: relative; width: 3rem; height: 3rem; border-radius: 50%; border: 2px solid var(--line); background: var(--surface); color: var(--muted); font: 700 1.0625rem system-ui, sans-serif; cursor: pointer; }
37
38
  .track .done { border-color: var(--accent); color: var(--accent); }
38
- .track .on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
39
+ .track .on { background: var(--action); border-color: var(--action); color: var(--on-accent); }
39
40
  .stage { min-height: 7.5rem; border-radius: 0.75rem; background: var(--accent-soft); padding: 1rem; display: grid; gap: 0.375rem; align-content: start; }
40
41
  .stage .count { font-size: 0.875rem; color: var(--muted); }
41
42
  .stage h3 { margin: 0; font-size: 1.1875rem; }
42
43
  .nav { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
43
44
  .nav button { min-height: 3rem; border-radius: 0.75rem; font: 650 1.0625rem system-ui, sans-serif; cursor: pointer; border: 2px solid var(--accent); }
44
45
  .back { background: transparent; color: var(--accent); }
45
- .next { background: var(--accent); color: var(--on-accent); }
46
+ .next { background: var(--action); border-color: var(--action); color: var(--on-accent); }
46
47
  button:disabled { opacity: 0.4; cursor: default; }
47
48
  button:focus-visible, summary:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
48
49
  summary { min-height: 3rem; display: flex; align-items: center; justify-content: space-between; font-weight: 650; cursor: pointer; list-style: none; }
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones."}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"release-day","title":"Which day: A) today, B) Friday or C) later?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"a","label":"A: One column","outcome":"The form stays in one column."},{"value":"b","label":"B: Split","outcome":"The form sits beside a picture."}],"recommend_value":"a","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"a"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"done","label":"Done","outcome":"The card closes."},{"value":"later","label":"Not now","outcome":"The card waits."}],"recommend_value":"done","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"later"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No"}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Sure","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"release-day","title":"Which day: A) today, B) Friday or C) later?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no","silent_by":"2026-10-17 16:00"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship ship-signin-2 this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Say yes or no: ship the new sign-in screen?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"long-title","title":"Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it Ship it ","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Should we ship the new sign-in screen to every single user this week?","why":"The screen is ready and the release goes out Friday.","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}
@@ -1 +0,0 @@
1
- {"contract":1,"kind":"ask","schema":1,"key":"ship-signin-2","title":"Ship the new sign-in screen this week?","options":[{"value":"yes","label":"Yes","outcome":"It ships to everyone on Friday."},{"value":"no","label":"No","outcome":"It waits for next week's release."}],"recommend_value":"yes","recommend_reason":"Every check passed on 3 phones.","default_if_silent":"no"}