sealkeep 0.6.2 → 0.7.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/CHANGELOG.md CHANGED
@@ -3,6 +3,39 @@
3
3
  Notable changes, by published version. Sealkeep is pre-1.0: minor versions
4
4
  may change behavior, and say so here when they do.
5
5
 
6
+ ## 0.7.1 — 2026-08-20
7
+
8
+ The dashboard and the account panel are one design system again.
9
+
10
+ - `design-system/tokens.css` is the single source; `npm run tokens` injects it
11
+ into both surfaces and `--check` fails on drift, with the suite enforcing it.
12
+ - The rule it encodes: monospace is what a machine said — a size, a path, a
13
+ state, a command — and sans is what we said. The dashboard had been running
14
+ every sentence in monospace because `--sans` was aliased to `--mono`, so its
15
+ option descriptions and standing notes read as terminal output. They read as
16
+ English now; every number, path and identifier is untouched.
17
+
18
+ ## 0.7.0 — 2026-08-20
19
+
20
+ Two reviews — does each section deserve to exist, and can a person finish the
21
+ job it promises — and the answers, applied.
22
+
23
+ - **Setup no longer argues for the unrecoverable mistake.** The password path
24
+ said skipping the recovery phrase was "fine". Your password opens the vault on
25
+ that machine only; the screen now says so, names the day it matters, and shows
26
+ the 24 words as what survives it.
27
+ - The account panel leads with the Recovery key card instead of burying it ninth
28
+ of eleven, merges Plan into Your package (they answered the same question),
29
+ makes the package tiers actually start a change instead of looking clickable
30
+ and doing nothing, warns "Delete forever?" with the stake named, puts
31
+ connecting a machine above the list of machines, and stops calling an
32
+ account-wide upload rule "what leaves this machine".
33
+ - Revoked machines fold into one line — they are kept as the record, never
34
+ deleted, but no longer bury the machines that can actually write.
35
+ - `sealkeep doctor` warns when a routing rule pins a project this machine has
36
+ never sealed. The panel cannot check that — project names never reach us —
37
+ so the machine that knows does.
38
+
6
39
  ## 0.6.2 — 2026-08-20
7
40
 
8
41
  - New: `sealkeep projects` lists the project names this vault knows, with how
@@ -22,11 +22,23 @@
22
22
  field on it is always, only, encrypted material.
23
23
  --------------------------------------------------------------------- */
24
24
  :root{
25
- --paper:#f4f4f3; --card:#fbfbfa; --ink:#111316; --soft:#5e646c; --rule:#dadcde;
26
- --seal:#101a3d; --seal-ink:#96a6e0; --live:#2a3fb0; --warn:#8c3220; --warn-bg:#f4e6e2;
27
- --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,"Helvetica Neue",sans-serif;
28
- --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,"DejaVu Sans Mono",Consolas,monospace;
29
- --gut:2.6rem;
25
+ /* @tokens:start — generated from design-system/tokens.css, do not edit here */
26
+ --paper: #f4f4f3;
27
+ --card: #fbfbfa;
28
+ --ink: #111316;
29
+ --soft: #5e646c;
30
+ --rule: #dadcde;
31
+ --seal: #101a3d;
32
+ --seal-ink: #96a6e0;
33
+ --live: #2a3fb0;
34
+ --warn: #8c3220;
35
+ --warn-bg: #f4e6e2;
36
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, "Helvetica Neue", sans-serif;
37
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace;
38
+ /* @tokens:end */
39
+
40
+ /* Layout constant for this page's spine; not part of the shared palette. */
41
+ --gut: 2.6rem;
30
42
  }
