sign-off-axi 0.4.0 → 0.4.1

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)));
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.1",
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; }
@@ -143,15 +157,17 @@
143
157
  /* Tall enough for the tallest column, six dots under its count, so no count reaches the caption above. */
144
158
  .day { position: relative; height: 6.5rem; border-bottom: 1px solid var(--border); }
145
159
  .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; }
160
+ font-size: 0.875rem; font-weight: 700; line-height: 1.2; overflow-wrap: normal; }
147
161
  .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; }
162
+ .hours { display: flex; justify-content: space-between; color: var(--muted); font-size: 0.875rem; font-variant-numeric: tabular-nums; }
149
163
  .events { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.25rem; font-size: 0.9375rem; }
150
164
  .events time, .sec-head .meta { font-weight: 700; font-variant-numeric: tabular-nums; margin-right: 0.5rem; }
151
165
  .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
152
166
  .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); }
167
+ /* A grouped list: hairlines between rows, inset from the left as on iOS. */
168
+ .rows { list-style: none; margin: 0; padding: 0 0 0 1rem; }
169
+ .rows > li { padding-right: 0.5rem; }
170
+ .rows > li + li { border-top: 0.5px solid var(--line); }
155
171
  .compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
156
172
  .compact .t { display: grid; flex: 1; min-width: 0; }
157
173
  /* The answer sits under the title, never beside it: a long answer beside the title squeezed it to a letter a line. */
@@ -169,13 +185,13 @@
169
185
  /* A read-only board is for reading: its pages get most of the screen. */
170
186
  .readonly .rung iframe { height: 80vh; }
171
187
  .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); }
188
+ background: var(--surface); border-radius: 0.875rem; }
189
+ /* Waiting cards carry the accent stripe; answered ones sit flat and quiet. */
190
+ .in-waiting .row { border-left: 3px solid var(--accent); padding-left: calc(1rem - 3px); }
175
191
  .in-answered .row { background: transparent; box-shadow: none; }
176
192
  .in-answered .row h3 { color: var(--muted); font-weight: 500; }
177
193
  /* 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);
194
+ .open-link { display: grid; place-items: center; min-height: 3.5rem; border-radius: 980px; background: var(--action); color: var(--on-accent);
179
195
  font-size: 1.125rem; font-weight: 700; text-decoration: none; }
180
196
  button.open-link:disabled { border: 1px solid var(--line); background: var(--sunk); color: var(--muted); cursor: not-allowed; }
181
197
  .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 +201,15 @@
185
201
  .about { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
186
202
  .row:has(details:not(.note, .other, .tech)[open]) .about { display: none; }
187
203
  .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; }
204
+ summary { cursor: pointer; color: var(--accent); font-size: 0.875rem; margin-top: 0.25rem; width: fit-content; min-height: 2.75rem; }
205
+ .meta { color: var(--muted); font-size: 0.875rem; margin: 0.5rem 0 0; }
190
206
  /* 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
207
  The headline keeps the card's whole width: the dismiss x floats at the corner, never in a row that squeezes the title. */
192
208
  .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; }
209
+ .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
210
  .ask-line { grid-column: 1 / -1; margin: 0 0 -0.375rem; font-weight: 650; }
195
211
  .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; }
212
+ .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
213
  /* Each capture keeps its own shape: the frame is the shot, bounded in height, never letterboxed; two or more share the row. */
198
214
  .media { display: flex; flex-wrap: wrap; align-items: flex-start; gap: .75rem; }
199
215
  .media a { display: block; max-width: 100%; color: var(--accent); cursor: zoom-in; }
@@ -203,7 +219,7 @@
203
219
  .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
220
  /* An explainer's chapters: one tap jumps the video to that part and plays it. */
205
221
  .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; }
222
+ .chapters button { min-height: 2.75rem; padding: 0 0.75rem; border-radius: 999px; border-color: var(--line); font-size: 0.875rem; }
207
223
  .chapters .at { margin-right: 0.375rem; color: var(--muted); font-variant-numeric: tabular-nums; }
208
224
  .meta a { color: var(--accent); }
209
225
  .err:empty { display: none; }
@@ -211,7 +227,7 @@
211
227
  .choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
212
228
  /* The quick answers come first and are big enough for a thumb; typing is last, behind Other answer. */
