@monster-orange/den 0.4.14 → 0.4.15

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +144 -8
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +33 -4
  7. package/src/shared/agents/den-claude.ts +13 -3
  8. package/src/shared/agents/records.ts +18 -0
  9. package/src/shared/agents/runtime.ts +6 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/web-session.ts +64 -5
  15. package/src/ui/cli/agent-ops.ts +75 -22
  16. package/src/ui/cli/agents-command.ts +4 -1
  17. package/src/ui/cli/doctor-command.ts +29 -3
  18. package/src/ui/cli/doctor-onboard.ts +26 -0
  19. package/src/ui/cli/flag-parser.ts +9 -1
  20. package/src/ui/cli/index.ts +29 -3
  21. package/src/ui/cli/onboard-command.ts +180 -0
  22. package/src/ui/cli/onboard-reads.ts +613 -0
  23. package/src/ui/cli/repo-command.ts +156 -10
  24. package/src/ui/cli/web-command.ts +92 -1
  25. package/src/ui/mcp/api.ts +14 -2
  26. package/src/ui/mcp/server.ts +12 -2
  27. package/src/ui/mcp/tools.ts +368 -1
  28. package/src/ui/server/agent-routes.ts +2 -5
  29. package/src/ui/server/approval-routes.ts +390 -0
  30. package/src/ui/server/approvals.ts +416 -0
  31. package/src/ui/server/github-routes.ts +12 -4
  32. package/src/ui/server/held-actions.ts +203 -0
  33. package/src/ui/server/http-helpers.ts +35 -0
  34. package/src/ui/server/index.ts +286 -16
  35. package/src/ui/server/lifecycle-exec.ts +70 -0
  36. package/src/ui/server/onboard-routes.ts +132 -0
  37. package/src/ui/server/pair-routes.ts +7 -4
  38. package/src/ui/server/public/agents-page.js +91 -4
  39. package/src/ui/server/public/error-page.js +27 -3
  40. package/src/ui/server/public/header.js +1 -1
  41. package/src/ui/server/public/i18n/es.js +29 -0
  42. package/src/ui/server/public/i18n/ja.js +31 -0
  43. package/src/ui/server/public/index.html +1 -1
  44. package/src/ui/server/public/local-dev.html +5 -4
  45. package/src/ui/server/public/persona-ui.css +21 -0
  46. package/src/ui/server/public/release.html +0 -1
  47. package/src/ui/server/public/settings-registry.js +3 -1
  48. package/src/ui/server/public/settings.html +9 -1
  49. package/src/ui/server/public/shell.js +313 -0
  50. package/src/ui/server/request-guard.ts +2 -0
  51. package/src/ui/server/route-classes.ts +28 -1
  52. package/src/ui/server/session-routes.ts +11 -3
  53. package/src/ui/server/workspace-routes.ts +21 -10
  54. package/themes/den/den-theme.json +6 -6
@@ -22,8 +22,8 @@ export interface PairRouteDeps {
22
22
  pairs: PairRequests;
23
23
  sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
24
24
  readBody: (req: http.IncomingMessage) => Promise<string>;
25
- /** A new browser session for this request (via "code"); its Set-Cookie line. */
26
- signIn: (req: http.IncomingMessage) => string;
25
+ /** A new browser session for this request (via "code"): its Set-Cookie line and approval key. */
26
+ signIn: (req: http.IncomingMessage) => { cookie: string; approveKey: string };
27
27
  /** The client's address for a new request's record, when Den knows it. */
28
28
  client: (req: http.IncomingMessage) => string | undefined;
29
29
  /** The commands that approve `code`: on the server, and by ssh from the browser's machine. */
@@ -132,8 +132,11 @@ export async function handlePairRoutes(
132
132
  deps.sendJson(res, { ok: false, state: "gone" }, 404);
133
133
  return true;
134
134
  }
135
- res.setHeader("Set-Cookie", deps.signIn(req));
136
- deps.sendJson(res, { ok: true, state: "ok" });
135
+ const s = deps.signIn(req);
136
+ res.setHeader("Set-Cookie", s.cookie);
137
+ res.setHeader("Cache-Control", "no-store");
138
+ // The approval key goes to this answer only (web-session.ts).
139
+ deps.sendJson(res, { ok: true, state: "ok", approveKey: s.approveKey });
137
140
  return true;
138
141
  }