31
43
  @media(prefers-color-scheme:dark){:root{
32
44
  --paper:#0d0f11; --card:#14171a; --ink:#e7e9ec; --soft:#8d949c; --rule:#24282d;
@@ -216,6 +228,10 @@
216
228
  .acct-nav{display:flex;gap:.35rem;flex-wrap:wrap;position:sticky;top:0;z-index:5;background:var(--paper);padding:.65rem 0;margin:.4rem 0 .2rem;border-bottom:1px solid var(--rule)}
217
229
  .acct-nav a{font-family:var(--mono);font-size:.74rem;color:var(--soft);text-decoration:none;border:1px solid var(--rule);border-radius:999px;padding:.4rem .85rem;min-height:26px;display:inline-flex;align-items:center}
218
230
  .acct-nav a:hover{color:var(--ink);border-color:var(--ink)}
231
+ #d-retired details{margin:.5rem 0 .2rem}
232
+ #d-retired summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;padding:.35rem 0}
233
+ #d-retired summary:hover{color:var(--ink)}
234
+ #d-retired .lines li{opacity:.75}
219
235
  .acct-hero{font-family:var(--mono);font-size:1.55rem;font-weight:700;letter-spacing:-.03em;margin:.9rem 0 0;line-height:1.3}
220
236
  h1.acct-hero:empty{display:none}
221
237
  .ruleslist{margin:.3rem 0 .9rem;padding-left:1.1rem}
@@ -611,6 +627,12 @@ sealkeep open 08424eb6… ./session.jsonl \
611
627
  <span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
612
628
  </div>
613
629
 
630
+ <div class="card" id="c-recovery"><span id="anchor-recovery"></span>
631
+ <span class="eyebrow">Recovery key</span>
632
+ <h2 id="e-head">Loading…</h2>
633
+ <p id="e-text"></p>
634
+ </div>
635
+
614
636
  <div class="card" id="anchor-storage">
615
637
  <span class="eyebrow">Storage</span>
616
638
  <div id="a-store-free" style="display:none">
@@ -662,7 +684,9 @@ sealkeep open 08424eb6… ./session.jsonl \
662
684
  <span class="eyebrow">Machines</span>
663
685
  <h2>Connected machines</h2>
664
686
  <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
687
+ <div id="pair-top"></div>
665
688
  <ul class="lines" id="d-list"></ul>
689
+ <div id="d-retired"></div>
666
690
  <p class="empty" id="d-msg">Loading…</p>
667
691
 
668
692
  <!-- One pairing block exists on the page. It leads the panel while no
@@ -700,44 +724,36 @@ sealkeep open 08424eb6… ./session.jsonl \
700
724
  <p class="msg" id="s-msg">Loading…</p>
701
725
  <div id="s-wrap" style="display:none;overflow-x:auto">
702
726
  <table>
703
- <thead><tr><th>Archive</th><th>Contents</th><th>State</th><th>Added</th><th></th></tr></thead>
727
+ <thead><tr><th>Archive</th><th>Size</th><th>State</th><th>Added</th><th></th></tr></thead>
704
728
  <tbody id="s-body"></tbody>
705
729
  </table>
706
730
  <p class="msg" id="s-foot"></p>
707
731
  </div>
708
732
  </div>
709
733
 
710
- <div class="card" id="c-plan"><span id="anchor-plan"></span>
711
- <span class="eyebrow">Plan</span>
712
- <div id="a-plan-free" style="display:none">
713
- <h2>Free — your bucket, your bill</h2>
714
- <p>Unlimited archives with the same encryption. You supply the storage credentials and pay your provider directly.</p>
715
- <p class="empty">Want us to run storage instead? Pricing is in Your package, below. Write to <span id="a-sales">hello@spala.ai</span> from this address and we will switch it on.</p>
716
- </div>
717
- <div id="a-plan-paid" style="display:none">
718
- <h2>Managed storage</h2>
719
- <p>We run the bucket and meter what you store. We still hold no key that opens any of it — the only difference from your own bucket is who pays the storage bill and who can delete it.</p>
720
- <div class="kv" style="margin-top:1rem">
721
- <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
722
- <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
723
- <div><span class="k">Billed through</span><b id="a-sub-prov">—</b></div>
724
- </div>
725
- <p class="empty" id="a-sub-note"></p>
726
- </div>
727
- </div>
734
+
728
735
 
729
736
  <div class="card" id="a-pack-card">
737
+ <span id="anchor-plan"></span>
730
738
  <span class="eyebrow">Your package</span>
731
739
  <h2 id="a-pack-current">—</h2>
732
740
  <p class="msg" id="a-pack-context" style="margin:0 0 .8rem"></p>
733
741
  <div class="packrow" id="a-packs"></div>
742
+ <p class="empty" id="a-plan-free-note"></p>
734
743
  <p class="empty" id="a-pack-alt">Not storing with us? Your own bucket is free with the same encryption, and Pro Lite keeps key escrow and machine sign-in with no metered storage.</p>
744
+ <div class="kv" id="a-sub-facts" style="margin-top:1rem">
745
+ <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
746
+ <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
747
+ <div><span class="k">Billed through</span><b id="a-sub-prov">—</b></div>
748
+ </div>
749
+ <p class="empty" id="a-sub-note"></p>
735
750
  <p class="why" id="a-pack-note">Changing your package is handled by hand today. Email hello@spala.ai from this address and we will move you within one business day — your archives keep uploading throughout.</p>
736
751
  </div>
737
752
 
738
753
  <div class="card">
739
- <span class="eyebrow">What leaves this machine</span>
754
+ <span class="eyebrow">What gets uploaded</span>
740
755
  <h2>Your upload rules</h2>
756
+ <p class="why" style="margin:.1rem 0 .8rem">Saved to your account — every machine you connect follows these, the same way routing rules do.</p>
741
757
  <p>Everything you do not choose here stays local and is never uploaded.</p>
742
758
  <p class="empty" id="r-note" style="margin-bottom:.9rem">Enforced on your machine, before a session is read or sealed. Anything held back is never uploaded, so this is a rule about what leaves — not a filter we apply after it arrives.</p>
743
759
  <div class="rules">
@@ -764,11 +780,7 @@ sealkeep open 08424eb6… ./session.jsonl \
764
780
  </div>
765
781
  </div>
766
782
 
767
- <div class="card" id="c-recovery"><span id="anchor-recovery"></span>
768
- <span class="eyebrow">Recovery key</span>
769
- <h2 id="e-head">Loading…</h2>
770
- <p id="e-text"></p>
771
- </div>
783
+
772
784
 
773
785
  <div class="card">
774
786
  <span class="eyebrow">Activity</span>
@@ -1051,7 +1063,7 @@ sealkeep open 08424eb6… ./session.jsonl \
1051
1063
  $("a-name").value = user.label || "";
1052
1064
  $("a-plan").textContent = paid ? "Managed storage" : "Free";
1053
1065
  $("a-plan").className = "chip" + (paid ? " pro" : "");
1054
- $("a-plan").onclick = () => goTo($("c-plan"));
1066
+ $("a-plan").onclick = () => goTo($("anchor-plan"));
1055
1067
  $("a-who").textContent = [
1056
1068
  user.email,
1057
1069
  "joined " + day(user.created_at),
@@ -1069,8 +1081,13 @@ sealkeep open 08424eb6… ./session.jsonl \
1069
1081
  lastPaid = paid;
1070
1082
  applyStorageView();
1071
1083
  on("anchor-archives", paid); on("nav-archives", paid);
1072
- on("a-plan-free", !paid);
1073
- on("a-plan-paid", paid);
1084
+ // One card carries billing now. A free account is told what managed
1085
+ // storage would give it; a paying one is told the state of what it bought.
1086
+ $("a-plan-free-note").textContent = paid
1087
+ ? ""
1088
+ : "You are on your own storage: unlimited archives, same encryption, your provider's bill. Managed storage means we run the bucket and meter what you store — we still hold no key that opens any of it.";
1089
+ on("a-sub-facts", paid);
1090
+ on("a-plan-free-note", !paid);
1074
1091
 
1075
1092
  if (paid) {
1076
1093
  const quota = Number(user.quota_bytes) || 0, used = Number(user.used_bytes) || 0;
@@ -1117,7 +1134,37 @@ sealkeep open 08424eb6… ./session.jsonl \
1117
1134
  return deviceCount;
1118
1135
  }
1119
1136
  $("d-msg").textContent = "";
1120
- for (const d of devices) {
1137
+ // Revocation never deletes a machine — the row stays as the record that
1138
+ // it once had access. On an account that has re-paired a few times that
1139
+ // buries the two machines actually running under a wall of history: 29
1140
+ // rows, 1666 px, nearly two screens, all looking alike. Active machines
1141
+ // are the answer to "what can write to my account right now"; the rest
1142
+ // fold away, counted and reachable, deleted by nobody.
1143
+ const active = devices.filter((d) => d.state !== "revoked");
1144
+ const retired = devices.filter((d) => d.state === "revoked");
1145
+ const foldHost = $("d-retired");
1146
+ foldHost.replaceChildren();
1147
+ if (retired.length) {
1148
+ const fold = document.createElement("details");
1149
+ const summary = document.createElement("summary");
1150
+ summary.textContent = `${retired.length} revoked machine${retired.length === 1 ? "" : "s"} — kept as the record, none can write`;
1151
+ const inner = document.createElement("ul");
1152
+ inner.className = "lines";
1153
+ for (const d of retired) {
1154
+ const li = document.createElement("li");
1155
+ const name = document.createElement("span");
1156
+ name.className = "grow";
1157
+ name.textContent = d.label || "Unnamed machine";
1158
+ const when = document.createElement("span");
1159
+ when.className = "when";
1160
+ when.textContent = "revoked " + ago(d.revoked_at);
1161
+ li.append(name, when);
1162
+ inner.append(li);
1163
+ }
1164
+ fold.append(summary, inner);
1165
+ foldHost.append(fold);
1166
+ }
1167
+ for (const d of active) {
1121
1168
  const li = document.createElement("li");
1122
1169
  const name = document.createElement("span");
1123
1170
  name.className = "grow";
@@ -1138,7 +1185,13 @@ sealkeep open 08424eb6… ./session.jsonl \
1138
1185
  // Two-step, like archive deletion: revoking locks a machine out of
1139
1186
  // its own backups, so a stray click must not do it.
1140
1187
  if (btn.dataset.armed !== "yes") {
1141
- btn.dataset.armed = "yes"; btn.textContent = "Confirm";
1188
+ btn.dataset.armed = "yes";
1189
+ // The local dashboard escalates to "Destroy forever?" when it can
1190
+ // prove a copy is the last one. This page cannot see the customer's
1191
+ // disk, so it must not claim either way — it names the stake and
1192
+ // lets them decide.
1193
+ btn.textContent = "Delete forever?";
1194
+ btn.title = "If your machine already reclaimed the source, this is the only copy";
1142
1195
  setTimeout(() => {
1143
1196
  if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Revoke"; }
1144
1197
  }, 4000);
@@ -1489,7 +1542,7 @@ sealkeep open 08424eb6… ./session.jsonl \
1489
1542
  : paidPlan ? "Sealkeep cloud" : "the bucket each machine is configured with");
1490
1543
  const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1491
1544
  on("a-firstrun", nothingYet);
1492
- $(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
1545
+ $(nothingYet ? "pair-home-first" : "pair-top").append($("pair"));
1493
1546
  $("pair-side").textContent = nothingYet ? "Your pairing code" : "Pairing code";
1494
1547
  if (devices === 0)
1495
1548
  $("d-msg").textContent = nothingYet
@@ -1742,8 +1795,16 @@ sealkeep open 08424eb6… ./session.jsonl \
1742
1795
  const host = $("a-packs");
1743
1796
  while (host.firstChild) host.removeChild(host.firstChild);
1744
1797
  for (const pack of PACKAGES.filter((p) => p.managed)) {
1745
- const chip = document.createElement("span");
1798
+ const chip = document.createElement(pack.id === current ? "span" : "a");
1746
1799
  chip.className = "packchip" + (pack.id === current ? " now" : "");
1800
+ if (pack.id !== current) {
1801
+ // It looked like a picker and did nothing when clicked. Changing a
1802
+ // package is handled by a person today, so the chip starts that
1803
+ // message rather than pretending to be a checkout.
1804
+ chip.href = "mailto:hello@spala.ai?subject=" + encodeURIComponent(`Move me to ${pack.name}`)
1805
+ + "&body=" + encodeURIComponent(`Please move my account to ${pack.name}${pack.managed ? ` ($${pack.monthly} a month)` : ""}.`);
1806
+ chip.title = `Ask us to move you to ${pack.name}`;
1807
+ }
1747
1808
  const name = document.createElement("b"); name.textContent = pack.name;
1748
1809
  const price = document.createElement("span"); price.textContent = `$${pack.monthly}/mo`;
1749
1810
  chip.append(name, price);
@@ -169,6 +169,44 @@ export async function runDoctor(dataDir, env = process.env) {
169
169
  }
170
170
  }
171
171
  catch { /* coverage is advisory; a broken sidecar must not fail doctor */ }
172
+ // A routing rule can pin a project to a destination, and the account panel
173
+ // cannot check the name: project names never reach the cloud. So a typo is
174
+ // saved happily and silently routes nothing, forever. This machine knows the
175
+ // real names, so it is the one that can say.
176
+ try {
177
+ const { resolveTargets } = await import("./storage-targets.js");
178
+ const targets = await resolveTargets(dataDir);
179
+ const pinned = [...new Set(targets.flatMap((target) => target.projects ?? []))];
180
+ if (pinned.length > 0) {
181
+ const { listArchives } = await import("./vault.js");
182
+ const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
183
+ const records = await listArchives(dataDir).catch(() => []);
184
+ const known = new Set();
185
+ for (const record of records) {
186
+ try {
187
+ const name = record.source.agent === "claude"
188
+ ? claudeProjectFromPath(record.source.path).project
189
+ : record.source.agent === "codex"
190
+ ? (await codexProjectFromRollout(record.source.path, record.createdAt)).project
191
+ : null;
192
+ if (name)
193
+ known.add(name);
194
+ }
195
+ catch { /* a project we cannot read is not evidence of a bad pin */ }
196
+ }
197
+ const unmatched = pinned.filter((name) => !known.has(name));
198
+ if (unmatched.length === 0) {
199
+ checks.push({ name: "routing-pins", status: "pass", detail: `${pinned.length} pinned project${pinned.length === 1 ? "" : "s"} match sessions on this machine.` });
200
+ }
201
+ else {
202
+ checks.push({
203
+ name: "routing-pins", status: "warn",
204
+ detail: `${unmatched.length === pinned.length ? "No" : unmatched.length} pinned project${unmatched.length === 1 ? "" : "s"} match anything sealed here: ${unmatched.slice(0, 3).join(", ")}. Those rules route nothing. Run \`sealkeep projects\` for the names this vault actually knows${known.size ? `, such as ${[...known].slice(0, 2).join(", ")}` : ""}.`
205
+ });
206
+ }
207
+ }
208
+ }
209
+ catch { /* routing rules are advisory here; never fail doctor over them */ }
172
210
  // Where the daemon actually looks: an explicit env var, or the phrase the
173
211
  // wizard put in this machine's keystore. This used to test only the env var,
174
212
  // so a machine set up correctly through the window — phrase in the keystore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -41,7 +41,8 @@
41
41
  "build:site": "node tools/build-site.mjs",
42
42
  "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\"",
43
43
  "acceptance": "node scripts/acceptance.mjs",
44
- "acceptance:managed": "node scripts/acceptance.mjs --managed"
44
+ "acceptance:managed": "node scripts/acceptance.mjs --managed",
45
+ "tokens": "node scripts/sync-tokens.mjs"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^22.10.2",
package/web/setup.html CHANGED
@@ -267,12 +267,12 @@
267
267
  <p>It unlocks with your password on this machine. If you ever lose the password, the phrase below is the only other way in — which is a good reason to spend a minute on it now.</p>
268
268
  </div>
269
269
  <details class="panel pad explain" id="phrase-fold">
270
- <summary>Prefer a recovery phrase too?</summary>
270
+ <summary>Show the 24 words that survive this machine</summary>
271
271
  <p id="fold-note"></p>
272
272
  <ol class="phrase" id="fold-phrase-words"></ol>
273
273
  </details>
274
274
  <div class="wizfoot">
275
- <p class="note">Skipping the phrase is fine — your password already opens this vault.</p>
275
+ <p class="note warn"><b>Your password opens this vault on this machine only.</b> It is kept in this machine's keystore — it does not travel. If this machine is lost or wiped, the 24 words above are the only thing that opens the archives, and nobody, including us, can recreate them. Take them now, or accept that this vault dies with this machine.</p>
276
276
  <div class="actions"><button type="button" class="action primary" id="finish-password">Finish setup</button></div>
277
277
  </div>
278
278
  </div>
package/web/style.css CHANGED
@@ -10,12 +10,7 @@
10
10
  */
11
11
 
12
12
  :root {
13
- /* Vaultline design system — the ten tokens from site/index.html, the source
14
- of truth (see design-system/README.md). The older dashboard names below are
15
- kept as aliases so existing rules resolve to the system's colours without a
16
- full rename. The page is achromatic: the ONE saturated block is ciphertext
17
- (--seal); --live marks what is interactive or currently true; --warn is
18
- rationed to where inaction loses data. */
13
+ /* @tokens:start — generated from design-system/tokens.css, do not edit here */
19
14
  --paper: #f4f4f3;
20
15
  --card: #fbfbfa;
21
16
  --ink: #111316;
@@ -26,14 +21,17 @@
26
21
  --live: #2a3fb0;
27
22
  --warn: #8c3220;
28
23
  --warn-bg: #f4e6e2;
29
-
24
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, "Helvetica Neue", sans-serif;
30
25
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace;
31
- /* One typeface: monospace, including display sizes. --sans is aliased to it so
32
- any rule still asking for a second face complies instead of breaking the
33
- rule the whole system is built on. */
34
- --sans: var(--mono);
26
+ /* @tokens:end */
35
27
 
36
- /* Compatibility aliases: prior dashboard rules reference these names. */
28
+ /* Surface-local aliases: older dashboard rules ask for these names, and they
29
+ resolve to the system's colours rather than a second palette. Kept outside
30
+ the generated block on purpose — the token file describes the design
31
+ system, this describes one surface's history. */
32
+ /* Layout constants for this surface, not part of the shared palette. */
33
+ --rail: 264px;
34
+ --gap: 20px;
37
35
  --panel: var(--card);
38
36
  --rule-soft: #e6e7e9;
39
37
  --text: var(--ink);
@@ -43,17 +41,8 @@
43
41
  --verified: var(--live);
44
42
  --held: var(--soft);
45
43
  --alert: var(--warn);
46
- /* Were dark-navy fills for the old rail; now the solid-ink fill the system
47
- uses for a filled control. Lazy substitution keeps them theme-correct. */
48
44
  --ink-2: var(--ink);
49
45
  --ink-3: var(--soft);
50
- /* Rail is now part of the light page, not a dark slab. These name faint
51
- surfaces used for nav hover/active and glyphs. */
52
- --rail-active: #ecedf0;
53
- --rail-glyph: #9aa0a8;
54
-
55
- --rail: 264px;
56
- --gap: 20px;
57
46
  }
58
47
 
59
48
  /* ── Dark ─────────────────────────────────────────────── */
@@ -176,7 +165,9 @@ nav button .count { margin-left: auto; font-size: 11.5px; color: var(--soft); }
176
165
  margin-top: auto;
177
166
  border-top: 1px solid var(--rule);
178
167
  padding: 18px 12px 0;
179
- font-family: var(--mono);
168
+ /* Prose, so it reads as prose. Mono is reserved for what a machine said —
169
+ a size, a path, a state, a command — and this is us explaining the page. */
170
+ font-family: var(--sans);
180
171
  font-size: 12px;
181
172
  color: var(--soft);
182
173
  line-height: 1.65;
@@ -674,6 +665,16 @@ nav.steps .step.locked.current:hover { background: var(--ink-2); color: #fff; }
674
665
  .callout h3 { margin: 0; }
675
666
  .callout h3 + p { margin-top: -6px; }
676
667
  .callout.warn { border-left: 3px solid var(--alert); }
668
+ /* A note that carries a consequence reads like one. Used where setup asks
669
+ whether to keep the 24 words: the copy there used to call skipping them
670
+ "fine", which is true until the machine is lost and then permanently false. */
671
+ .note.warn {
672
+ border-left: 3px solid var(--alert);
673
+ padding: 10px 12px;
674
+ background: color-mix(in srgb, var(--alert) 7%, transparent);
675
+ color: var(--ink);
676
+ }
677
+ .note.warn b { color: var(--alert); }
677
678
  .callout.warn p { color: var(--text); }
678
679
  ul.plain { margin: 0; padding-left: 19px; display: grid; gap: 8px; color: var(--text-2); max-width: 72ch; }
679
680
  ul.plain b { color: var(--text); }