213
229
  /* 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; }
230
+ .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
231
  .choices .tag { order: -1; margin: 0 0 0.125rem; font-size: 0.875rem; letter-spacing: 0; text-transform: none; }
216
232
  .choices button[aria-pressed="true"]::before { position: absolute; right: 1rem; top: 50%; translate: 0 -50%; margin: 0; }
217
233
  .row > .note, .row > .err { grid-column: 1 / -1; }
@@ -223,23 +239,24 @@
223
239
  .other[open] > summary::after { margin-top: 0.25rem; transform: rotate(-135deg); }
224
240
  .actions .quick { border-radius: 999px; }
225
241
  /* 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); }
242
+ #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;
243
+ display: flex; align-items: center; gap: 0.875rem; padding: 0.5rem 0.5rem 0.5rem 1.125rem; border-radius: 0.875rem;
244
+ background: var(--text); color: var(--bg); font-size: 1rem; }
229
245
  #toast[hidden] { display: none; }
230
246
  #toast span { flex: 1; min-width: 0; }
231
- #toast button { flex: none; background: transparent; color: inherit; border-color: currentColor; font-weight: 600; }
247
+ #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
248
  #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; }
249
+ /* Buttons are pills: grey for the plain ones, filled blue once picked, solid grey when off. 50px on a phone, 44px on a desk. */
250
+ button { min-height: 3.125rem; padding: 0 1.25rem; border: 0; border-radius: 980px; cursor: pointer;
251
+ background: var(--sunk); color: var(--text); font: inherit; font-weight: 500; }
235
252
  /* Recommended is a dashed accent outline and a tag; picked is filled with a check. They never look alike. */
236
253
  button.rec { border: 1.5px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
237
254
  .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; }
255
+ .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; }
256
+ button[aria-pressed="true"] { background: var(--action); color: var(--on-accent); font-weight: 600; }
240
257
  button[aria-pressed="true"]::before { content: "✓" / ""; margin-right: 0.375rem; font-weight: 700; }
241
258
  button[aria-pressed="true"] .tag { color: inherit; }
242
- button:disabled { opacity: 0.6; cursor: progress; }
259
+ button:disabled { cursor: progress; } button:disabled:not([aria-pressed="true"]) { background: var(--sunk); color: var(--muted); }
243
260
  .choices.many { display: flex; flex-wrap: wrap; }
244
261
  /* 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
262
  .choices.told { grid-template-columns: minmax(0, 1fr); }
@@ -250,10 +267,10 @@
250
267
  .ask-why, .silent { margin: 0.375rem 0 0; max-width: 70ch; font-size: 1rem; }
251
268
  .silent { color: var(--muted); }
252
269
  .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; }
270
+ .note label { display: block; color: var(--muted); font-size: 0.875rem; margin-top: 0.5rem; }
254
271
  .tradeoff { margin: 0; max-width: 70ch; }
255
272
  /* 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%;
273
+ .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
274
  background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
258
275
  .x:hover { background: var(--sunk); color: var(--text); }
259
276
  .rows > li { display: flex; align-items: center; }
@@ -277,11 +294,11 @@
277
294
  /* Waiting on you: one row of type chips, and the sort beside them (or below them on a phone). */
278
295
  .filters { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 0.75rem; margin: 0 0 0.75rem; }
279
296
  .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; }
297
+ .chips button { min-height: 2.75rem; padding: 0 0.75rem; border-radius: 999px; font-size: 0.875rem; font-weight: 600; }
281
298
  .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; }
299
+ .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
300
  .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; }
301
+ font-size: 0.875rem; font-weight: 600; line-height: 1.25rem; letter-spacing: 0.04em; text-transform: uppercase; }
285
302
  /* 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
303
  Every option shares the label, frame and button rows, so a label that wraps never pushes one frame below the rest.
287
304
  The row keeps 0.25rem of room on each side for focus rings, given back by the margin. */
@@ -314,7 +331,7 @@
314
331
  .rung iframe { display: block; width: 100%; height: min(18rem, 50vh); border: 0; }
315
332
  .rung .gone { color: var(--danger); margin: 0; }
316
333
  .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);
334
+ .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
335
  background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
319
336
  .round { margin-top: 0.5rem; }
320
337
  .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
@@ -328,6 +345,7 @@
328
345
  @media (min-width: 45rem) {
329
346
  main { padding: 0 2rem; }
330
347
  .row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 0.875rem 2rem; padding: 1.125rem 1.25rem; }
348
+ .in-waiting .row { padding-left: calc(1.25rem - 3px); }
331
349
  .row.design, .row.update { grid-template-columns: minmax(0, 1fr); gap: 0.875rem; }
332
350
  /* The frames share the card width down to 11rem each; only a list longer than that scrolls. */
333
351
  .mocks { grid-auto-columns: minmax(11rem, calc(var(--tall) * 9 / 19.5)); }
@@ -344,13 +362,21 @@
344
362
  }
345
363
  /* A wide screen keeps the areas in a sidebar; the Summary puts its tiles in one row and its lists in two columns. */
346
364
  @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; }