139
142
  deps.sendJson(res, {
@@ -1702,6 +1702,12 @@ function paintDetails() {
1702
1702
  * and the dialog says so.
1703
1703
  */
1704
1704
  function paintRuntime(reset) {
1705
+ paintRuntimeFields(reset);
1706
+ // Last: a Name that names a running agent disables Start (AG-10.10b).
1707
+ paintStartsAs();
1708
+ }
1709
+
1710
+ function paintRuntimeFields(reset) {
1705
1711
  const rs = $d("#ag-runtime");
1706
1712
  const go = $d("#ag-go");
1707
1713
  const ready = $d("#ag-r-ready");
@@ -1712,7 +1718,6 @@ function paintRuntime(reset) {
1712
1718
  $d("#ag-task-l").hidden = plain || !!sd.bring;
1713
1719
  $d("#ag-adv").hidden = plain || !!sd.bring;
1714
1720
  $d("#ag-name-l").hidden = plain || !!sd.bring;
1715
- paintStartsAs();
1716
1721
  if (sd.bring) {
1717
1722
  $d("#ag-r-miss").hidden = true;
1718
1723
  ready.hidden = true;
@@ -1958,14 +1963,93 @@ function showStartError(err) {
1958
1963
  el.hidden = false;
1959
1964
  }
1960
1965
 
1961
- /** AG-10.10: the id this start takes, so two of one persona are told apart. */
1966
+ /**
1967
+ * AG-10.10b: the id a Name gives, as the server's nameSlug: lowercase,
1968
+ * spaces to "-", only id characters, at most 64; null when none is left.
1969
+ */
1970
+ export function nameSlug(name) {
1971
+ const s = String(name)
1972
+ .trim()
1973
+ .toLowerCase()
1974
+ .replace(/\s+/g, "-")
1975
+ .replace(/[^a-z0-9._-]/g, "")
1976
+ .replace(/-{2,}/g, "-")
1977
+ .replace(/^[^a-z0-9]+/, "")
1978
+ .slice(0, 64)
1979
+ .replace(/[-._]+$/, "");
1980
+ return s && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(s) ? s : null;
1981
+ }
1982
+
1983
+ /**
1984
+ * The id this start takes (AG-10.10, 10.10b), said as you type: a Name is
1985
+ * the id as its slug. A running agent here with that id or title: "is
1986
+ * already running · Open it", Start off. A stopped one: "Resume it", or
1987
+ * Start takes the next suffix.
1988
+ */
1962
1989
  function paintStartsAs() {
1963
1990
  const el = $d("#ag-as");
1964
1991
  if (!el) return;
1965
1992
  const p = sd.pid && sd.pid !== PLAIN && !sd.bring ? curPersona() : null;
1966
1993
  el.hidden = !p;
1967
- if (p)
1968
- el.textContent = t("ag.new.as", { id: sd.data?.startsAs?.[p.id] ?? p.id });
1994
+ el.textContent = "";
1995
+ if (!p) return;
1996
+ const name = ($d("#ag-name")?.value ?? "").trim();
1997
+ const slug = name ? nameSlug(name) : null;
1998
+ const ws = $d("#ag-ws")?.value || viewWorkspaces()[0];
1999
+ const here = (st.data?.sessions ?? []).filter(
2000
+ (r) => r.kind === "agent" && r.workspace === ws,
2001
+ );
2002
+ const live = (r) => r.state === "running" || r.state === "stopping";
2003
+ const match = name
2004
+ ? (here.find(
2005
+ (r) =>
2006
+ live(r) &&
2007
+ (r.agent === slug ||
2008
+ (r.displayName ?? "").toLowerCase() === name.toLowerCase()),
2009
+ ) ?? here.find((r) => r.agent === slug))
2010
+ : null;
2011
+ const act = (label, fn) => {
2012
+ const b = document.createElement("button");
2013
+ b.type = "button";
2014
+ b.className = "den-btn sm";
2015
+ b.textContent = label;
2016
+ b.addEventListener("click", fn);
2017
+ return b;
2018
+ };
2019
+ if (match && live(match)) {
2020
+ el.append(
2021
+ t("ag.new.isRunning", { id: match.agent }) + " · ",
2022
+ act(t("ag.new.openIt"), () => {
2023
+ $d("#ag-dlg").close?.();
2024
+ void openPane(match.id);
2025
+ }),
2026
+ );
2027
+ $d("#ag-go").disabled = true;
2028
+ return;
2029
+ }
2030
+ if (match) {
2031
+ el.append(
2032
+ t("ag.new.isStopped", { id: match.agent }) + " · ",
2033
+ act(t("ag.new.resumeIt"), () => {
2034
+ $d("#ag-dlg").close?.();
2035
+ void restartRow(match, "resume", null);
2036
+ }),
2037
+ " " + t("ag.new.orNext", { id: nextFree(slug) }),
2038
+ );
2039
+ return;
2040
+ }
2041
+ el.textContent = t("ag.new.as", {
2042
+ id: slug ?? sd.data?.startsAs?.[p.id] ?? p.id,
2043
+ });
2044
+ }
2045
+
2046
+ /** The first `<slug>-<n>` no agent on this page has (the server decides). */
2047
+ function nextFree(slug) {
2048
+ const ids = new Set((st.data?.sessions ?? []).map((r) => r.agent));
2049
+ for (let n = 2; n < 1000; n++)
2050
+ if (!ids.has(`${slug.slice(0, 58)}-${n}`))
2051
+ return `${slug.slice(0, 58)}-${n}`;
2052
+ return slug;
1969
2053
  }
1970
2054
 
1971
2055
  async function submitStart(form) {
@@ -2131,6 +2215,9 @@ function wireStartDialog(root) {
2131
2215
  if (e.target.id === "ag-q") {
2132
2216
  sd.f.q = e.target.value;
2133
2217
  paintPlist(false);
2218
+ } else if (e.target.id === "ag-name") {
2219
+ // AG-10.10b: what the Name gives, as you type.
2220
+ paintRuntime(false);
2134
2221
  } else if (e.target.id === "ag-tools") {
2135
2222
  sd.extras = splitCommands(e.target.value);
2136
2223
  syncPermBlock(form, "ag", sd.data, {
@@ -139,7 +139,7 @@ function withCmd(text, cmd) {
139
139
  function withHost(t, key, vars) {
140
140
  const marks = {};
141
141
  const v = { ...vars };
142
- for (const k of ["host", "target"])
142
+ for (const k of ["host", "target", "user"])
143
143
  if (k in v) {
144
144
  marks[k] = v[k];
145
145
  v[k] = `\u0001${k}\u0001`;
@@ -203,6 +203,20 @@ export function countdownText(ms) {
203
203
  * Where a signed-in page goes: the page that was asked for. On /signin,
204
204
  * its same-origin `next` (else `/`); anywhere else, the same URL.
205
205
  */
206
+ /**
207
+ * 0.4.15: keep a sign-in's approval key (web-session.ts) in this origin's
208
+ * localStorage, where shell.js sends it from. It comes in the sign-in's
209
+ * own answer only.
210
+ */
211
+ function keepApproveKey(b) {
212
+ if (!b || typeof b.approveKey !== "string") return;
213
+ try {
214
+ localStorage.setItem("den.approveKey", b.approveKey);
215
+ } catch {
216
+ /* storage blocked: this browser signs in again to approve */
217
+ }
218
+ }
219
+
206
220
  export function landingAfterSignIn(loc) {
207
221
  if (loc.pathname !== "/signin") return loc.pathname + loc.search + loc.hash;
208
222
  const n = new URLSearchParams(loc.search).get("next") || "/";
@@ -263,6 +277,7 @@ function wireGithub(root, t, s) {
263
277
  return;
264
278
  }
265
279
  if (b.state === "ok") {
280
+ keepApproveKey(b);
266
281
  show(
267
282
  `<p class="lead" style="margin-top:12px">${esc(t("err.signin.githubDone"))}</p>`,
268
283
  );
@@ -351,6 +366,9 @@ function wirePair(root, t, s) {
351
366
  if (!box) return;
352
367
  const host = s.machine || "";
353
368
  const target = s.ssh?.target || "";
369
+ // SEC-2a-UX: the account to log in as, so the SSH app doesn't guess one.
370
+ const user = s.ssh?.user || "";
371
+ const login = user ? `${user}@${target}` : target;
354
372
  let poll = null;
355
373
  let tick = null;
356
374
  let gen = 0;
@@ -376,7 +394,7 @@ function wirePair(root, t, s) {
376
394
  const show = (b) => {
377
395
  const term =
378
396
  s.ssh?.name && target
379
- ? `<a class="btn" id="den-pair-term" href="ssh://${esc(target)}">${esc(t("err.signin.openTerminal"))}</a>`
397
+ ? `<a class="btn" id="den-pair-term" href="ssh://${esc(login)}">${esc(t("err.signin.openTerminal"))}</a>`
380
398
  : "";
381
399
  box.innerHTML =
382
400
  `<ol class="den-steps">` +
@@ -389,7 +407,11 @@ function wirePair(root, t, s) {
389
407
  `<p>${withHost(t, "err.signin.noTerminal", { host })}</p>` +
390
408
  cmdLine("den-pair-ssh", b.ssh, t, term) +
391
409
  (term
392
- ? `<p class="den-pair-note">${withHost(t, "err.signin.sshNote", { target })}</p>`
410
+ ? `<p class="den-pair-note" id="den-pair-note">${
411
+ user
412
+ ? withHost(t, "err.signin.sshUserNote", { host, user })
413
+ : withHost(t, "err.signin.sshNote", { target })
414
+ }</p>`
393
415
  : "");
394
416
  wireCopy(box, t);
395
417
  };
@@ -432,6 +454,7 @@ function wirePair(root, t, s) {
432
454
  return;
433
455
  }
434
456
  if (p.state === "ok") {
457
+ keepApproveKey(p);
435
458
  stop();
436
459
  box.innerHTML = `<p class="lead" id="den-pair-in">${esc(t("err.signin.youreIn"))}</p>`;
437
460
  location.replace(landingAfterSignIn(location));
@@ -524,6 +547,7 @@ function mountSignedOut(root, sh, d) {
524
547
  .then((b) => {
525
548
  go.disabled = false;
526
549
  if (b.ok) {
550
+ keepApproveKey(b);
527
551
  err.textContent = "";
528
552
  form.innerHTML = `<p class="lead" id="den-confirm-in">${esc(t("err.signin.youreIn"))}</p>`;
529
553
  location.replace(typeof b.next === "string" ? b.next : "/");
@@ -251,7 +251,7 @@ function _injectChangelogStyles() {
251
251
  padding: 5px 16px; border-radius: 5px; font-size: 12px;
252
252
  font-weight: 600; cursor: pointer;
253
253
  }
254
- .cl-got-it:hover { opacity: .85; }
254
+ .cl-got-it:hover { filter: brightness(0.92); }
255
255
  `;
256
256
  document.head.appendChild(style);
257
257
 
@@ -271,6 +271,8 @@
271
271
  "err.signin.noTerminal":
272
272
  "¿No tienes una terminal en {host}? Desde este ordenador, ejecuta:",
273
273
  "err.signin.openTerminal": "Abrir Terminal",
274
+ "err.signin.sshUserNote":
275
+ "Inicia sesión con tu cuenta de {host} ({user}) y su contraseña, o con una clave SSH.",
274
276
  "err.signin.sshNote":
275
277
  "Abrir Terminal abre ssh://{target} en tu aplicación de terminal. Necesita acceso SSH a {target}.",
276
278
  "err.signin.whyTitle": "¿Por qué?",
@@ -459,6 +461,11 @@
459
461
  "ag.new.name": "Nombre (opcional)",
460
462
  "ag.new.as": "Se inicia como {id}",
461
463
  "ag.new.openRunning": "Abrir el que está en ejecución",
464
+ "ag.new.isRunning": "{id} ya está en ejecución",
465
+ "ag.new.openIt": "Abrirlo",
466
+ "ag.new.isStopped": "{id} está detenido",
467
+ "ag.new.resumeIt": "Reanudarlo",
468
+ "ag.new.orNext": "o Iniciar crea {id}",
462
469
  "ag.new.taskPh":
463
470
  "p. ej., Toma la historia del estado vacío. Sigue el diseño y añade una prueba.",
464
471
  "ag.new.adv": "Avanzado: modelo y permisos",
@@ -488,6 +495,28 @@
488
495
  "ag.bring.now": "ahora mismo",
489
496
  "ag.bring.min": "hace {n} min",
490
497
  "ag.bring.h": "hace {n} h",
498
+ "ap.title": "{agent} quiere:",
499
+ "ap.agent": "El agente {name}",
500
+ "ap.noAgent": "Algo en esta máquina (sin identidad de agente)",
501
+ "ap.noAgentWarn": "Si no le pediste esto a un agente, deniégalo.",
502
+ "ap.signin.title": "Algo espera tu aprobación.",
503
+ "ap.signin": "Inicia sesión para verlo: ejecuta {cmd} y abre el enlace.",
504
+ "ap.do.workspace_create": 'Crear el espacio "{name}"',
505
+ "ap.do.repo_add":
506
+ 'Añadir el repositorio de {path} al espacio "{workspace}"',
507
+ "ap.do.repo_add.new":
508
+ 'Añadir el repositorio de {path} a un espacio nuevo "{workspace}"',
509
+ "ap.do.service_try": "Iniciar {service}",
510
+ "ap.says": "El agente dice:",
511
+ "ap.review": "Revisar",
512
+ "ap.hide": "Ocultar detalles",
513
+ "ap.later": "Más tarde",
514
+ "ap.deny": "Denegar",
515
+ "ap.allow": "Permitir",
516
+ "ap.waits": "Nada cambia hasta que lo permitas. Espera hasta las {time}.",
517
+ "ap.more": "{n} más en espera",
518
+ "ap.failed": "Se ejecutó y falló: {error}",
519
+ "ap.done": "Hecho: {what}",
491
520
  "ag.new.note":
492
521
  "Se inicia en la carpeta del espacio de trabajo. Sigue en ejecución si cierras el panel.",
493
522
  "ag.new.started": "{p} iniciado en {w}",
@@ -260,6 +260,8 @@
260
260
  "err.signin.noTerminal":
261
261
  "{host} のターミナルがありませんか?このコンピューターで次を実行します:",
262
262
  "err.signin.openTerminal": "ターミナルを開く",
263
+ "err.signin.sshUserNote":
264
+ "{host} のアカウント({user})とそのパスワード、または SSH キーでログインします。",
263
265
  "err.signin.sshNote":
264
266
  "「ターミナルを開く」はターミナルアプリで ssh://{target} を開きます。{target} への SSH アクセスが必要です。",
265
267
  "err.signin.whyTitle": "なぜ?",
@@ -442,6 +444,11 @@
442
444
  "ag.new.name": "名前(任意)",
443
445
  "ag.new.as": "{id} として開始します",
444
446
  "ag.new.openRunning": "実行中のものを開く",
447
+ "ag.new.isRunning": "{id} はすでに実行中です",
448
+ "ag.new.openIt": "開く",
449
+ "ag.new.isStopped": "{id} は停止しています",
450
+ "ag.new.resumeIt": "再開する",
451
+ "ag.new.orNext": "または開始で {id} を作成します",
445
452
  "ag.new.taskPh":
446
453
  "例: 空の状態のストーリーを担当。デザインに合わせ、テストを追加する。",
447
454
  "ag.new.adv": "詳細設定: モデルと権限",
@@ -472,6 +479,30 @@
472
479
  "ag.bring.now": "たった今",
473
480
  "ag.bring.min": "{n} 分前",
474
481
  "ag.bring.h": "{n} 時間前",
482
+ "ap.title": "{agent}が次のことを求めています:",
483
+ "ap.agent": "エージェント {name} ",
484
+ "ap.noAgent": "このマシン上の何か(エージェントの識別なし)",
485
+ "ap.noAgentWarn":
486
+ "エージェントにこれを頼んでいなければ、拒否してください。",
487
+ "ap.signin.title": "承認を待っているものがあります。",
488
+ "ap.signin":
489
+ "サインインすると表示されます: {cmd} を実行してリンクを開いてください。",
490
+ "ap.do.workspace_create": "ワークスペース「{name}」を作成",
491
+ "ap.do.repo_add":
492
+ "{path} のリポジトリをワークスペース「{workspace}」に追加",
493
+ "ap.do.repo_add.new":
494
+ "{path} のリポジトリを新しいワークスペース「{workspace}」に追加",
495
+ "ap.do.service_try": "{service} を起動",
496
+ "ap.says": "エージェントのコメント:",
497
+ "ap.review": "確認",
498
+ "ap.hide": "詳細を隠す",
499
+ "ap.later": "あとで",
500
+ "ap.deny": "拒否",
501
+ "ap.allow": "許可",
502
+ "ap.waits": "許可するまで何も変わりません。{time} まで待ちます。",
503
+ "ap.more": "ほかに {n} 件が待機中",
504
+ "ap.failed": "実行しましたが失敗しました: {error}",
505
+ "ap.done": "完了: {what}",
475
506
  "ag.new.note":
476
507
  "ワークスペースのフォルダーで開始します。ペインを閉じても実行は続きます。",
477
508
  "ag.new.started": "{w} で {p} を開始しました",
@@ -355,7 +355,7 @@
355
355
  white-space: nowrap;
356
356
  }
357
357
  .run-btn:hover:not(:disabled) {
358
- opacity: 0.85;
358
+ filter: brightness(0.92);
359
359
  }
360
360
  .run-btn:disabled {
361
361
  opacity: 0.45;
@@ -344,7 +344,7 @@
344
344
  cursor: pointer;
345
345
  }
346
346
  .new-ws-form button:hover {
347
- opacity: 0.85;
347
+ filter: brightness(0.92);
348
348
  }
349
349
  /* ── Content ──────────────────────────────────────────────────────────── */
350
350
  .content {
@@ -552,7 +552,7 @@
552
552
  cursor: pointer;
553
553
  }
554
554
  .add-btn:hover {
555
- opacity: 0.85;
555
+ filter: brightness(0.92);
556
556
  }
557
557
  .form-msg {
558
558
  font-size: 11px;
@@ -688,8 +688,9 @@
688
688
  .add-svc-row.selected {
689
689
  background: color-mix(in srgb, var(--accent) 9%, transparent);
690
690
  }
691
+ /* Already in the workspace: readable (WCAG AA), not dimmed. */
691
692
  .add-svc-row.already {
692
- opacity: 0.4;
693
+ color: var(--muted);
693
694
  }
694
695
  .add-svc-row-info {
695
696
  flex: 1;
@@ -839,7 +840,7 @@
839
840
  cursor: pointer;
840
841
  }
841
842
  .add-svc-submit:hover {
842
- opacity: 0.85;
843
+ filter: brightness(0.92);
843
844
  }
844
845
  .add-svc-submit:disabled {
845
846
  opacity: 0.45;
@@ -561,3 +561,24 @@ dialog.pe-dlg::backdrop {
561
561
  .pr[aria-disabled="true"]:hover {
562
562
  background: none;
563
563
  }
564
+
565
+ /* ON-3's approval dialog (shell.js, every page): the Start dialog's frame,
566
+ the request in the body text's size and weight, and Review's lines in
567
+ the mono box the persona Details use. */
568
+ .den-ap-dlg .den-ap-what {
569
+ color: var(--text);
570
+ font: 600 14.5px/1.45 var(--sans);
571
+ overflow-wrap: anywhere;
572
+ }
573
+ .den-ap-dlg .den-ap-det {
574
+ margin: 0;
575
+ max-height: 260px;
576
+ overflow: auto;
577
+ padding: 10px 12px;
578
+ border: 1px solid var(--border);
579
+ border-radius: var(--radius-button, 6px);
580
+ background: var(--surface, var(--raised));
581
+ font: 12px/1.5 var(--mono);
582
+ white-space: pre-wrap;
583
+ overflow-wrap: anywhere;
584
+ }
@@ -398,7 +398,6 @@
398
398
  .loading-note .sub {
399
399
  display: block;
400
400
  margin-top: 4px;
401
- opacity: 0.75;
402
401
  font-size: 11px;
403
402
  }
404
403
  .loading-dots::after {
@@ -160,7 +160,9 @@ export function deviceLine(d, now = Date.now()) {
160
160
  ? `signed in with GitHub${d.login ? ` (@${d.login})` : ""}`
161
161
  : d.via === "code"
162
162
  ? "approved with a code"
163
- : "signed in with a one-time link";
163
+ : d.via === "claim"
164
+ ? "claimed with a setup link"
165
+ : "signed in with a one-time link";
164
166
  const name = d.name || "Unknown browser";
165
167
  const from = d.client || d.host;
166
168
  return {
@@ -340,10 +340,18 @@
340
340
  .set-radio:hover {
341
341
  background: var(--sunken);
342
342
  }
343
+ /* Not installed: the option stays readable (WCAG AA); only its
344
+ radio dims. */
343
345
  .set-radio.off {
344
- opacity: 0.5;
346
+ color: var(--muted);
345
347
  cursor: default;
346
348
  }
349
+ .set-radio.off:hover {
350
+ background: none;
351
+ }
352
+ .set-radio.off input {
353
+ opacity: 0.5;
354
+ }
347
355
  .set-radio input {
348
356
  accent-color: var(--accent);
349
357
  margin: 0;