clauderipple 0.9.5 → 0.11.0

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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,109 @@
1
+ // The phone's socket through the relay: a SocketLike (conn.ts) that looks to the Link exactly like
2
+ // the direct WebSocket, but opens only after the relay connected it to the host AND the host proved
3
+ // it holds the key from the pairing QR (src/e2ee.ts). Every frame after that is sealed here; the
4
+ // relay forwards bytes it cannot read. Anything that does not open closes the socket, and the
5
+ // Link's never-give-up reconnect takes over.
6
+ import { RELAY_FRAME_CAP } from "../src/protocol.ts";
7
+ import type { RelayMsg } from "../src/protocol.ts";
8
+ import { fromB64u, phoneFinish, phoneHello } from "../src/e2ee.ts";
9
+ import type { Channel, KeyPair, Nacl } from "../src/e2ee.ts";
10
+ import type { SocketLike } from "./conn.ts";
11
+
12
+ /** What this needs of a browser WebSocket (tests pass a fake). */
13
+ export interface RawSocket {
14
+ binaryType: string;
15
+ readyState: number;
16
+ send(data: string | Uint8Array): void;
17
+ close(code?: number, reason?: string): void;
18
+ onopen: ((ev: unknown) => void) | null;
19
+ onmessage: ((ev: { data: unknown }) => void) | null;
20
+ onclose: ((ev: unknown) => void) | null;
21
+ onerror: ((ev: unknown) => void) | null;
22
+ }
23
+
24
+ export interface RelayPairing { url: string; server: string; hpk: string }
25
+
26
+ export function relayClientUrl(p: RelayPairing): string {
27
+ const u = new URL(p.url);
28
+ return `${u.protocol === "https:" ? "wss:" : "ws:"}//${u.host}/ws/client?server=${encodeURIComponent(p.server)}`;
29
+ }
30
+
31
+ /** tweetnacl, loaded by index.html as a classic script (vendor/nacl-fast.min.js sets self.nacl). */
32
+ export function browserNacl(): Nacl {
33
+ const n = (globalThis as unknown as { nacl?: Nacl }).nacl;
34
+ if (!n) throw new Error("tweetnacl did not load");
35
+ return n;
36
+ }
37
+
38
+ export class RelaySocket implements SocketLike {
39
+ readyState = 0;
40
+ onopen: ((ev: unknown) => void) | null = null;
41
+ onmessage: ((ev: { data: unknown }) => void) | null = null;
42
+ onclose: ((ev: unknown) => void) | null = null;
43
+ onerror: ((ev: unknown) => void) | null = null;
44
+
45
+ private raw: RawSocket;
46
+ private nacl: Nacl;
47
+ private hostKey: Uint8Array;
48
+ private stage: "relay" | "handshake" | "open" | "closed" = "relay";
49
+ private eph: KeyPair | null = null;
50
+ private channel: Channel | null = null;
51
+
52
+ constructor(raw: RawSocket, pairing: RelayPairing, nacl: Nacl) {
53
+ this.raw = raw;
54
+ this.nacl = nacl;
55
+ this.hostKey = fromB64u(pairing.hpk);
56
+ raw.binaryType = "arraybuffer";
57
+ raw.onmessage = (ev) => this.onRaw(ev.data);
58
+ raw.onclose = (ev) => this.end(ev);
59
+ raw.onerror = (ev) => this.onerror?.(ev);
60
+ }
61
+
62
+ send(data: string): void {
63
+ if (this.stage !== "open" || !this.channel) throw new Error("not open");
64
+ this.raw.send(this.channel.seal(data));
65
+ }
66
+
67
+ close(code?: number, reason?: string): void {
68
+ try { this.raw.close(code, reason); } catch { /* gone */ }
69
+ this.end({ code });
70
+ }
71
+
72
+ private onRaw(data: unknown): void {
73
+ try {
74
+ if (this.stage === "relay") {
75
+ // The relay's one word to us: the host has a data socket for this connection. Then the handshake.
76
+ if (typeof data !== "string" || (JSON.parse(data) as RelayMsg).relay !== "open") throw new Error("expected relay open");
77
+ const hello = phoneHello(this.nacl);
78
+ this.eph = hello.eph;
79
+ this.stage = "handshake";
80
+ this.raw.send(hello.text);
81
+ return;
82
+ }
83
+ if (this.stage === "handshake") {
84
+ if (typeof data !== "string") throw new Error("expected handshake");
85
+ this.channel = phoneFinish(this.nacl, this.eph!, this.hostKey, data);
86
+ this.eph = null;
87
+ this.stage = "open";
88
+ this.readyState = 1;
89
+ this.onopen?.({});
90
+ return;
91
+ }
92
+ if (this.stage === "open") {
93
+ if (!(data instanceof ArrayBuffer) || data.byteLength > RELAY_FRAME_CAP) throw new Error("bad frame");
94
+ this.onmessage?.({ data: this.channel!.open(new Uint8Array(data)) });
95
+ }
96
+ } catch {
97
+ // Not the paired host, or an altered frame: drop the socket, never the check.
98
+ this.close(4400, "e2ee");
99
+ }
100
+ }
101
+
102
+ private end(ev: unknown): void {
103
+ if (this.stage === "closed") return;
104
+ this.stage = "closed";
105
+ this.readyState = 3;
106
+ this.channel = null;
107
+ this.onclose?.(ev);
108
+ }
109
+ }
@@ -0,0 +1,106 @@
1
+ import type { SessionSummary } from "../src/protocol.ts";
2
+ import { lang, t } from "./i18n.ts";
3
+
4
+ export function sidebarLabel(name: string): string {
5
+ if (name === "sidebar:ungrouped") return lang() === "ko" ? "그룹 없음" : "No group";
6
+ if (name === "sidebar:phone") return lang() === "ko" ? "휴대폰에서 시작" : "Started on phone";
7
+ if (name === "sidebar:routines") return lang() === "ko" ? "예약 작업" : "Scheduled tasks";
8
+ if (name === "sidebar:pinned") return lang() === "ko" ? "고정됨" : "Pinned";
9
+ if (name === "sidebar:recent") return lang() === "ko" ? "최근" : "Recent";
10
+ return name;
11
+ }
12
+ const RECENT_MS = 60 * 60_000;
13
+
14
+ /** Needs a look now: working, waiting, background work, unread, or talked to in the last hour. */
15
+ export function isRecent(s: SessionSummary, now: number): boolean {
16
+ return sidebarState(s) !== "read" || now - s.updatedAt < RECENT_MS;
17
+ }
18
+
19
+ /**
20
+ * The app's groups in the app's order, with a 최근 section on top that the app lacks: the
21
+ * sessions that need a look now (isRecent), newest first. Those leave their group so nothing
22
+ * is listed twice.
23
+ */
24
+ export function sidebarSections(sessions: SessionSummary[], groups?: string[], now = Date.now()): { name: string; sessions: SessionSummary[] }[] {
25
+ const recent = sessions.filter(s => isRecent(s, now)).sort((a, b) => b.updatedAt - a.updatedAt);
26
+ const rest = sessions.filter(s => !isRecent(s, now));
27
+ const groupOf = (s: SessionSummary) => s.group ?? (s.origin === "desktop" ? "sidebar:ungrouped" : "sidebar:phone");
28
+ const names = [...new Set([...(groups ?? []), ...rest.map(groupOf)])];
29
+ return [{ name: sidebarLabel("sidebar:recent"), sessions: recent }, ...names.map(name => ({ name: sidebarLabel(name), sessions: rest.filter(s => groupOf(s) === name)
30
+ .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || b.updatedAt - a.updatedAt) }))].filter(g => g.sessions.length > 0);
31
+ }
32
+ /**
33
+ * The app sidebar's rule (checked against live sessions 2026-10-12): unread outranks working,
34
+ * and working is one state whether the main turn or only subagents run. Opening the phone never
35
+ * changes Claude's read state. The transcript task count is not used here: tasks whose end was
36
+ * never recorded linger in it, which made long-idle sessions blink.
37
+ */
38
+ export function sidebarState(s: SessionSummary): string {
39
+ if (s.pending > 0 || s.state === "waiting") return "waiting";
40
+ if (s.state === "error") return "error";
41
+ if (s.unread) return "unread";
42
+ if (s.state === "running" || s.state === "starting" || s.background) return "running";
43
+ return "read";
44
+ }
45
+
46
+ // ---- the phone's session list: grouped by status (or folder), newest change first --------------
47
+
48
+ /** The list's five buckets, in the order they stack from the top. */
49
+ export type SessionGroup = "input" | "failed" | "review" | "working" | "done";
50
+ export const GROUP_ORDER: readonly SessionGroup[] = ["input", "failed", "review", "working", "done"];
51
+
52
+ /**
53
+ * The bucket a session belongs to. A permission request (open or waiting) outranks everything; the
54
+ * last turn's error outranks a lingering finished flag; a turn that finished and nobody has looked
55
+ * at yet (its `attention`) is 검토 대기; running/starting/background is 작업 중; the rest is 완료.
56
+ */
57
+ export function sessionGroup(s: SessionSummary): SessionGroup {
58
+ if (s.pending > 0 || s.state === "waiting") return "input";
59
+ if (s.state === "error") return "failed";
60
+ if (s.attention?.reason === "finished") return "review";
61
+ if (s.state === "running" || s.state === "starting" || s.background) return "working";
62
+ return "done";
63
+ }
64
+
65
+ export function groupLabel(group: SessionGroup): string {
66
+ return t(`group.${group}` as Parameters<typeof t>[0]);
67
+ }
68
+
69
+ export type GroupMode = "status" | "folder";
70
+
71
+ /** Newest state change first: the host stamps `updatedAt` when a state changes, so this is that time. */
72
+ function byRecency(a: SessionSummary, b: SessionSummary): number {
73
+ return b.updatedAt - a.updatedAt || a.id.localeCompare(b.id);
74
+ }
75
+
76
+ /** The last path segment, the way a folder is named in speech. `~` for the home folder. */
77
+ function folderLabel(p: string, home?: string): string {
78
+ if (home && p === home) return "~";
79
+ const parts = p.split("/").filter(Boolean);
80
+ return parts[parts.length - 1] ?? "/";
81
+ }
82
+
83
+ /** The folder's most recently changed session, for ordering folder sections. */
84
+ function folderRecency(sessions: SessionSummary[]): number {
85
+ return sessions.reduce((max, s) => Math.max(max, s.updatedAt), 0);
86
+ }
87
+
88
+ /**
89
+ * The list in the order the phone shows it. `status` (default) stacks the five buckets; `folder`
90
+ * groups by working directory instead. Archived sessions are hidden unless `includeArchived`,
91
+ * because archiving only sets a flag — the transcripts and files stay. Within a section, newest
92
+ * state change first.
93
+ */
94
+ export function groupSessions(sessions: SessionSummary[], mode: GroupMode = "status", opts: { includeArchived?: boolean; home?: string } = {}): { key: string; name: string; sessions: SessionSummary[] }[] {
95
+ const list = opts.includeArchived ? sessions.slice() : sessions.filter(s => !s.archived);
96
+ if (mode === "folder") {
97
+ const byFolder = new Map<string, SessionSummary[]>();
98
+ for (const s of list) { const bucket = byFolder.get(s.cwd); if (bucket) bucket.push(s); else byFolder.set(s.cwd, [s]); }
99
+ return [...byFolder.entries()]
100
+ .sort((a, b) => folderRecency(b[1]) - folderRecency(a[1]) || a[0].localeCompare(b[0]))
101
+ .map(([cwd, group]) => ({ key: `folder:${cwd}`, name: folderLabel(cwd, opts.home), sessions: group.sort(byRecency) }));
102
+ }
103
+ return GROUP_ORDER
104
+ .map(group => ({ key: `group:${group}`, name: groupLabel(group), sessions: list.filter(s => sessionGroup(s) === group).sort(byRecency) }))
105
+ .filter(section => section.sessions.length > 0);
106
+ }
@@ -0,0 +1,311 @@
1
+ /* Ripple Remote. Tokens follow the mascot's palette (packages/mod/hooks/ui/theme.ts values);
2
+ surfaces are warm neutrals so the accent is the only colour that speaks. */
3
+ .tasks-chip { width: fit-content; margin: 0 6px 8px; padding: 6px 10px; border-radius: var(--r-sm); background: var(--surface-2); color: var(--muted); font-size: 13px; }
4
+ button.tasks-chip { display: flex; align-items: center; gap: 4px; border: 0; font: inherit; font-size: 13px; cursor: pointer; }
5
+ .tasks-list { margin: 0 6px 6px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--surface); }
6
+ .tasks-list .task { display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--muted); }
7
+ .tasks-list .task + .task { border-top: 1px solid var(--line); }
8
+ .tasks-list .task span { flex: 1; min-width: 0; color: var(--ink-2); font-size: 13.5px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
9
+ .tasks-list .task time { color: var(--faint); font-size: 12.5px; font-variant-numeric: tabular-nums; }
10
+ :root {
11
+ --bg: #f5f4f1;
12
+ --surface: #ffffff;
13
+ --surface-2: #eceae5;
14
+ --sunk: #e7e5df;
15
+ --ink: #1d1b26;
16
+ --ink-2: #3b3946;
17
+ --muted: #64616f;
18
+ --faint: #c9c6d2;
19
+ --line: rgba(29, 27, 38, 0.1);
20
+ --line-2: rgba(29, 27, 38, 0.16);
21
+ --accent: #6a56d6;
22
+ --accent-strong: #5b47c9;
23
+ --accent-soft: rgba(106, 86, 214, 0.1);
24
+ --accent-ink: #ffffff;
25
+ --user-bg: #ebe7fb;
26
+ --warn: #b5820a;
27
+ --warn-soft: rgba(181, 130, 10, 0.12);
28
+ --bad: #c73434;
29
+ --bad-soft: rgba(199, 52, 52, 0.1);
30
+ --ok: #1a8a4a;
31
+ --ok-soft: rgba(26, 138, 74, 0.1);
32
+ --shadow: 0 1px 2px rgba(29, 27, 38, 0.06), 0 8px 24px rgba(29, 27, 38, 0.08);
33
+ --sheet-shadow: 0 -10px 40px rgba(29, 27, 38, 0.18);
34
+ --scrim: rgba(20, 18, 28, 0.32);
35
+ --font: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", "Noto Sans KR", system-ui, sans-serif;
36
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
37
+ --r-lg: 20px;
38
+ --r-md: 14px;
39
+ --r-sm: 10px;
40
+ --tap: 48px;
41
+ --safe-b: env(safe-area-inset-bottom, 0px);
42
+ --safe-t: env(safe-area-inset-top, 0px);
43
+ color-scheme: light;
44
+ }
45
+ @media (prefers-color-scheme: dark) {
46
+ :root:not([data-theme="light"]) {
47
+ --bg: #141413; --surface: #1e1e1d; --surface-2: #2a2a28; --sunk: #232322; --ink: #ecebf2; --ink-2: #cfcdd8;
48
+ --muted: #a19fae; --faint: #55535f; --line: rgba(236, 235, 242, 0.09); --line-2: rgba(236, 235, 242, 0.15);
49
+ --accent: #a594f5; --accent-strong: #6f5bdc; --accent-soft: rgba(165, 148, 245, 0.13); --accent-ink: #ffffff; --user-bg: #2c2840;
50
+ --warn: #e0b436; --warn-soft: rgba(224, 180, 54, 0.13); --bad: #ef5a5a; --bad-soft: rgba(239, 90, 90, 0.13);
51
+ --ok: #3ecb7d; --ok-soft: rgba(62, 203, 125, 0.12);
52
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); --sheet-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
53
+ --scrim: rgba(0, 0, 0, 0.5); color-scheme: dark;
54
+ }
55
+ }
56
+ :root[data-theme="dark"] {
57
+ --bg: #141413; --surface: #1e1e1d; --surface-2: #2a2a28; --sunk: #232322; --ink: #ecebf2; --ink-2: #cfcdd8;
58
+ --muted: #a19fae; --faint: #55535f; --line: rgba(236, 235, 242, 0.09); --line-2: rgba(236, 235, 242, 0.15);
59
+ --accent: #a594f5; --accent-strong: #6f5bdc; --accent-soft: rgba(165, 148, 245, 0.13); --accent-ink: #ffffff; --user-bg: #2c2840;
60
+ --warn: #e0b436; --warn-soft: rgba(224, 180, 54, 0.13); --bad: #ef5a5a; --bad-soft: rgba(239, 90, 90, 0.13);
61
+ --ok: #3ecb7d; --ok-soft: rgba(62, 203, 125, 0.12);
62
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); --sheet-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
63
+ --scrim: rgba(0, 0, 0, 0.5); color-scheme: dark;
64
+ }
65
+
66
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
67
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); }
68
+ body { font: 16px/1.5 var(--font); -webkit-font-smoothing: antialiased; overscroll-behavior: none; }
69
+ button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
70
+ input, textarea { font: inherit; color: inherit; }
71
+ .ic { flex: none; display: block; }
72
+ .tnum { font-variant-numeric: tabular-nums; }
73
+
74
+ /* ---- frame: header / band / banner / scrolling main / thumb dock ---------------------------- */
75
+ .app { height: 100dvh; display: flex; flex-direction: column; max-width: 640px; margin: 0 auto; position: relative; overflow: hidden; }
76
+ .top { flex: none; padding: calc(var(--safe-t) + 6px) 8px 6px; display: flex; align-items: center; gap: 4px; min-height: 52px; background: var(--bg); position: relative; z-index: 2; }
77
+ .top .icon-btn { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 12px; color: var(--ink-2); }
78
+ .top .icon-btn:active { background: var(--surface-2); }
79
+ .top .title { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; line-height: 1.2; padding: 0 4px; }
80
+ .top .title b { font-size: 16px; font-weight: 650; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
+ .top .title button { font-size: 13px; color: var(--muted); display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
82
+ .top .title button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
83
+ .top .spacer { flex: 1; }
84
+ .top .icon-btn.stale .ring { opacity: 0.45; filter: grayscale(0.7); }
85
+
86
+ .link { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px 0 10px; border-radius: 99px; background: var(--surface); border: 1px solid var(--line); font-size: 13.5px; font-weight: 550; color: var(--ink-2); white-space: nowrap; }
87
+ .link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
88
+ .link.waiting .dot, .link.connecting .dot { background: var(--warn); box-shadow: 0 0 0 3px var(--warn-soft); }
89
+ .link.offline .dot, .link.denied .dot, .link.lost .dot { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-soft); }
90
+ .link .host { color: var(--muted); font-weight: 500; }
91
+
92
+ main.scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; }
93
+ .dock { flex: none; padding: 8px 12px calc(var(--safe-b) + 10px); background: linear-gradient(to bottom, transparent, var(--bg) 14px); position: relative; z-index: 2; }
94
+
95
+ /* ---- connection banner: says what is true, and how old what you see is ----------------------- */
96
+ .banner { margin: 2px 12px 8px; padding: 12px 14px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--line); display: flex; gap: 12px; align-items: flex-start; box-shadow: var(--shadow); }
97
+ .banner .ripple { flex: none; margin-top: 2px; }
98
+ .banner.push { margin-top: 18px; box-shadow: none; }
99
+ .banner.push > .ic { flex: none; margin-top: 2px; color: var(--accent); }
100
+ .banner .txt { flex: 1; min-width: 0; font-size: 14.5px; line-height: 1.45; color: var(--ink-2); }
101
+ .banner .txt b { display: block; font-size: 15.5px; color: var(--ink); margin-bottom: 2px; }
102
+ .banner .txt small { display: block; margin-top: 6px; color: var(--muted); font-size: 13px; }
103
+ .banner .act { margin-top: 10px; display: flex; gap: 8px; }
104
+ .banner.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
105
+ .banner.bad { border-color: color-mix(in srgb, var(--bad) 35%, transparent); }
106
+ .banner.slim { padding: 8px 12px; align-items: center; box-shadow: none; background: var(--warn-soft); border-color: transparent; font-size: 13.5px; color: var(--ink-2); gap: 8px; }
107
+
108
+ .btn { height: 40px; padding: 0 16px; border-radius: 12px; font-weight: 600; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--surface-2); color: var(--ink); }
109
+ .btn.primary { background: var(--accent-strong); color: var(--accent-ink); }
110
+ .btn.ghost { background: transparent; color: var(--accent); padding: 0 6px; }
111
+ .btn:active { filter: brightness(0.94); transform: scale(0.98); }
112
+
113
+ /* ---- home ------------------------------------------------------------------------------------ */
114
+ .lt { padding: 4px 20px 6px; font-size: 30px; font-weight: 750; letter-spacing: -0.6px; }
115
+ .sec { padding: 14px 20px 6px; font-size: 13px; font-weight: 650; color: var(--muted); letter-spacing: 0.1px; display: flex; align-items: center; gap: 6px; }
116
+ .sec .count { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 99px; background: var(--warn); color: #fff; font-size: 12px; display: inline-grid; place-items: center; font-weight: 700; }
117
+ .list { margin: 0 12px; background: var(--surface); border-radius: var(--r-lg); border: 1px solid var(--line); overflow: hidden; }
118
+ .row { display: flex; align-items: center; gap: 12px; padding: 12px 14px 12px 16px; min-height: 68px; width: 100%; text-align: left; position: relative; }
119
+ .row + .row::before { content: ""; position: absolute; left: 52px; right: 0; top: 0; border-top: 1px solid var(--line); }
120
+ .row:active { background: var(--surface-2); }
121
+ .row .ring { flex: none; }
122
+ .row .body { flex: 1; min-width: 0; }
123
+ .row .t1 { font-size: 16px; font-weight: 600; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
124
+ .row .t2 { margin-top: 3px; font-size: 13.5px; color: var(--muted); display: flex; gap: 6px; align-items: center; min-width: 0; white-space: nowrap; }
125
+ .row .t2 .sep { opacity: 0.5; }
126
+ .row .t2 .m { overflow: hidden; text-overflow: ellipsis; }
127
+ .row .tag { flex: none; font-size: 12.5px; font-weight: 650; padding: 3px 9px; border-radius: 99px; background: var(--warn-soft); color: var(--warn); }
128
+ .row .tag.run { background: var(--accent-soft); color: var(--accent); }
129
+ .list.waiting { border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
130
+ .list.stale .ring, .list.stale .tag { opacity: 0.5; filter: grayscale(0.6); }
131
+ .empty { padding: 40px 32px 20px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 6px; }
132
+ .empty b { font-size: 19px; margin-top: 10px; }
133
+ .empty p { margin: 0; color: var(--muted); font-size: 15px; max-width: 30ch; }
134
+ .empty .hint { margin-top: 18px; color: var(--accent); }
135
+
136
+ /* ---- chips above the composer: where and on what the next session runs ---------------------- */
137
+ .chips { display: flex; gap: 8px; margin: 0 2px 8px; }
138
+ .chip { height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 10px 0 11px; border-radius: 12px; background: var(--surface); border: 1px solid var(--line-2); font-size: 14.5px; font-weight: 560; min-width: 0; color: var(--ink); }
139
+ .chip .ic { color: var(--muted); }
140
+ .chip .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
141
+ .chip .eff { color: var(--accent); font-weight: 650; }
142
+ .chip.folder { flex: 0 1 auto; max-width: 46%; }
143
+ .chip.model { flex: 1 1 auto; }
144
+ .chip .down { margin-left: auto; color: var(--muted); }
145
+ .chip:active { background: var(--surface-2); }
146
+
147
+ .composer { display: flex; align-items: flex-end; gap: 8px; background: var(--surface); border: 1px solid var(--line-2); border-radius: 24px; padding: 6px 6px 6px 16px; box-shadow: var(--shadow); }
148
+ .composer textarea { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; resize: none; font-size: 16px; line-height: 1.45; padding: 8px 0; max-height: 38vh; min-height: 24px; }
149
+ .composer textarea::placeholder { color: var(--muted); }
150
+ .send { flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--accent-strong); color: var(--accent-ink); }
151
+ .send:disabled { background: var(--surface-2); color: var(--faint); }
152
+ .send.stop { background: var(--ink); color: var(--bg); }
153
+ .working { display: flex; align-items: center; gap: 8px; margin: 0 6px 8px; font-size: 13.5px; color: var(--muted); }
154
+ .working .ring { animation: spin 2.4s linear infinite; }
155
+ @keyframes spin { to { transform: rotate(360deg); } }
156
+ @media (prefers-reduced-motion: reduce) { .working .ring, .caret { animation: none !important; } }
157
+
158
+ /* ---- the mod band: four figures, one line, tap for workers ---------------------------------- */
159
+ .band { flex: none; margin: 0 12px 6px; display: grid; grid-template-columns: repeat(4, 1fr); background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); text-align: left; width: calc(100% - 24px); }
160
+ .band .cell { padding: 7px 10px 8px; min-width: 0; position: relative; }
161
+ .band .cell + .cell::before { content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; border-left: 1px solid var(--line); }
162
+ .band .k { font-size: 11.5px; color: var(--muted); font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
163
+ .band .v { font-size: 15.5px; font-weight: 680; line-height: 1.25; white-space: nowrap; font-variant-numeric: tabular-nums; }
164
+ .band .v.na { color: var(--faint); }
165
+ .band.stale { opacity: 0.55; }
166
+ .working.stale { color: var(--warn); }
167
+ .band .bar { height: 3px; border-radius: 3px; background: var(--sunk); margin-top: 4px; overflow: hidden; }
168
+ .band .bar i { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
169
+ .band .bar.warn i { background: var(--warn); }
170
+ .band .bar.bad i { background: var(--bad); }
171
+ .workers { margin: -2px 12px 8px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); font-size: 14px; }
172
+ .workers .w { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
173
+ .workers .w span:first-child { font-family: var(--mono); font-size: 13px; }
174
+ .workers .w span:last-child { color: var(--muted); }
175
+ .workers .none { color: var(--muted); }
176
+ .workers .foot { margin-top: 6px; font-size: 12px; color: var(--muted); }
177
+
178
+ /* ---- timeline -------------------------------------------------------------------------------- */
179
+ .conversation-image { display: block; width: 240px; max-width: 100%; margin: 6px 0; border-radius: 12px; overflow: hidden; background: var(--sunk); }
180
+ .conversation-image img { width: 100%; height: 180px; object-fit: contain; }
181
+ .conversation-image span, .image-unavailable { padding: 8px; font-size: 12.5px; color: var(--muted); }
182
+ .image-viewer { position: fixed; inset: 0; z-index: 30; background: #000; color: #fff; display: flex; flex-direction: column; padding: calc(var(--safe-t) + 12px) 12px calc(var(--safe-b) + 12px); }
183
+ .image-viewer button { align-self: flex-end; padding: 12px; }
184
+ .image-viewer img { min-height: 0; flex: 1; width: 100%; object-fit: contain; }
185
+
186
+ .tl { padding: 8px 16px 16px; display: flex; flex-direction: column; gap: 14px; }
187
+ /* Real item heights keep initial and streaming bottom anchors accurate. */
188
+ .tl > * { min-width: 0; }
189
+ .msg-user { align-self: flex-end; max-width: 86%; background: var(--user-bg); color: var(--ink); padding: 10px 14px; border-radius: 20px 20px 6px 20px; font-size: 16px; white-space: pre-wrap; word-break: break-word; }
190
+ .msg-user.queued { opacity: 0.72; }
191
+ .msg-user .q { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted); margin-top: 4px; justify-content: flex-end; }
192
+ .msg-asst { font-size: 16px; line-height: 1.6; word-break: break-word; color: var(--ink); }
193
+ .msg-asst p { margin: 0 0 10px; }
194
+ .msg-asst p:last-child { margin-bottom: 0; }
195
+ .msg-asst h2, .msg-asst h3, .msg-asst h4 { font-size: 16.5px; margin: 14px 0 6px; line-height: 1.35; }
196
+ .msg-asst ul, .msg-asst ol { margin: 0 0 10px; padding-left: 22px; }
197
+ .msg-asst li { margin: 2px 0; }
198
+ .msg-asst code { font-family: var(--mono); font-size: 0.86em; background: var(--surface-2); padding: 1px 5px; border-radius: 6px; }
199
+ .msg-asst a { color: var(--accent); }
200
+ .msg-asst blockquote { margin: 0 0 10px; padding-left: 12px; border-left: 3px solid var(--line-2); color: var(--ink-2); }
201
+ .md-code { margin: 0 0 10px; background: var(--sunk); border-radius: var(--r-sm); padding: 10px 12px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
202
+ .md-code code { background: none; padding: 0; font-size: inherit; }
203
+ .md-table { overflow-x: auto; margin: 0 0 10px; }
204
+ .md-table table { border-collapse: collapse; font-size: 14px; }
205
+ .md-table th, .md-table td { border-bottom: 1px solid var(--line); padding: 5px 10px 5px 0; text-align: left; white-space: nowrap; }
206
+ .caret { display: inline-block; width: 8px; height: 1.05em; vertical-align: -0.15em; background: var(--accent); border-radius: 2px; margin-left: 2px; animation: blink 1s steps(1) infinite; }
207
+ @keyframes blink { 50% { opacity: 0; } }
208
+ .ring-background { animation: pulse 1.4s ease-in-out infinite; }
209
+ @keyframes pulse { 50% { opacity: 0.25; } }
210
+ @media (prefers-reduced-motion: reduce) { .ring-background { animation: none; } }
211
+
212
+ .tools { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
213
+ .tool { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 6px 10px; border-radius: var(--r-sm); background: var(--surface); border: 1px solid var(--line); font-size: 13.5px; width: 100%; text-align: left; color: var(--ink-2); }
214
+ .tool .nm { font-weight: 650; color: var(--ink); flex: none; }
215
+ .tool .sm { font-family: var(--mono); font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
216
+ .tool .st { flex: none; color: var(--ok); }
217
+ .tool .st.bad { color: var(--bad); }
218
+ .tool .st.run { color: var(--faint); }
219
+ .tool-out { margin: 2px 0 4px; padding: 10px 12px; border-radius: var(--r-sm); background: var(--sunk); font-family: var(--mono); font-size: 12.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; color: var(--ink-2); }
220
+
221
+ .tool-details { font-size: 13px; color: var(--accent); }
222
+ .tool-details summary { padding: 4px 0; cursor: pointer; }
223
+ .tool-details[open] + .tool-preview { display: none; }
224
+ .tool-details .tool-out { max-height: none; }
225
+ .msg-asst blockquote > :last-child { margin-bottom: 0; }
226
+
227
+ .sub { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--line); width: 100%; text-align: left; }
228
+ .sub .body { flex: 1; min-width: 0; }
229
+ .sub .k { font-size: 12.5px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
230
+ .sub .k .model { font-family: var(--mono); color: var(--accent); font-weight: 600; }
231
+ .sub .d { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232
+ .sub .ic { color: var(--muted); }
233
+
234
+ .foot { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; margin-top: -6px; }
235
+ .foot.bad { color: var(--bad); }
236
+ .notice { align-self: center; font-size: 13px; color: var(--muted); background: var(--surface-2); padding: 5px 12px; border-radius: 99px; text-align: center; max-width: 92%; }
237
+ .start { align-self: center; font-size: 12.5px; color: var(--faint); padding: 4px 0; }
238
+ .loading-older { text-align: center; font-size: 13px; color: var(--muted); padding: 8px; }
239
+
240
+ .perm-done { display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--muted); padding: 2px 2px; }
241
+ .perm-done .nm { font-weight: 650; color: var(--ink-2); }
242
+ .perm-done .sm { font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
243
+ .perm-done.deny .ic, .perm-done.expired .ic { color: var(--bad); }
244
+ .perm-done.allow .ic { color: var(--ok); }
245
+ .perm-done.open, .perm-done.open .nm { color: var(--warn); }
246
+ .perm-done .why { display: block; font-size: 12.5px; }
247
+
248
+ /* ---- permission: one thumb, Allow on the right, never smaller than 52 px --------------------- */
249
+ .perm { background: var(--surface); border: 1.5px solid color-mix(in srgb, var(--warn) 55%, transparent); border-radius: var(--r-lg); padding: 14px 14px 12px; margin-bottom: 10px; box-shadow: var(--shadow); }
250
+ .perm .hd { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--warn); font-weight: 650; }
251
+ .perm .hd .of { margin-left: auto; color: var(--muted); font-weight: 600; }
252
+ .perm .q { font-size: 17px; font-weight: 680; margin: 6px 0 8px; line-height: 1.35; }
253
+ .perm .what { font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--sunk); border-radius: var(--r-sm); padding: 9px 11px; max-height: 7.6em; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink-2); position: relative; }
254
+ .perm .what.open { max-height: 40vh; overflow: auto; }
255
+ .perm .more { font-size: 13px; color: var(--accent); font-weight: 600; padding: 6px 0 0; }
256
+ .perm .act { display: grid; grid-template-columns: 1fr 1.35fr; gap: 10px; margin-top: 12px; }
257
+ .perm .act button { height: 54px; border-radius: 16px; font-size: 17px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
258
+ .perm .deny { background: var(--surface-2); color: var(--ink); }
259
+ .perm .allow { background: var(--accent-strong); color: var(--accent-ink); }
260
+ .perm .always { display: block; width: 100%; text-align: center; margin-top: 8px; height: 36px; font-size: 14px; font-weight: 600; color: var(--muted); }
261
+
262
+ /* ---- sheets ---------------------------------------------------------------------------------- */
263
+ .scrim { position: absolute; inset: 0; background: var(--scrim); z-index: 10; }
264
+ .sheet { position: absolute; left: 0; right: 0; bottom: 0; z-index: 11; background: var(--bg); border-radius: 22px 22px 0 0; box-shadow: var(--sheet-shadow); max-height: 90%; display: flex; flex-direction: column; }
265
+ .sheet .grab { width: 38px; height: 5px; border-radius: 3px; background: var(--faint); margin: 8px auto 2px; flex: none; }
266
+ .sheet .sh { display: flex; align-items: center; padding: 4px 8px 6px 20px; flex: none; }
267
+ .sheet .sh b { flex: 1; font-size: 18px; font-weight: 700; }
268
+ .sheet .sh .icon-btn { width: 44px; height: 44px; display: grid; place-items: center; color: var(--muted); }
269
+ .sheet .sbody { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 8px; }
270
+ .sheet .sfoot { flex: none; padding: 10px 12px calc(var(--safe-b) + 12px); border-top: 1px solid var(--line); background: var(--bg); }
271
+ .sheet .sfoot .btn { width: 100%; height: 52px; border-radius: 16px; font-size: 16.5px; }
272
+ .sheet .sfoot .path { font-size: 12.5px; color: var(--muted); margin: 0 4px 8px; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
273
+
274
+ .opt { display: flex; align-items: center; gap: 12px; padding: 10px 14px; min-height: 56px; width: 100%; text-align: left; position: relative; }
275
+ .opt + .opt::before { content: ""; position: absolute; left: 48px; right: 0; top: 0; border-top: 1px solid var(--line); }
276
+ .opt .ic { color: var(--muted); }
277
+ .opt .body { flex: 1; min-width: 0; }
278
+ .opt .t1 { font-size: 16px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
279
+ .opt .t2 { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
280
+ .opt .t2.mono { font-family: var(--mono); font-size: 12px; }
281
+ .opt.on .t1 { color: var(--accent); font-weight: 650; }
282
+ .opt .tick { color: var(--accent); }
283
+ .opt:disabled { opacity: 0.45; }
284
+ .opt:active:not(:disabled) { background: var(--surface-2); }
285
+ .opt .badge { font-size: 12px; color: var(--muted); background: var(--surface-2); border-radius: 99px; padding: 2px 8px; flex: none; }
286
+
287
+ .crumbs { display: flex; gap: 4px; align-items: center; padding: 0 16px 8px; overflow-x: auto; white-space: nowrap; font-size: 14px; scrollbar-width: none; }
288
+ .crumbs button { padding: 6px 8px; border-radius: 8px; color: var(--muted); font-weight: 550; }
289
+ .crumbs button:last-child { color: var(--ink); font-weight: 650; background: var(--surface-2); }
290
+ .crumbs .sl { color: var(--faint); }
291
+ .filter { margin: 2px 12px 10px; display: flex; align-items: center; gap: 8px; background: var(--surface-2); border-radius: 12px; padding: 0 12px; height: 42px; color: var(--muted); }
292
+ .filter input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 16px; color: var(--ink); min-width: 0; }
293
+
294
+ .seg { display: flex; background: var(--surface-2); border-radius: 13px; padding: 3px; gap: 2px; }
295
+ .seg button { flex: 1; height: 40px; border-radius: 10px; font-size: 14.5px; font-weight: 600; color: var(--ink-2); white-space: nowrap; }
296
+ .seg button.on { background: var(--surface); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); }
297
+ .seg-label { font-size: 13px; color: var(--muted); margin: 0 4px 6px; font-weight: 600; display: flex; justify-content: space-between; }
298
+
299
+ /* ---- pairing --------------------------------------------------------------------------------- */
300
+ .pair { padding: 40px 28px 24px; display: flex; flex-direction: column; align-items: center; text-align: center; }
301
+ .pair h1 { font-size: 26px; letter-spacing: -0.5px; margin: 18px 0 22px; }
302
+ .pair ol { list-style: none; padding: 0; margin: 0; width: 100%; text-align: left; display: flex; flex-direction: column; gap: 14px; counter-reset: s; }
303
+ .pair li { display: flex; gap: 12px; align-items: flex-start; font-size: 15.5px; counter-increment: s; }
304
+ .pair li::before { content: counter(s); flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 700; font-size: 14px; display: grid; place-items: center; }
305
+ .pair code { display: block; margin-top: 6px; font-family: var(--mono); font-size: 13px; white-space: nowrap; overflow-x: auto; background: var(--surface); border: 1px solid var(--line); padding: 8px 12px; border-radius: 10px; }
306
+ .pair .paste { margin-top: 24px; width: 100%; text-align: left; font-size: 14px; color: var(--muted); }
307
+ .pair .paste .row { display: flex; gap: 8px; margin-top: 8px; }
308
+ .pair .paste input { flex: 1; min-width: 0; height: 40px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); font-family: var(--mono); font-size: 16px; }
309
+ .pair .note { margin-top: 26px; font-size: 13.5px; color: var(--muted); }
310
+
311
+ .toast { position: absolute; left: 16px; right: 16px; bottom: calc(var(--safe-b) + 140px); z-index: 20; background: var(--ink); color: var(--bg); border-radius: 14px; padding: 12px 14px; font-size: 14.5px; box-shadow: var(--shadow); }
@@ -0,0 +1,48 @@
1
+ // Service worker: shows the host's pushes and opens the session on tap. A classic script (no
2
+ // imports), registered from app.ts. It caches nothing: the app's state lives on the host, and a
3
+ // stale offline copy of the shell would be one more thing that could lie.
4
+ //
5
+ // Every push shows a notification, even one that fails to parse: iOS revokes push for a site
6
+ // whose pushes show nothing.
7
+
8
+ interface RipplePush { title?: string; body?: string; session?: string; tag?: string }
9
+ interface SwWindow { url: string; focus(): Promise<unknown>; navigate(url: string): Promise<unknown> }
10
+ interface SwScope {
11
+ addEventListener(type: "install" | "activate", fn: (e: { waitUntil(p: Promise<unknown>): void }) => void): void;
12
+ addEventListener(type: "push", fn: (e: { data: { json(): unknown } | null; waitUntil(p: Promise<unknown>): void }) => void): void;
13
+ addEventListener(type: "notificationclick", fn: (e: { notification: { data: unknown; close(): void }; waitUntil(p: Promise<unknown>): void }) => void): void;
14
+ registration: { scope: string; showNotification(title: string, options: { body?: string; tag?: string; data?: unknown; icon?: string; badge?: string }): Promise<void> };
15
+ clients: { matchAll(o: { type: "window"; includeUncontrolled: boolean }): Promise<SwWindow[]>; openWindow(url: string): Promise<unknown>; claim(): Promise<void> };
16
+ skipWaiting(): Promise<void>;
17
+ }
18
+ const swScope = self as unknown as SwScope;
19
+
20
+ swScope.addEventListener("install", (e) => e.waitUntil(swScope.skipWaiting()));
21
+ swScope.addEventListener("activate", (e) => e.waitUntil(swScope.clients.claim()));
22
+
23
+ swScope.addEventListener("push", (e) => {
24
+ let p: RipplePush = {};
25
+ try { p = (e.data?.json() ?? {}) as RipplePush; } catch { /* shown generic below */ }
26
+ e.waitUntil(swScope.registration.showNotification(p.title || "Ripple", {
27
+ body: p.body ?? "",
28
+ ...(p.tag ? { tag: p.tag } : {}),
29
+ data: { session: p.session ?? null },
30
+ icon: new URL("icon.svg", swScope.registration.scope).href,
31
+ }));
32
+ });
33
+
34
+ swScope.addEventListener("notificationclick", (e) => {
35
+ e.notification.close();
36
+ const session = (e.notification.data as { session?: string | null } | null)?.session;
37
+ const target = new URL(session ? `./#/s/${encodeURIComponent(session)}` : "./", swScope.registration.scope).href;
38
+ e.waitUntil((async () => {
39
+ const wins = await swScope.clients.matchAll({ type: "window", includeUncontrolled: true });
40
+ const win = wins[0];
41
+ if (win) {
42
+ await win.focus();
43
+ try { await win.navigate(target); } catch { /* not controlled yet: focusing is the most we can do */ }
44
+ return;
45
+ }
46
+ await swScope.clients.openWindow(target);
47
+ })());
48
+ });