365
+ :root { --bg: var(--desk); --surface: var(--desk-tile); }
366
+ body { display: grid; grid-template-columns: 16.25rem minmax(0, 1fr); padding-bottom: 3rem; }
367
+ .tabs { display: none; }
368
+ .side { display: flex; flex-direction: column; gap: 0.125rem; position: sticky; top: 0; height: 100vh; padding: 1.25rem 0.75rem;
369
+ border-right: 1px solid var(--line); background: var(--surface); }
370
+ .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; }
371
+ .side a:hover { background: color-mix(in srgb, var(--sunk) 50%, transparent); }
372
+ .side a[aria-current] { background: var(--sunk); }
373
+ .side .n { margin-left: auto; color: var(--muted); font-size: 1rem; font-variant-numeric: tabular-nums; }
374
+ .brand { margin: 0.25rem 0.75rem 1.25rem; font-weight: 600; }
375
+ .top { padding: 3.5rem 0 1.5rem; }
376
+ .top h1 { font-size: 3rem; line-height: 1.0833; letter-spacing: -0.003em; }
377
+ button { min-height: 2.75rem; }
378
+ #toast button { min-height: 2.75rem; }
379
+ #toast { bottom: 1.75rem; }
354
380
  main { width: 100%; padding: 0 3rem; }
355
381
  .back { display: none; }
356
382
  .tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@@ -359,13 +385,13 @@
359
385
  .tri { display: grid; grid-template-columns: minmax(16rem, 2fr) minmax(0, 3fr); gap: 2rem; align-items: start; }
360
386
  .queue { display: grid; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
361
387
  .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); }
388
+ .queue button[aria-current] { border-color: var(--line); background: var(--surface); }
363
389
  .queue .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
364
390
  .hint, .upnext { display: none; }
365
391
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
366
392
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
367
393
  .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); }
394
+ .rows.i-answered > li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); }
369
395
  .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
370
396
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
371
397
  .list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
@@ -376,6 +402,7 @@
376
402
  <body>
377
403
  <nav class="side" id="nav" aria-label="Board areas"></nav>
378
404
  <main><p class="banner" id="banner" role="alert" hidden></p><div id="board"></div></main>
405
+ <nav class="tabs" id="tabs" aria-label="Board areas"></nav>
379
406
  <div id="reader" role="dialog" aria-modal="true" aria-labelledby="reader-title" hidden></div>
380
407
  <div id="toast" role="status" hidden><span id="toast-text"></span><button id="undo" hidden>Undo</button></div>
381
408
  <script>
@@ -834,9 +861,14 @@
834
861
  }
835
862
 
836
863
  const AREAS = { waiting: 'Waiting on you', evidence: 'Evidence', reports: 'Reports and explainers', answered: 'Answered' };
864
+ // The sidebar and the tab bar name the areas short enough for one line.
865
+ const NAV = { ...AREAS, reports: 'Reports' }, TAB = { ...NAV, waiting: 'Waiting' };
837
866
  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',
867
+ 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',
868
+ 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',
869
+ 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',
870
+ 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',
871
+ answered: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM9 12l2 2 4-4',
840
872
  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
873
  // The drawings are fixed strings from this page, never text from the board file.
842
874
  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; };
@@ -986,7 +1018,7 @@
986
1018
  const reader = document.getElementById('reader');
987
1019
  function readPage(d, from = 'reports') {
988
1020
  document.body.classList.toggle('reading', !!d);
989
- for (const n of [document.querySelector('main'), document.getElementById('nav')]) n.inert = !!d;
1021
+ for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
990
1022
  reader.hidden = !d;
991
1023
  const url = d ? src(mockUrl(d.key, '', d.view)) : '';
992
1024
  if (reader.dataset.url === url) return d && (reader.querySelector('h1').textContent = d.title);
@@ -1099,7 +1131,9 @@
1099
1131
  by.answered.sort((x, y) => state.answers[y.key].at.localeCompare(state.answers[x.key].at));
1100
1132
  const [, at, key] = /^#(waiting|evidence|reports|answered)(?:\/([\w.-]+))?$/.exec(location.hash) ?? [];
1101
1133
  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)));
1134
+ document.getElementById('nav').replaceChildren(el('p', { class: 'brand' }, 'sign-off'), link('', 'Summary'), ...Object.keys(AREAS).map((a) => link(a, NAV[a], by[a].length)));
1135
+ document.getElementById('tabs').replaceChildren(...Object.keys(AREAS).map((a) => el('a', { href: `#${a}`, 'aria-label': `${AREAS[a]}, ${by[a].length}`,
1136
+ ...(a === at && { 'aria-current': 'page' }) }, icon(a), TAB[a])));
1103
1137
  const ro = state.readonly ? 'Read only' : FROZEN ? `Read-only copy, ${FROZEN.exported}` : '';
1104
1138
  const top = el('header', { class: 'top' }, at ? el('a', { class: 'back', href: '#' }, '‹ Summary')
1105
1139
  : el('p', { class: 'date' }, new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })),
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"}