on master help sign in
Redesign the popup and settings page

The popup now reads as a calm status view: a summary of this window, group rows with their tab-group colour that expand for actions, and quiet Organize/Tidy buttons in the footer. Setup problems show as one notice with a copyable Terminal command. The settings page groups options into cards with switches, dims settings that don't apply, turns the self-test into a plain-language check, and moves host details under Troubleshooting. Both follow the new icon's emerald palette in light and dark mode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkFeSsjn41Whjpa2gj7THN

commit6177e0a98635dbb3891fe3352f293187def7a5a1 authorClaude <noreply@anthropic.com> dateThu 24 Sep 2026, 06:24 (3 weeks ago) parentf0c4d2f
9 files changed+656−284
README.md+2−2 src/background/index.ts+1 src/options/options.html+71−35 src/options/options.ts+189−97 src/popup/popup.html+71−30 src/popup/popup.ts+87−79 src/shared/health.ts+104 src/shared/ui.css+130−40 test/e2e/smoke.mjs+1−1
README.md+2−2
@@ -54,7 +54,7 @@ Chromium browsers only install extensions on their own from a web store, so the
5454 ```
5555
5656 (Type `bash `, then drag `install-host.command` from the folder into the Terminal window.) If it says python3 is not set up, run `xcode-select --install` and try again. It shows Apple's terms for `fm` if they haven't been accepted on this Mac yet, and ends with "Diagonal's host is ready." when everything works.
57−4. Open Diagonal's settings and click **Run self-test**. The browser does not need a restart.
57+4. Open Diagonal's settings and click **Run check** under "Connection to the model". The browser does not need a restart.
5858
5959The zip carries the host (`host/`), `extension-id` and `install-host.command`, which is the same script as `scripts/install-manifest.sh`. When you update to a newer release, rerun step 3.
6060
@@ -92,7 +92,7 @@ programs.diagonal.enable = true;
9292
9393#### 3. Check it
9494
95−Open Diagonal's settings page and click **Run self-test**. Expect `fmAvailable: true`, `schemasOk: true` and a title for the three fixture tabs. From a terminal, `diagonal-host --selftest` runs the same checks.
95+Open Diagonal's settings page and click **Run check**. It should say "Working" and show a title for a sample group; the full result (`fmAvailable: true`, `schemasOk: true`) is under Troubleshooting. From a terminal, `diagonal-host --selftest` runs the same checks.
9696
9797## Develop
9898
src/background/index.ts+1
@@ -635,6 +635,7 @@ async function statusFor(windowId: number | undefined): Promise<unknown> {
635635 .map(async (g) => ({
636636 id: g.id,
637637 title: g.stripTitle || g.title || "",
638+ color: g.color,
638639 origin: g.origin,
639640 managed: g.managed,
640641 userNamed: g.userNamed,
src/options/options.html+71−35
@@ -5,44 +5,80 @@
55 <title>Diagonal settings</title>
66 <link rel="stylesheet" href="ui.css">
77 <style>
8− body { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
9− h1 { font-size: 20px; }
10− section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px 16px 12px; margin-top: 16px; }
11− .field { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
12− .field:last-child { border-bottom: 0; }
13− .field .help { grid-column: 1 / -1; color: var(--muted); font-size: 12px; }
14− input[type=number], select { font: inherit; width: auto; min-width: 90px; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
15− textarea { font: 12px ui-monospace, Menlo, monospace; width: 100%; min-height: 70px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); padding: 6px; }
16− pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
17− .warn-text { color: var(--warn); }
18− #saved { transition: opacity .3s; }
8+ body { font-size: 13.5px; }
9+ .page { max-width: 680px; margin: 0 auto; padding: 40px 20px 64px; display: grid; gap: 16px; }
10+ header { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; }
11+ header h1 { font-size: 22px; }
12+ header p { color: var(--muted); margin-top: 2px; }
13+
14+ .card > .head { padding: 16px 18px 4px; }
15+ .card > .head p { color: var(--muted); margin-top: 4px; }
16+ .field { display: grid; grid-template-columns: 1fr auto; gap: 2px 24px; align-items: center; padding: 12px 18px; border-top: 1px solid var(--border); }
17+ .card > .head + .field { border-top: 0; }
18+ .field label, .field .label { font-weight: 500; }
19+ .field .help { grid-column: 1; color: var(--muted); font-size: 12.5px; }
20+ .field .wide { grid-column: 1 / -1; margin-top: 6px; }
21+ .field.off label, .field.off .help { opacity: .5; }
22+ .field .warn-text { grid-column: 1 / -1; color: var(--warn); font-size: 12.5px; margin-top: 4px; }
23+
24+ .unit { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); overflow: hidden; }
25+ .unit input { width: 64px; border: 0; background: none; color: var(--text); font: inherit; padding: 5px 4px 5px 10px; text-align: right; }
26+ .unit input:focus { outline: none; }
27+ .unit:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
28+ .unit span { color: var(--muted); padding: 0 10px 0 4px; font-size: 12.5px; }
29+ select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
30+ select { padding: 5px 8px; }
31+ select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
32+ textarea { width: 100%; min-height: 116px; padding: 8px 10px; font: 12px/1.6 ui-monospace, "SF Mono", Menlo, monospace; resize: vertical; }
33+ input:disabled, select:disabled, textarea:disabled { opacity: .5; }
34+
35+ .conn { display: flex; align-items: center; gap: 14px; padding: 16px 18px; }
36+ .conn .title { font-weight: 600; }
37+
38+ details.card > summary { list-style: none; cursor: pointer; padding: 14px 18px; display: flex; align-items: center; gap: 8px; }
39+ details.card > summary::-webkit-details-marker { display: none; }
40+ pre { margin: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace; }
41+ .foot { text-align: center; color: var(--faint); font-size: 12px; margin-top: 8px; }
1942 </style>
2043</head>
2144<body>
22− <div class="row"><h1 class="grow">Diagonal settings</h1><span id="saved" class="muted" style="opacity:0">Saved</span></div>
23− <p class="muted">Tab groups named by Apple's on-device model through the <code>fm</code> command-line tool. Nothing is sent over the network.</p>
24−
25− <section>
26− <h2>Host setup and self-test</h2>
27− <div class="field"><span>Extension ID</span><code id="ext-id"></code></div>
28− <div class="field"><span>Host manifest path</span><button class="link" id="copy-path">Copy</button><code class="help" id="manifest-path"></code></div>
29− <div class="field"><span>Host manifest contents</span><button class="link" id="copy-manifest">Copy</button><pre class="help" id="manifest-json"></pre></div>
30− <div class="field"><span>Self-test: ping the host, then name three fixture tabs</span><button class="primary" id="selftest">Run self-test</button><pre class="help" id="selftest-out" hidden></pre></div>
31− <div class="field"><span>Naming latency (last 100 calls)</span><span id="latency" class="mono muted">no data yet</span></div>
32− <div class="field"><span>Last host error</span><span></span><pre class="help" id="last-error">none</pre></div>
33− </section>
34−
35− <section id="settings"></section>
36−
37− <section>
38− <h2>Archive</h2>
39− <div class="field"><span>Export every archived tab as JSON</span><button id="export">Export archive</button></div>
40− </section>
41−
42− <section id="debug-section" hidden>
43− <h2>Debug log</h2>
44− <pre id="debug-log"></pre>
45− </section>
45+ <div class="page">
46+ <header>
47+ <img class="logo" src="icons/128.png" width="48" height="48" alt="">
48+ <div class="grow">
49+ <h1>Diagonal</h1>
50+ <p>Groups your tabs as you browse and names them with Apple's on-device model.</p>
51+ </div>
52+ <span class="pill warn busy" id="status"><span class="dot"></span><span id="status-text">Connecting…</span></span>
53+ </header>
54+
55+ <div id="notices" style="display:grid;gap:8px" hidden></div>
56+
57+ <div class="card conn">
58+ <div class="grow">
59+ <div class="title">Connection to the model</div>
60+ <div class="muted" id="selftest-summary">Check that the helper answers and can name a sample group.</div>
61+ </div>
62+ <button id="selftest">Run check</button>
63+ </div>
64+
65+ <div id="settings" style="display:grid;gap:16px"></div>
66+
67+ <details class="card" id="troubleshooting">
68+ <summary><span class="chev"></span><h2 class="grow">Troubleshooting</h2></summary>
69+ <div class="field"><span class="label">Extension ID</span><code id="ext-id"></code></div>
70+ <div class="field"><span class="label">Naming speed</span><span id="latency" class="mono muted">No calls yet</span><span class="help">Median and slowest 5% of the last 100 model calls.</span></div>
71+ <div class="field"><span class="label">Host manifest path</span><button class="link small" id="copy-path">Copy</button><code class="wide muted" id="manifest-path"></code></div>
72+ <div class="field"><span class="label">Host manifest</span><button class="link small" id="copy-manifest">Copy</button><pre class="wide" id="manifest-json"></pre></div>
73+ <div class="field"><span class="label">Last check, in full</span><span></span><pre class="wide" id="selftest-out">Not run yet</pre></div>
74+ <div class="field"><span class="label">Last host error</span><span></span><pre class="wide" id="last-error">None</pre></div>
75+ <div id="debug-fields"></div>
76+ <div class="field" id="debug-section" hidden><span class="label">Debug log</span><span></span><pre class="wide" id="debug-log"></pre></div>
77+ </details>
78+
79+ <p class="foot">Changes save as you make them.</p>
80+ </div>
81+ <div class="toast" id="saved" role="status">Saved</div>
4682 <script src="options.js" type="module"></script>
4783</body>
4884</html>
src/options/options.ts+189−97
@@ -1,107 +1,164 @@
11import { DEFAULT_SETTINGS, withDefaults, type Settings } from "../background/settings";
2+import { notices, renderNotice, renderPill, type Health } from "../shared/health";
23import { el, send } from "../shared/messaging";
34
45const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
56
6−type Field =
7− | { key: keyof Settings; label: string; help?: string; kind: "bool" }
8− | { key: keyof Settings; label: string; help?: string; kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
9− | { key: keyof Settings; label: string; help?: string; kind: "select"; options: [string, string][] }
10− | { key: keyof Settings; label: string; help?: string; kind: "list" };
11−
12−const SECTIONS: [string, Field[]][] = [
13− ["Grouping", [
14− { key: "autoOrganize", label: "Organize loose tabs into groups automatically", help: "Tabs you take out of a group stay out until they go to another page.", kind: "bool" },
15− { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, step: 1, unit: "s", scale: 1000 },
16− { key: "openerGrouping", label: "Group a tab with the tab it was opened from", kind: "bool" },
17− { key: "dissolveSingletons", label: "Ungroup Diagonal's groups that drop to one tab", kind: "bool" },
18− ]],
19− ["Naming", [
20− { key: "naming", label: "Name groups with the on-device model", kind: "bool" },
21− { key: "nameUserGroups", label: "Name my own groups too", help: "Stops for good once you type a title.", kind: "bool" },
22− { key: "emoji", label: "Prefix titles with an emoji", kind: "bool" },
23− { key: "namingDebounceMs", label: "Quiet period before naming", kind: "number", min: 1, max: 30, step: 1, unit: "s", scale: 1000 },
24− { key: "sendDescription", label: "Send page descriptions to the model", help: "Off: title and address only.", kind: "bool" },
25− { key: "sendFullUrl", label: "Send full addresses to the model", help: "Off: hostname only.", kind: "bool" },
26− { key: "model", label: "Model", kind: "select", options: [["system", "On-device (system)"], ["pcc", "Private Cloud Compute"]] },
27− { key: "timeoutMs", label: "Timeout per model call", kind: "number", min: 5, max: 120, step: 1, unit: "s", scale: 1000 },
28− { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs" },
29− ]],
30− ["Tidy", [
31− { key: "tidyMode", label: "Tidy sweep", kind: "select", options: [["auto", "Park automatically"], ["ask", "Ask before parking"], ["off", "Off"]] },
32− { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "h" },
33− { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 = never archive.", kind: "number", min: 0, max: 2160, unit: "h" },
34− { key: "tidyThreshold", label: "Ask mode: offer a sweep at", kind: "number", min: 1, max: 100, unit: "tabs" },
35− { key: "discardParked", label: "Discard parked tabs from memory", kind: "bool" },
36− { key: "tidyUserGroups", label: "Tidy inside my own groups", kind: "bool" },
37− { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Write /…/ for a regular expression.", kind: "list" },
38− { key: "archiveCap", label: "Keep at most", kind: "number", min: 50, max: 5000, unit: "archived tabs" },
39− ]],
40− ["Diagnostics", [
41− { key: "debugLog", label: "Debug logging", help: "Worker logs to the console; the host logs to ~/Library/Logs/Diagonal/host.log when ~/Library/Application Support/Diagonal/debug exists.", kind: "bool" },
42− ]],
7+type Field = { key: keyof Settings; label: string; help?: string; when?: (s: Settings) => boolean } & (
8+ | { kind: "bool" }
9+ | { kind: "number"; min: number; max: number; step?: number; unit?: string; scale?: number }
10+ | { kind: "select"; options: [string, string][] }
11+ | { kind: "list" }
12+);
13+
14+interface Section { title: string; intro: string; fields: Field[]; extra?: () => HTMLElement }
15+
16+const SECTIONS: Section[] = [
17+ {
18+ title: "Grouping",
19+ intro: "Tabs join a group on their own. Nothing to press.",
20+ fields: [
21+ { key: "openerGrouping", label: "Keep a tab with the one it was opened from", kind: "bool" },
22+ { key: "autoOrganize", label: "Sort loose tabs into groups", help: "A tab you pull out of a group stays out until it goes to another page.", kind: "bool" },
23+ { key: "autoOrganizeDelayMs", label: "Wait after the last tab change", kind: "number", min: 3, max: 120, unit: "sec", scale: 1000, when: (s) => s.autoOrganize },
24+ { key: "organizeMinGroupSize", label: "Smallest new group", kind: "number", min: 2, max: 5, unit: "tabs", when: (s) => s.autoOrganize },
25+ { key: "dissolveSingletons", label: "Ungroup a group that's down to one tab", help: "Only groups Diagonal made.", kind: "bool" },
26+ ],
27+ },
28+ {
29+ title: "Naming",
30+ intro: "Groups get a short title from the model on this Mac.",
31+ fields: [
32+ { key: "naming", label: "Name groups automatically", kind: "bool" },
33+ { key: "emoji", label: "Start titles with an emoji", kind: "bool", when: (s) => s.naming },
34+ { key: "nameUserGroups", label: "Name groups I make too", help: "Stops for good once you type a title yourself.", kind: "bool", when: (s) => s.naming },
35+ { key: "namingDebounceMs", label: "Wait before naming", kind: "number", min: 1, max: 30, unit: "sec", scale: 1000, when: (s) => s.naming },
36+ { key: "timeoutMs", label: "Give the model up to", kind: "number", min: 5, max: 120, unit: "sec", scale: 1000, when: (s) => s.naming },
37+ ],
38+ },
39+ {
40+ title: "Privacy",
41+ intro: "What the model sees when it names a group.",
42+ fields: [
43+ { key: "model", label: "Model", kind: "select", options: [["system", "On this Mac"], ["pcc", "Private Cloud Compute"]] },
44+ { key: "sendDescription", label: "Include page descriptions", help: "Off: titles and addresses only.", kind: "bool" },
45+ { key: "sendFullUrl", label: "Include full addresses", help: "Off: just the site name.", kind: "bool" },
46+ ],
47+ },
48+ {
49+ title: "Tidy",
50+ intro: "Tabs you haven't touched in a while move to a parked group, then to the archive.",
51+ fields: [
52+ { key: "tidyMode", label: "Tidy old tabs", kind: "select", options: [["auto", "Automatically"], ["ask", "Ask me first"], ["off", "Never"]] },
53+ { key: "parkAfterHours", label: "Park tabs untouched for", kind: "number", min: 1, max: 720, unit: "hours", when: (s) => s.tidyMode !== "off" },
54+ { key: "archiveAfterHours", label: "Archive parked tabs after", help: "0 keeps them parked.", kind: "number", min: 0, max: 2160, unit: "hours", when: (s) => s.tidyMode !== "off" },
55+ { key: "tidyThreshold", label: "Ask once this many are waiting", kind: "number", min: 1, max: 100, unit: "tabs", when: (s) => s.tidyMode === "ask" },
56+ { key: "discardParked", label: "Free memory used by parked tabs", kind: "bool", when: (s) => s.tidyMode !== "off" },
57+ { key: "tidyUserGroups", label: "Tidy inside groups I made", kind: "bool", when: (s) => s.tidyMode !== "off" },
58+ { key: "tidyExclusions", label: "Never tidy addresses containing", help: "One per line. Wrap in /…/ for a regular expression.", kind: "list", when: (s) => s.tidyMode !== "off" },
59+ ],
60+ },
61+ {
62+ title: "Archive",
63+ intro: "Archived tabs can be restored from the toolbar popup.",
64+ fields: [{ key: "archiveCap", label: "Keep up to", kind: "number", min: 50, max: 5000, unit: "tabs" }],
65+ extra: () =>
66+ el("div", { className: "field" },
67+ el("span", { className: "label", textContent: "Export the archive" }),
68+ el("button", { textContent: "Export JSON", onclick: exportArchive }),
69+ el("span", { className: "help", textContent: "Every archived tab with its title, address and group." })),
70+ },
4371];
4472
73+const DEBUG_FIELD: Field = {
74+ key: "debugLog", label: "Debug logging", kind: "bool",
75+ help: "The extension logs to its console. The helper logs to ~/Library/Logs/Diagonal/host.log once ~/Library/Application Support/Diagonal/debug exists.",
76+};
77+
4578let settings: Settings = DEFAULT_SETTINGS;
79+const rows: { field: Field; row: HTMLElement }[] = [];
80+let toastTimer: ReturnType<typeof setTimeout> | undefined;
4681
4782async function save(patch: Partial<Settings>): Promise<void> {
4883 settings = withDefaults({ ...settings, ...patch });
4984 await chrome.storage.local.set({ settings });
50− const s = $("saved");
51− s.style.opacity = "1";
52− setTimeout(() => (s.style.opacity = "0"), 1200);
53− renderWarnings();
85+ const t = $("saved");
86+ t.classList.add("show");
87+ clearTimeout(toastTimer);
88+ toastTimer = setTimeout(() => t.classList.remove("show"), 1200);
89+ applyDependencies();
90+ $("debug-section").hidden = !settings.debugLog;
5491}
5592
56−function renderWarnings(): void {
93+/** Dim and disable settings that don't apply with the current choices. */
94+function applyDependencies(): void {
95+ for (const { field, row } of rows) {
96+ const on = field.when ? field.when(settings) : true;
97+ row.classList.toggle("off", !on);
98+ row.querySelectorAll<HTMLInputElement>("input, select, textarea").forEach((c) => (c.disabled = !on));
99+ }
57100 const w = document.getElementById("pcc-warning");
58101 if (w) w.hidden = settings.model !== "pcc";
59102}
60103
104+function fieldRow(f: Field): HTMLElement {
105+ const value = settings[f.key] as unknown;
106+ const id = `f-${f.key}`;
107+ let control: HTMLElement;
108+ if (f.kind === "bool") {
109+ control = el("input", { id, type: "checkbox", className: "switch", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
110+ } else if (f.kind === "number") {
111+ const scale = f.scale ?? 1;
112+ control = el("span", { className: "unit" },
113+ el("input", {
114+ id, type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
115+ onchange: (e: Event) => {
116+ const input = e.target as HTMLInputElement;
117+ const n = Math.min(f.max, Math.max(f.min, Number(input.value) || f.min));
118+ input.value = String(n);
119+ void save({ [f.key]: n * scale });
120+ },
121+ }),
122+ el("span", { textContent: f.unit ?? "" }));
123+ } else if (f.kind === "select") {
124+ const sel = el("select", { id, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
125+ for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
126+ control = sel;
127+ } else {
128+ control = el("span");
129+ }
130+ const row = el("div", { className: "field" }, el("label", { htmlFor: id, textContent: f.label }), control);
131+ if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
132+ if (f.kind === "list") {
133+ row.append(el("textarea", {
134+ id, className: "wide", spellcheck: false,
135+ value: (value as string[]).join("\n"),
136+ onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
137+ }));
138+ }
139+ if (f.key === "model") {
140+ row.append(el("div", {
141+ className: "warn-text", id: "pcc-warning",
142+ textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. On this Mac, nothing leaves the computer.",
143+ }));
144+ }
145+ rows.push({ field: f, row });
146+ return row;
147+}
148+
61149function renderSettings(): void {
150+ rows.length = 0;
62151 const root = $("settings");
63152 root.replaceChildren();
64− for (const [title, fields] of SECTIONS) {
65− root.append(el("h2", { textContent: title }));
66− for (const f of fields) {
67− const value = settings[f.key] as unknown;
68− let control: HTMLElement;
69− if (f.kind === "bool") {
70− control = el("input", { type: "checkbox", checked: !!value, onchange: (e: Event) => save({ [f.key]: (e.target as HTMLInputElement).checked }) });
71− } else if (f.kind === "number") {
72− const scale = f.scale ?? 1;
73− control = el("span", {},
74− el("input", {
75− type: "number", min: String(f.min), max: String(f.max), step: String(f.step ?? 1), value: String((value as number) / scale),
76− onchange: (e: Event) => save({ [f.key]: Number((e.target as HTMLInputElement).value) * scale }),
77− }),
78− f.unit ? ` ${f.unit}` : "");
79− } else if (f.kind === "select") {
80− const sel = el("select", { onchange: (e: Event) => save({ [f.key]: (e.target as HTMLSelectElement).value }) });
81− for (const [v, label] of f.options) sel.append(el("option", { value: v, textContent: label, selected: v === value }));
82− control = sel;
83− } else {
84− control = el("span");
85− }
86− const row = el("div", { className: "field" }, el("label", { textContent: f.label }), control);
87− if (f.kind === "list") {
88− row.append(el("textarea", {
89− className: "help",
90− value: (value as string[]).join("\n"),
91− onchange: (e: Event) => save({ [f.key]: (e.target as HTMLTextAreaElement).value.split("\n") }),
92− }));
93− }
94− if (f.help) row.append(el("div", { className: "help", textContent: f.help }));
95− if (f.key === "model") {
96− row.append(el("div", {
97− className: "help warn-text", id: "pcc-warning",
98− textContent: "Private Cloud Compute sends tab titles, addresses and descriptions to Apple's servers. The on-device model keeps them on this Mac.",
99− }));
100− }
101− root.append(row);
102− }
153+ for (const s of SECTIONS) {
154+ const card = el("section", { className: "card" },
155+ el("div", { className: "head" }, el("h2", { textContent: s.title }), el("p", { textContent: s.intro })));
156+ for (const f of s.fields) card.append(fieldRow(f));
157+ if (s.extra) card.append(s.extra());
158+ root.append(card);
103159 }
104− renderWarnings();
160+ $("debug-fields").replaceChildren(fieldRow(DEBUG_FIELD));
161+ applyDependencies();
105162}
106163
107164function percentile(values: number[], p: number): number {
@@ -109,18 +166,53 @@ function percentile(values: number[], p: number): number {
109166 return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
110167}
111168
169+async function renderHealth(): Promise<void> {
170+ try {
171+ const s = await send<Health>("status");
172+ renderPill($("status"), s);
173+ const list = notices(s);
174+ $("notices").replaceChildren(...list.map(renderNotice));
175+ $("notices").hidden = list.length === 0;
176+ } catch {
177+ /* worker asleep or reloading */
178+ }
179+}
180+
112181async function renderDiagnostics(): Promise<void> {
113182 try {
114183 const d = await send<{ latency: number[]; debugLog: { at: number; msg: string }[]; host: { lastError?: unknown } }>("diagnostics");
115− if (d.latency.length) $("latency").textContent = `p50 ${percentile(d.latency, 50)} ms · p95 ${percentile(d.latency, 95)} ms · n=${d.latency.length}`;
116− $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "none";
184+ if (d.latency.length) $("latency").textContent = `${percentile(d.latency, 50)} ms · ${percentile(d.latency, 95)} ms · ${d.latency.length} calls`;
185+ $("last-error").textContent = d.host.lastError ? JSON.stringify(d.host.lastError, null, 2) : "None";
117186 $("debug-section").hidden = !settings.debugLog;
118− $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "empty";
187+ $("debug-log").textContent = d.debugLog.map((e) => `${new Date(e.at).toLocaleTimeString()} ${e.msg}`).join("\n") || "Empty";
119188 } catch {
120189 /* worker asleep or reloading */
121190 }
122191}
123192
193+async function exportArchive(): Promise<void> {
194+ const archive = await send("exportArchive");
195+ const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
196+ const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
197+ a.click();
198+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
199+}
200+
201+type SelfTest = {
202+ ping?: { ok: boolean; result?: { fmAvailable?: boolean }; error?: { message?: string } };
203+ pingMs?: number;
204+ name?: { ok: boolean; result?: { title?: string; emoji?: string }; error?: { message?: string } };
205+ nameMs?: number;
206+};
207+
208+function summarize(r: SelfTest): string {
209+ if (!r.ping?.ok) return `The helper didn't answer: ${r.ping?.error?.message ?? "no reply"}`;
210+ if (!r.name) return `The helper answered in ${r.pingMs} ms.`;
211+ if (!r.name.ok) return `The helper answered, but naming failed: ${r.name.error?.message ?? "no reply"}`;
212+ const title = [r.name.result?.emoji, r.name.result?.title].filter(Boolean).join(" ");
213+ return `Working. A sample group was named “${title}” in ${r.nameMs} ms.`;
214+}
215+
124216async function main(): Promise<void> {
125217 settings = withDefaults((await chrome.storage.local.get("settings")).settings);
126218 renderSettings();
@@ -140,30 +232,30 @@ async function main(): Promise<void> {
140232 $("copy-path").onclick = () => navigator.clipboard.writeText(path);
141233 $("copy-manifest").onclick = () => navigator.clipboard.writeText(manifest);
142234 $("selftest").onclick = async () => {
235+ const b = $<HTMLButtonElement>("selftest");
143236 const out = $("selftest-out");
144− out.hidden = false;
145− out.textContent = "Running…";
237+ const summary = $("selftest-summary");
238+ b.disabled = true;
239+ b.textContent = "Checking…";
240+ summary.textContent = "Asking the helper to name a sample group…";
146241 try {
147− const r = await send("selftest");
242+ const r = await send<SelfTest>("selftest");
148243 out.textContent = JSON.stringify(r, null, 2);
244+ summary.textContent = summarize(r);
149245 } catch (e) {
150246 out.textContent = String(e);
247+ summary.textContent = `The check couldn't run: ${e instanceof Error ? e.message : String(e)}`;
151248 }
152− await renderDiagnostics();
153− };
154− $("export").onclick = async () => {
155− const archive = await send("exportArchive");
156− const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" });
157− const a = el("a", { href: URL.createObjectURL(blob), download: `diagonal-archive-${new Date().toISOString().slice(0, 10)}.json` });
158− a.click();
159− setTimeout(() => URL.revokeObjectURL(a.href), 1000);
249+ b.disabled = false;
250+ b.textContent = "Run check";
251+ await Promise.all([renderHealth(), renderDiagnostics()]);
160252 };
161253 chrome.storage.onChanged.addListener((changes, area) => {
162254 if (area === "local" && changes.settings) {
163255 settings = withDefaults(changes.settings.newValue);
164256 }
165257 });
166− await renderDiagnostics();
258+ await Promise.all([renderHealth(), renderDiagnostics()]);
167259}
168260
169261void main();
src/popup/popup.html+71−30
@@ -5,40 +5,81 @@
55 <title>Diagonal</title>
66 <link rel="stylesheet" href="ui.css">
77 <style>
8− body { width: 360px; padding: 12px 14px 14px; }
9− .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
10− .group-actions button { padding: 2px 6px; font-size: 12px; }
11− #archive { max-height: 220px; overflow-y: auto; }
12− #groups { max-height: 200px; overflow-y: auto; }
13− .count { font-variant-numeric: tabular-nums; }
8+ body { width: 340px; background: var(--bg); }
9+ header { display: flex; align-items: center; gap: 10px; padding: 14px 14px 12px; }
10+ main { padding: 0 10px 10px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
11+ .summary { padding: 12px 14px; display: grid; gap: 2px; }
12+ .summary .big { font-size: 14px; font-weight: 600; }
13+ .section { padding: 4px 0; }
14+ .section > .head { display: flex; align-items: center; padding: 8px 14px 4px; }
15+ .list { list-style: none; margin: 0; padding: 0 4px; }
16+ .scroll { max-height: 232px; overflow-y: auto; }
17+
18+ details.group > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 8px; cursor: pointer; }
19+ details.group > summary::-webkit-details-marker { display: none; }
20+ details.group > summary:hover, details.group[open] > summary { background: var(--surface-2); }
21+ details.group .count { color: var(--faint); font-size: 12px; }
22+ details.group .more { padding: 4px 10px 10px 30px; display: grid; gap: 6px; }
23+ details.group .acts { display: flex; flex-wrap: wrap; gap: 4px; }
24+ .tag { font-size: 11px; color: var(--muted); }
25+
26+ .arch { display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: 8px; }
27+ .arch:hover { background: var(--surface-2); }
28+ .arch .acts { display: flex; align-items: center; gap: 0; }
29+ .arch .forget { color: var(--faint); font-size: 15px; line-height: 1; padding: 2px 6px; }
30+ .arch .sub { font-size: 11px; color: var(--faint); }
31+ .empty { padding: 6px 14px 10px; color: var(--muted); }
32+
33+ .result { padding: 9px 12px; background: var(--accent-soft); border-radius: var(--radius); display: flex; align-items: center; gap: 8px; }
34+ .result .grow { color: var(--text); }
35+ .park { padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
36+
37+ footer { display: flex; align-items: center; gap: 4px; padding: 8px 10px 12px; border-top: 1px solid var(--border); }
1438 </style>
1539</head>
1640<body>
17− <div class="row">
41+ <header>
42+ <img class="logo" src="icons/48.png" width="26" height="26" alt="">
1843 <h1 class="grow">Diagonal</h1>
19− <span class="pill" id="status"><span class="dot" id="status-dot"></span><span id="status-text">Checking…</span></span>
20− </div>
21− <div id="notice" class="notice" hidden></div>
22−
23− <div class="actions">
24− <button id="organize">Organize now</button>
25− <button id="tidy">Tidy now</button>
26− <button id="park" hidden></button>
27− </div>
28− <div class="actions">
29− <button class="link" id="undo-organize" hidden>Undo organize</button>
30− <button class="link" id="undo-sweep" hidden>Undo last sweep</button>
31− </div>
32− <p id="result" class="muted" hidden></p>
33− <p id="parked" class="muted"></p>
34−
35− <h2>Groups in this window</h2>
36− <ul class="list" id="groups"></ul>
37−
38− <div class="row"><h2 class="grow">Archive</h2><button class="link" id="restore-all" hidden>Restore all</button></div>
39− <ul class="list" id="archive"></ul>
40−
41− <div class="row" style="margin-top:12px"><span class="grow"></span><button class="link" id="options">Settings</button></div>
44+ <span class="pill warn busy" id="status"><span class="dot"></span><span id="status-text">Connecting…</span></span>
45+ </header>
46+
47+ <main>
48+ <div id="notices" class="notices" style="display:grid;gap:8px" hidden></div>
49+
50+ <div class="card summary">
51+ <div class="big" id="summary-title">Keeping this window tidy</div>
52+ <div class="muted" id="summary-sub">Related tabs gather into named groups as you browse.</div>
53+ </div>
54+
55+ <div class="card park" id="park-card" hidden>
56+ <div class="grow" id="park-text"></div>
57+ <button class="primary small" id="park"></button>
58+ </div>
59+
60+ <div class="result" id="result-card" hidden>
61+ <span class="grow" id="result"></span>
62+ <button class="link" id="undo-organize" hidden>Undo</button>
63+ <button class="link" id="undo-sweep" hidden>Undo</button>
64+ </div>
65+
66+ <div class="card section">
67+ <div class="head"><h2 class="grow">Groups in this window</h2></div>
68+ <ul class="list scroll" id="groups"></ul>
69+ </div>
70+
71+ <div class="card section" id="archive-card" hidden>
72+ <div class="head"><h2 class="grow">Recently archived</h2><button class="link small" id="restore-all">Restore all</button></div>
73+ <ul class="list scroll" id="archive"></ul>
74+ </div>
75+ </main>
76+
77+ <footer>
78+ <button class="quiet small" id="organize" title="Group loose tabs now instead of waiting">Organize now</button>
79+ <button class="quiet small" id="tidy" title="Park tabs you haven't used in a while">Tidy now</button>
80+ <span class="grow"></span>
81+ <button class="quiet small" id="options">Settings</button>
82+ </footer>
4283 <script src="popup.js" type="module"></script>
4384</body>
4485</html>
src/popup/popup.ts+87−79
@@ -1,16 +1,13 @@
1+import { notices, renderNotice, renderPill, type Health } from "../shared/health";
12import { ago, el, send } from "../shared/messaging";
23
34const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
45let windowId: number | undefined;
6+let resultText = "";
7+const open = new Set<number>(); // groups whose actions are expanded, kept across refreshes
58
6−interface Status {
7− health: "ok" | "unknown" | "degraded";
8− groupsSupported: boolean;
9− message: string;
10− lastError?: { code: string; message: string };
9+interface Status extends Health {
1110 lastPing?: { fmAvailable: boolean; schemasOk: boolean };
12− timeoutHint: boolean;
13− inFlight: boolean;
1411 tidyMode: string;
1512 tidyThreshold: number;
1613 tidyCandidates: number;
@@ -18,100 +15,95 @@ interface Status {
1815 canUndoSweep: boolean;
1916 canUndoOrganize: boolean;
2017 archive: { url: string; title: string; favIconUrl?: string; groupTitle?: string; archivedAt: number }[];
21− groups: { id: number; title: string; origin: string; managed: boolean; userNamed: boolean; keep: boolean; dirty: boolean; size: number }[];
18+ groups: { id: number; title: string; color?: string; origin: string; managed: boolean; userNamed: boolean; keep: boolean; dirty: boolean; size: number }[];
2219 extensionId: string;
2320 manifestPath: string;
2421}
2522
23+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
24+
2625function renderStatus(s: Status): void {
27− const dot = $("status-dot");
28− const text = $("status-text");
29− dot.className = "dot";
30− if (s.health === "degraded") {
31− dot.classList.add("bad");
32− const code = s.lastError?.code;
33− text.textContent =
34− code === "HOST_NOT_FOUND" ? "Host not installed" :
35− code === "MODEL_UNAVAILABLE" ? "Model unavailable" :
36− code === "LICENSE_REQUIRED" ? "Run sudo fm license" :
37− code === "HOST_FORBIDDEN" || code === "FORBIDDEN_ORIGIN" ? "Host refuses this ID" :
38− code === "SCHEMA_MISSING" ? "Schemas missing" : "Host unavailable";
39− } else if (s.health === "ok") {
40− dot.classList.add("ok");
41− text.textContent = s.inFlight ? "Naming…" : "Host ok";
42− } else {
43− dot.classList.add("warn");
44− text.textContent = "Not checked yet";
45− }
46− const notice = $("notice");
47− notice.replaceChildren();
48− const lines: (string | Node)[] = [];
49− if (!s.groupsSupported) lines.push("This browser doesn't let extensions manage tab groups, so Diagonal can't group tabs here.");
50− if (s.message) lines.push(s.message);
51− if (s.timeoutHint) lines.push("Three timeouts in a row: consider raising the timeout in Settings.");
52− if (s.lastError?.code === "HOST_NOT_FOUND") {
53− lines.push(el("div", {}, el("button", { className: "link", textContent: "Copy manifest path", onclick: () => navigator.clipboard.writeText(s.manifestPath) })));
54− }
55− if (s.lastError?.code === "MODEL_UNAVAILABLE") {
56− lines.push(el("div", {}, el("button", {
57− className: "link",
58− textContent: "Open Apple Intelligence settings",
59− onclick: () => chrome.tabs.create({ url: "x-apple.systempreferences:com.apple.Siri-Settings.extension" }),
60− })));
61− }
62− notice.hidden = lines.length === 0;
63− notice.className = s.health === "degraded" ? "notice bad" : "notice";
64− for (const l of lines) notice.append(typeof l === "string" ? el("div", { textContent: l }) : l);
26+ renderPill($("status"), s);
27+
28+ const list = notices(s);
29+ const box = $("notices");
30+ box.replaceChildren(...list.map(renderNotice));
31+ box.hidden = list.length === 0;
32+
33+ const tabs = s.groups.reduce((n, g) => n + g.size, 0);
34+ const naming = s.groups.filter((g) => g.dirty && !g.userNamed).length;
35+ $("summary-title").textContent = s.groups.length ? `${plural(s.groups.length, "group")} · ${plural(tabs, "tab")}` : "Nothing to group yet";
36+ $("summary-sub").textContent = [
37+ s.groups.length ? "" : "Related tabs gather into named groups as you browse.",
38+ naming ? `Naming ${plural(naming, "group")}…` : "",
39+ s.parkedCount ? `${plural(s.parkedCount, "tab")} parked` : "",
40+ ].filter(Boolean).join(" · ") || "Everything is in place.";
41+
42+ const ask = s.tidyMode === "ask" && s.tidyCandidates >= s.tidyThreshold;
43+ $("park-card").hidden = !ask;
44+ $("park-text").textContent = `${plural(s.tidyCandidates, "tab")} you haven't used in a while.`;
45+ $("park").textContent = "Park them";
6546
66− const park = $<HTMLButtonElement>("park");
67− park.hidden = !(s.tidyMode === "ask" && s.tidyCandidates >= s.tidyThreshold);
68− park.textContent = `Park ${s.tidyCandidates} tabs`;
6947 $("undo-sweep").hidden = !s.canUndoSweep;
7048 $("undo-organize").hidden = !s.canUndoOrganize;
71− $("parked").textContent = s.parkedCount ? `${s.parkedCount} tab${s.parkedCount === 1 ? "" : "s"} parked in this window` : "";
49+ const both = s.canUndoSweep && s.canUndoOrganize;
50+ $("undo-organize").textContent = both ? "Undo organize" : "Undo";
51+ $("undo-sweep").textContent = both ? "Undo tidy" : "Undo";
52+ const fallback = s.canUndoOrganize ? "Organized a moment ago." : s.canUndoSweep ? "Tidied a moment ago." : "";
53+ $("result").textContent = resultText || fallback;
54+ $("result-card").hidden = !(resultText || fallback);
7255
7356 const groups = $("groups");
7457 groups.replaceChildren();
75− if (!s.groups.length) groups.append(el("li", { className: "muted", textContent: "No groups yet. Cmd-click a link to start one." }));
58+ if (!s.groups.length) groups.append(el("li", { className: "empty", textContent: "Open a few tabs on one topic and they'll gather here." }));
7659 for (const g of s.groups) {
7760 const act = (label: string, title: string, cmd: string, extra: Record<string, unknown> = {}) =>
78− el("button", { textContent: label, title, onclick: () => run(cmd, { groupId: g.id, ...extra }) });
79− const tags = [g.origin === "user" ? "yours" : g.origin, g.userNamed ? "fixed name" : g.dirty ? "renaming" : "", g.keep ? "kept" : ""].filter(Boolean).join(" · ");
80− groups.append(
81− el("li", {},
82− el("div", { className: "row" },
83− el("span", { className: "grow ellipsis", textContent: g.title || "(untitled)" }),
84− el("span", { className: "muted count", textContent: String(g.size) })),
85− el("div", { className: "row group-actions" },
86− el("span", { className: "grow muted", textContent: tags }),
87− act("Name now", "Name this group now", "groupNameNow"),
88− act(g.keep ? "Unkeep" : "Keep", "Exclude from tidy", "groupKeep", { keep: !g.keep }),
89− ...(g.userNamed ? [] : [act("Don't name", "Stop naming this group", "groupDontName")]),
61+ el("button", { className: "small", textContent: label, title, onclick: () => run(cmd, { groupId: g.id, ...extra }) });
62+ const tags = [
63+ g.origin === "user" ? "Your group" : "Made by Diagonal",
64+ g.userNamed ? "your title" : g.dirty ? "naming…" : "",
65+ g.keep ? "never tidied" : "",
66+ ].filter(Boolean).join(" · ");
67+ const d = el("details", { className: "group", open: open.has(g.id) },
68+ el("summary", {},
69+ el("span", { className: `swatch ${g.color ?? "grey"}` }),
70+ el("span", { className: "grow ellipsis", textContent: g.title || "Untitled group" }),
71+ el("span", { className: "count num", textContent: String(g.size) }),
72+ el("span", { className: "chev" })),
73+ el("div", { className: "more" },
74+ el("div", { className: "tag", textContent: tags }),
75+ el("div", { className: "acts" },
76+ ...(g.userNamed ? [] : [act("Rename now", "Ask the model for a new name now", "groupNameNow")]),
77+ act(g.keep ? "Allow tidying" : "Never tidy", "Keep these tabs out of tidy sweeps", "groupKeep", { keep: !g.keep }),
78+ ...(g.userNamed ? [] : [act("Stop naming", "Leave this group's title alone", "groupDontName")]),
9079 act("Ungroup", "Ungroup these tabs", "groupUngroup"))));
80+ d.addEventListener("toggle", () => (d.open ? open.add(g.id) : open.delete(g.id)));
81+ groups.append(el("li", {}, d));
9182 }
9283
9384 const archive = $("archive");
9485 archive.replaceChildren();
95− $("restore-all").hidden = s.archive.length === 0;
96− if (!s.archive.length) archive.append(el("li", { className: "muted", textContent: "Nothing archived." }));
86+ $("archive-card").hidden = s.archive.length === 0;
9787 for (const a of s.archive) {
9888 const icon = a.favIconUrl && /^(https?|data):/.test(a.favIconUrl) ? el("img", { className: "fav", src: a.favIconUrl, alt: "" }) : el("span", { className: "fav" });
9989 archive.append(
100− el("li", { className: "row" },
90+ el("li", { className: "arch" },
10191 icon,
10292 el("div", { className: "grow" },
103− el("div", { className: "ellipsis", textContent: a.title, title: a.url }),
104− el("div", { className: "muted ellipsis", textContent: [a.groupTitle, ago(a.archivedAt)].filter(Boolean).join(" · ") })),
105− el("button", { className: "link", textContent: "Restore", onclick: () => run("restore", { archivedAt: a.archivedAt, url: a.url }) }),
106− el("button", { className: "link", textContent: "Forget", onclick: () => run("forget", { archivedAt: a.archivedAt, url: a.url }) })));
93+ el("div", { className: "ellipsis", textContent: a.title || a.url, title: a.url }),
94+ el("div", { className: "sub ellipsis", textContent: [a.groupTitle, ago(a.archivedAt)].filter(Boolean).join(" · ") })),
95+ el("div", { className: "acts" },
96+ el("button", { className: "link small", textContent: "Restore", onclick: () => run("restore", { archivedAt: a.archivedAt, url: a.url }) }),
97+ el("button", { className: "quiet forget", textContent: "×", title: "Forget this tab", ariaLabel: "Forget", onclick: () => run("forget", { archivedAt: a.archivedAt, url: a.url }) }))));
10798 }
10899}
109100
110101async function refresh(): Promise<void> {
111102 try {
112103 renderStatus(await send<Status>("status", { windowId }));
113− } catch (e) {
114− $("status-text").textContent = "Worker not responding";
104+ } catch {
105+ renderPill($("status"), { health: "degraded", groupsSupported: true, message: "", timeoutHint: false, inFlight: false });
106+ $("status-text").textContent = "Not responding";
115107 }
116108}
117109
@@ -126,9 +118,9 @@ async function run(cmd: string, extra: Record<string, unknown> = {}): Promise<un
126118}
127119
128120function showResult(text: string): void {
129− const r = $("result");
130− r.textContent = text;
131− r.hidden = !text;
121+ resultText = text;
122+ $("result").textContent = text;
123+ $("result-card").hidden = !text;
132124}
133125
134126async function main(): Promise<void> {
@@ -140,16 +132,32 @@ async function main(): Promise<void> {
140132 const r = (await run("organize")) as { grouped: number; groups: number; left: number; error?: string } | undefined;
141133 b.disabled = false;
142134 b.textContent = "Organize now";
143− if (r) showResult(`${r.grouped} tabs grouped into ${r.groups} new group${r.groups === 1 ? "" : "s"}, ${r.left} left${r.error ? ` (stopped: ${r.error})` : ""}.`);
135+ if (r) {
136+ showResult(r.grouped
137+ ? `Grouped ${plural(r.grouped, "tab")} into ${plural(r.groups, "new group")}.${r.error ? ` Stopped early: ${r.error}` : ""}`
138+ : r.error ? `Couldn't organize: ${r.error}` : "Nothing new to group.");
139+ await refresh();
140+ }
144141 };
145142 const tidy = async () => {
146143 const r = (await run("tidyNow")) as { parked: number; archived: number } | undefined;
147− if (r) showResult(`Parked ${r.parked}, archived ${r.archived}.`);
144+ if (r) {
145+ showResult(r.parked || r.archived
146+ ? [r.parked ? `Parked ${plural(r.parked, "tab")}` : "", r.archived ? `archived ${plural(r.archived, "tab")}` : ""].filter(Boolean).join(", ") + "."
147+ : "Nothing needed tidying.");
148+ await refresh();
149+ }
148150 };
149151 $("tidy").onclick = tidy;
150152 $("park").onclick = tidy;
151− $("undo-sweep").onclick = async () => showResult(`Restored ${await run("undoSweep")} tabs.`);
152− $("undo-organize").onclick = async () => showResult(`Restored ${await run("undoOrganize")} tabs.`);
153+ $("undo-sweep").onclick = async () => {
154+ const n = (await run("undoSweep")) as number | undefined;
155+ showResult(`Put back ${plural(n ?? 0, "tab")}.`);
156+ };
157+ $("undo-organize").onclick = async () => {
158+ const n = (await run("undoOrganize")) as number | undefined;
159+ showResult(`Put back ${plural(n ?? 0, "tab")}.`);
160+ };
153161 $("restore-all").onclick = () => run("restoreAll");
154162 $("options").onclick = () => chrome.runtime.openOptionsPage();
155163 await refresh();
src/shared/health.tsnew file+104
@@ -0,0 +1,104 @@
1+/** Turns the worker's host health into the words the popup and settings page show. */
2+import { el } from "./messaging";
3+
4+export interface Health {
5+ health: "ok" | "unknown" | "degraded";
6+ groupsSupported: boolean;
7+ message: string;
8+ lastError?: { code: string; message: string };
9+ timeoutHint: boolean;
10+ inFlight: boolean;
11+}
12+
13+export type Tone = "ok" | "warn" | "bad";
14+
15+export interface Notice {
16+ tone: "warn" | "bad" | "info";
17+ title: string;
18+ body: string;
19+ command?: string;
20+ action?: { label: string; run: () => void };
21+}
22+
23+export function pill(s: Health): { tone: Tone; label: string; busy: boolean } {
24+ if (s.health === "degraded") {
25+ const code = s.lastError?.code;
26+ const label =
27+ code === "HOST_NOT_FOUND" ? "Setup needed" :
28+ code === "LICENSE_REQUIRED" ? "Terms needed" :
29+ code === "MODEL_UNAVAILABLE" ? "Model off" :
30+ code === "HOST_FORBIDDEN" || code === "FORBIDDEN_ORIGIN" ? "Setup needed" :
31+ code === "SCHEMA_MISSING" ? "Setup needed" : "Paused";
32+ return { tone: "bad", label, busy: false };
33+ }
34+ if (s.health === "ok") return { tone: "ok", label: s.inFlight ? "Naming…" : "Ready", busy: s.inFlight };
35+ return { tone: "warn", label: "Connecting…", busy: true };
36+}
37+
38+export function notices(s: Health): Notice[] {
39+ const out: Notice[] = [];
40+ if (!s.groupsSupported) {
41+ out.push({ tone: "bad", title: "This browser can't group tabs", body: "It doesn't let extensions manage tab groups, so Diagonal has nothing to do here." });
42+ }
43+ if (s.health === "degraded") {
44+ const e = s.lastError;
45+ switch (e?.code) {
46+ case "HOST_NOT_FOUND":
47+ out.push({
48+ tone: "bad", title: "Finish setting up Diagonal",
49+ body: "Diagonal's helper isn't registered with this browser yet. Run this in Terminal, then reopen Diagonal.",
50+ command: "brew reinstall diagonal",
51+ });
52+ break;
53+ case "LICENSE_REQUIRED":
54+ out.push({
55+ tone: "bad", title: "Accept Apple's model terms",
56+ body: "Apple's fm tool needs its terms accepted once on this Mac. Run this in Terminal and Diagonal picks up on its own.",
57+ command: "sudo fm license",
58+ });
59+ break;
60+ case "MODEL_UNAVAILABLE":
61+ out.push({
62+ tone: "bad", title: "Apple Intelligence is off",
63+ body: `Groups can't be named until the on-device model is available. ${e.message}`.trim(),
64+ action: { label: "Open Apple Intelligence settings", run: () => void chrome.tabs.create({ url: "x-apple.systempreferences:com.apple.Siri-Settings.extension" }) },
65+ });
66+ break;
67+ case "HOST_FORBIDDEN":
68+ case "FORBIDDEN_ORIGIN":
69+ out.push({ tone: "bad", title: "The helper doesn't recognize this extension", body: "Reinstalling registers it again.", command: "brew reinstall diagonal" });
70+ break;
71+ case "SCHEMA_MISSING":
72+ out.push({ tone: "bad", title: "The helper is missing files", body: "Run this in Terminal to restore them.", command: "diagonal-host --install-schemas" });
73+ break;
74+ default:
75+ out.push({ tone: "bad", title: "Naming is paused", body: s.message || "The helper didn't answer. Diagonal retries on its own." });
76+ }
77+ }
78+ if (s.timeoutHint && s.health !== "degraded") {
79+ out.push({ tone: "info", title: "The model is slow right now", body: "It timed out three times in a row. You can give it longer under Naming in Settings." });
80+ }
81+ return out;
82+}
83+
84+export function renderNotice(n: Notice): HTMLElement {
85+ const node = el("div", { className: `notice ${n.tone}` }, el("div", { className: "title", textContent: n.title }), el("p", { textContent: n.body }));
86+ if (n.command) {
87+ const cmd = n.command;
88+ const copy = el("button", { className: "small", textContent: "Copy" });
89+ copy.onclick = async () => {
90+ await navigator.clipboard.writeText(cmd).catch(() => undefined);
91+ copy.textContent = "Copied";
92+ setTimeout(() => (copy.textContent = "Copy"), 1400);
93+ };
94+ node.append(el("div", { className: "cmd" }, el("code", { textContent: cmd }), copy));
95+ }
96+ if (n.action) node.append(el("div", {}, el("button", { className: "link", textContent: n.action.label, onclick: n.action.run })));
97+ return node;
98+}
99+
100+export function renderPill(root: HTMLElement, s: Health): void {
101+ const p = pill(s);
102+ root.className = `pill ${p.tone}${p.busy ? " busy" : ""}`;
103+ root.replaceChildren(el("span", { className: "dot" }), el("span", { id: "status-text", textContent: p.label }));
104+}
src/shared/ui.css+130−40
@@ -1,54 +1,144 @@
1+/* Shared look for the popup and the settings page. Colours follow the icon: deep emerald glass. */
12:root {
2− --bg: #ffffff;
3− --panel: #f6f7f9;
4− --text: #1f2328;
5− --muted: #656d76;
6− --border: #d8dee4;
7− --accent: #1a7f5a;
3+ --bg: #f5f7f6;
4+ --surface: #ffffff;
5+ --surface-2: #eef2f0;
6+ --text: #15201b;
7+ --muted: #5d6b65;
8+ --faint: #8a9690;
9+ --border: #e0e6e3;
10+ --accent: #127556;
11+ --accent-hover: #0e6448;
12+ --accent-soft: #e0f1ea;
813 --accent-text: #ffffff;
9− --warn: #b35900;
10− --bad: #cf222e;
11− --good: #1a7f37;
12− font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
13− color-scheme: light dark;
14+ --warn: #9a5a00;
15+ --warn-soft: #fcf1df;
16+ --bad: #b8322a;
17+ --bad-soft: #fcebe9;
18+ --good: #1f8a58;
19+ --shadow: 0 1px 2px rgb(16 40 30 / .06), 0 1px 1px rgb(16 40 30 / .04);
20+ --radius: 10px;
21+ --g-grey: #5f6368; --g-blue: #1a73e8; --g-red: #d93025; --g-yellow: #e8a100; --g-green: #1e8e3e;
22+ --g-pink: #d01884; --g-purple: #a142f4; --g-cyan: #007b83; --g-orange: #f07a1a;
23+ font: 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
24+ color-scheme: light;
25+ -webkit-font-smoothing: antialiased;
1426}
1527@media (prefers-color-scheme: dark) {
1628 :root {
17− --bg: #1c1d1f;
18− --panel: #26282b;
19− --text: #e6e8ea;
20− --muted: #9aa1a9;
21− --border: #3a3d41;
22− --accent: #3fb883;
23− --accent-text: #0d1a14;
24− --warn: #e0a050;
25− --bad: #ff7b72;
26− --good: #56d364;
29+ --bg: #0f1412;
30+ --surface: #171e1b;
31+ --surface-2: #1f2824;
32+ --text: #e4ebe8;
33+ --muted: #97a39e;
34+ --faint: #6d7a74;
35+ --border: #28322e;
36+ --accent: #52c99c;
37+ --accent-hover: #6ad6ad;
38+ --accent-soft: #163429;
39+ --accent-text: #06140e;
40+ --warn: #e7ac5c;
41+ --warn-soft: #2e2415;
42+ --bad: #ff8d84;
43+ --bad-soft: #341b19;
44+ --good: #52c99c;
45+ --shadow: none;
46+ --g-grey: #bdc1c6; --g-blue: #8ab4f8; --g-red: #f28b82; --g-yellow: #fdd663; --g-green: #81c995;
47+ --g-pink: #ff8bcb; --g-purple: #d7aefb; --g-cyan: #78d9ec; --g-orange: #fcad70;
48+ color-scheme: dark;
2749 }
2850}
51+
2952* { box-sizing: border-box; }
53+[hidden] { display: none !important; }
3054body { margin: 0; background: var(--bg); color: var(--text); }
31−h1 { font-size: 15px; margin: 0; }
32−h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 16px 0 6px; }
55+h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: -.01em; }
56+h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0; }
57+p { margin: 0; }
58+a { color: var(--accent); }
59+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
60+
3361button {
34− font: inherit; border: 1px solid var(--border); background: var(--panel); color: var(--text);
35− border-radius: 6px; padding: 5px 10px; cursor: pointer;
62+ font: inherit; font-weight: 500; color: var(--text); background: var(--surface);
63+ border: 1px solid var(--border); border-radius: 8px; padding: 5px 11px; cursor: pointer;
64+ transition: background .12s, border-color .12s, color .12s;
3665}
37−button:hover { border-color: var(--muted); }
38−button.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; font-weight: 600; }
39−button.link { border: 0; background: none; color: var(--accent); padding: 2px 4px; }
40−button:disabled { opacity: .5; cursor: default; }
66+button:hover { background: var(--surface-2); }
67+button:disabled { opacity: .55; cursor: default; }
68+button.primary { background: var(--accent); border-color: transparent; color: var(--accent-text); }
69+button.primary:hover { background: var(--accent-hover); }
70+button.link { border: 0; background: none; color: var(--accent); padding: 2px 4px; border-radius: 6px; }
71+button.link:hover { background: var(--accent-soft); }
72+button.quiet { border-color: transparent; background: none; color: var(--muted); }
73+button.quiet:hover { background: var(--surface-2); color: var(--text); }
74+button.small { font-size: 12px; padding: 3px 8px; border-radius: 6px; }
75+
4176.muted { color: var(--muted); }
77+.faint { color: var(--faint); }
4278.row { display: flex; gap: 8px; align-items: center; }
43−.row > .grow { flex: 1; min-width: 0; }
79+.row > .grow, .grow { flex: 1; min-width: 0; }
4480.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
45−.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 8px; border-radius: 999px; background: var(--panel); }
46−.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
47−.dot.ok { background: var(--good); } .dot.bad { background: var(--bad); } .dot.warn { background: var(--warn); }
48−.notice { background: var(--panel); border-left: 3px solid var(--warn); padding: 8px 10px; border-radius: 4px; margin-top: 8px; word-break: break-word; }
49−.notice.bad { border-left-color: var(--bad); }
50−.list { list-style: none; margin: 0; padding: 0; }
51−.list li { padding: 6px 0; border-bottom: 1px solid var(--border); }
52−.list li:last-child { border-bottom: 0; }
53−code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
54−img.fav { width: 16px; height: 16px; flex: none; }
81+.num { font-variant-numeric: tabular-nums; }
82+code, .mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; font-size: 12px; }
83+img.fav { width: 16px; height: 16px; flex: none; border-radius: 3px; }
84+span.fav { display: inline-block; width: 16px; height: 16px; flex: none; border-radius: 4px; background: var(--surface-2); }
85+.logo { flex: none; border-radius: 22%; }
86+
87+.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
88+
89+/* Status pill: a dot and a short word. */
90+.pill {
91+ display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500;
92+ padding: 3px 10px 3px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); white-space: nowrap;
93+}
94+.pill.ok { background: var(--accent-soft); color: var(--accent); }
95+.pill.bad { background: var(--bad-soft); color: var(--bad); }
96+.pill.warn { background: var(--warn-soft); color: var(--warn); }
97+.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
98+.pill.busy .dot { animation: pulse 1.2s ease-in-out infinite; }
99+@keyframes pulse { 50% { opacity: .3; } }
100+@media (prefers-reduced-motion: reduce) { .pill.busy .dot { animation: none; } }
101+
102+/* Notices: a title, a sentence, and at most one command to copy. */
103+.notice { border-radius: var(--radius); padding: 10px 12px; background: var(--warn-soft); color: var(--text); display: grid; gap: 6px; }
104+.notice.bad { background: var(--bad-soft); }
105+.notice.info { background: var(--surface-2); }
106+.notice .title { font-weight: 600; }
107+.notice.bad .title { color: var(--bad); }
108+.notice:not(.bad):not(.info) .title { color: var(--warn); }
109+.notice p { color: var(--muted); word-break: break-word; }
110+.cmd {
111+ display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--border);
112+ border-radius: 7px; padding: 4px 4px 4px 10px;
113+}
114+.cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; }
115+
116+/* Chrome tab-group colours. */
117+.swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; background: var(--g-grey); }
118+.swatch.blue { background: var(--g-blue); } .swatch.red { background: var(--g-red); } .swatch.yellow { background: var(--g-yellow); }
119+.swatch.green { background: var(--g-green); } .swatch.pink { background: var(--g-pink); } .swatch.purple { background: var(--g-purple); }
120+.swatch.cyan { background: var(--g-cyan); } .swatch.orange { background: var(--g-orange); }
121+
122+/* A switch drawn over a real checkbox, so labels and keyboard behave as usual. */
123+input.switch {
124+ appearance: none; -webkit-appearance: none; margin: 0; flex: none; cursor: pointer;
125+ width: 34px; height: 20px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s;
126+}
127+input.switch::after {
128+ content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
129+ background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .25); transition: transform .15s;
130+}
131+input.switch:checked { background: var(--accent); }
132+input.switch:checked::after { transform: translateX(14px); }
133+@media (prefers-color-scheme: dark) { input.switch:not(:checked) { background: #3a4540; } }
134+
135+.toast {
136+ position: fixed; left: 50%; bottom: 16px; transform: translate(-50%, 8px); opacity: 0; pointer-events: none;
137+ background: var(--text); color: var(--bg); padding: 6px 14px; border-radius: 999px; font-size: 12px; font-weight: 500;
138+ transition: opacity .2s, transform .2s;
139+}
140+.toast.show { opacity: 1; transform: translate(-50%, 0); }
141+
142+/* Disclosure chevron drawn with borders so it turns cleanly. */
143+.chev { width: 6px; height: 6px; flex: none; border-right: 1.5px solid var(--faint); border-bottom: 1.5px solid var(--faint); transform: rotate(-45deg); transition: transform .15s; margin: 0 4px 0 2px; }
144+details[open] > summary .chev { transform: rotate(45deg); }
test/e2e/smoke.mjs+1−1
@@ -191,7 +191,7 @@ try {
191191 await popup.reload();
192192 await sleep(800);
193193 const statusText = await popup.textContent("#status-text");
194− check("popup shows the host as healthy", statusText === "Host ok" || statusText === "Naming…", statusText);
194+ check("popup shows the host as healthy", statusText === "Ready" || statusText === "Naming…", statusText);
195195 await popup.screenshot({ path: join(shotDir, "popup.png") });
196196
197197 const options = await context.newPage();