oharness 0.7.1 → 0.9.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 (41) hide show
  1. package/README.md +5 -3
  2. package/dist/bin/browser-host.d.ts +10 -0
  3. package/dist/bin/browser-host.js +11 -0
  4. package/dist/bin/browser-host.js.map +1 -0
  5. package/dist/browser-host/main.d.ts +2 -0
  6. package/dist/browser-host/main.js +113 -0
  7. package/dist/browser-host/main.js.map +1 -0
  8. package/dist/ui/commands.d.ts +25 -0
  9. package/dist/ui/commands.js +64 -0
  10. package/dist/ui/commands.js.map +1 -0
  11. package/dist/ui/main.js +2 -23
  12. package/dist/ui/main.js.map +1 -1
  13. package/dist/ui/render.d.ts +12 -0
  14. package/dist/ui/render.js +23 -3
  15. package/dist/ui/render.js.map +1 -1
  16. package/dist/ui/tui/app.d.ts +17 -0
  17. package/dist/ui/tui/app.js +83 -2
  18. package/dist/ui/tui/app.js.map +1 -1
  19. package/dist/ui/tui/layout.d.ts +9 -0
  20. package/dist/ui/tui/layout.js +29 -1
  21. package/dist/ui/tui/layout.js.map +1 -1
  22. package/extension/README.md +58 -0
  23. package/extension/_locales/de/messages.json +83 -0
  24. package/extension/_locales/en/messages.json +83 -0
  25. package/extension/_locales/fr/messages.json +83 -0
  26. package/extension/_locales/ja/messages.json +83 -0
  27. package/extension/_locales/ko/messages.json +83 -0
  28. package/extension/_locales/zh_CN/messages.json +83 -0
  29. package/extension/background.js +380 -0
  30. package/extension/icons/icon-128.png +0 -0
  31. package/extension/icons/icon-16.png +0 -0
  32. package/extension/icons/icon-32.png +0 -0
  33. package/extension/icons/icon-48.png +0 -0
  34. package/extension/manifest.json +39 -0
  35. package/extension/popup.css +142 -0
  36. package/extension/popup.html +43 -0
  37. package/extension/popup.js +101 -0
  38. package/extension/sidepanel.css +123 -0
  39. package/extension/sidepanel.html +30 -0
  40. package/extension/sidepanel.js +126 -0
  41. package/package.json +21 -14
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The relay.
3
+ *
4
+ * Everything clever about driving a browser lives in the harness; this end only
5
+ * forwards. It holds a native messaging port to the local host process, turns
6
+ * the harness's CDP messages into `chrome.debugger` calls, and sends every
7
+ * debugger event back. The control messages exist because CDP has no
8
+ * browser-level half here — `chrome.debugger` is scoped to one tab, so listing
9
+ * tabs and opening one go through `chrome.tabs`.
10
+ *
11
+ * Why an extension at all: Chrome has refused remote debugging on the default
12
+ * profile since 136, so this is the only sanctioned way to reach a browser
13
+ * somebody is already signed into. Chrome shows its own "being debugged" banner
14
+ * for as long as anything is attached, which is the disclosure this deserves —
15
+ * it is not something to suppress.
16
+ *
17
+ * The port also keeps the service worker alive: an MV3 worker is shut down when
18
+ * idle, but not while a port is open. When the port goes — the host crashed, was
19
+ * rebuilt, or was never registered — it is tried again on a backoff, and an
20
+ * alarm wakes a worker Chrome has since put to sleep so the retry still happens.
21
+ */
22
+
23
+ const HOST_NAME = "ai.oharness.browser";
24
+ const PROTOCOL_VERSION = "1.3";
25
+ const RECONNECT_ALARM = "reconnect";
26
+ /** Seconds between attempts: quick after a restart, patient when nothing is installed. */
27
+ const BACKOFF = [1, 2, 5, 10, 30];
28
+
29
+ let port = null;
30
+ /** Tabs this extension has attached, so detach is never guessed at. */
31
+ const attached = new Set();
32
+ /** What the popup shows. */
33
+ const status = { connected: false, error: null, clients: 0, servers: [] };
34
+ let attempt = 0;
35
+ let retryTimer = null;
36
+
37
+ function connect() {
38
+ if (port) return port;
39
+ clearTimeout(retryTimer);
40
+ retryTimer = null;
41
+ port = chrome.runtime.connectNative(HOST_NAME);
42
+ status.connected = true;
43
+ status.error = null;
44
+
45
+ port.onMessage.addListener((message) => {
46
+ // A message means the host really started; connectNative returning does not.
47
+ attempt = 0;
48
+ void handle(message);
49
+ });
50
+
51
+ port.onDisconnect.addListener(() => {
52
+ const error = chrome.runtime.lastError;
53
+ if (error) console.warn("OHarness host disconnected:", error.message);
54
+ port = null;
55
+ status.connected = false;
56
+ status.clients = 0;
57
+ status.servers = [];
58
+ tell({ type: "servers", connected: false, servers: [] });
59
+ status.error = error?.message ?? "The OHarness host stopped.";
60
+ // Let go of every tab: a debugger left attached shows the banner forever
61
+ // and blocks DevTools on that tab.
62
+ for (const tabId of [...attached]) {
63
+ chrome.debugger.detach({ tabId }).catch(() => {});
64
+ attached.delete(tabId);
65
+ }
66
+ updateBadge();
67
+ scheduleReconnect();
68
+ });
69
+
70
+ updateBadge();
71
+ return port;
72
+ }
73
+
74
+ function scheduleReconnect() {
75
+ if (retryTimer) return;
76
+ const seconds = BACKOFF[Math.min(attempt, BACKOFF.length - 1)];
77
+ attempt += 1;
78
+ retryTimer = setTimeout(() => {
79
+ retryTimer = null;
80
+ connect();
81
+ }, seconds * 1000);
82
+ }
83
+
84
+ /** The toolbar says how many tabs are being driven, or that nothing can be. */
85
+ function updateBadge() {
86
+ if (!status.connected) {
87
+ void chrome.action.setBadgeBackgroundColor({ color: "#b45309" });
88
+ void chrome.action.setBadgeTextColor?.({ color: "#ffffff" });
89
+ void chrome.action.setBadgeText({ text: "!" });
90
+ return;
91
+ }
92
+ // The brand lime, as the app uses it: the harness is alive and working.
93
+ void chrome.action.setBadgeBackgroundColor({ color: "#c6f432" });
94
+ void chrome.action.setBadgeTextColor?.({ color: "#0e1013" });
95
+ void chrome.action.setBadgeText({ text: attached.size > 0 ? String(attached.size) : "" });
96
+ }
97
+
98
+ function reply(message) {
99
+ try {
100
+ port?.postMessage(message);
101
+ } catch (error) {
102
+ console.warn("OHarness could not reply:", error);
103
+ }
104
+ }
105
+
106
+ async function handle(message) {
107
+ if (!message || typeof message !== "object") return;
108
+
109
+ if (message.t === "host") {
110
+ status.clients = Number(message.clients) || 0;
111
+ // Where each OHarness page is, for the side panel. Kept in memory only:
112
+ // the tokens in them are this run's and never written anywhere.
113
+ status.servers = Array.isArray(message.servers) ? message.servers : [];
114
+ tell({ type: "servers", connected: true, servers: status.servers });
115
+ return;
116
+ }
117
+
118
+ if (message.t === "cdp") {
119
+ // No id means the harness sent an event back at us, which it does not.
120
+ if (typeof message.id !== "number") return;
121
+ const tabId = Number(message.sessionId);
122
+ if (!Number.isInteger(tabId)) {
123
+ reply({ t: "cdp", id: message.id, error: { message: "no tab for this command" } });
124
+ return;
125
+ }
126
+ try {
127
+ const result = await chrome.debugger.sendCommand(
128
+ { tabId },
129
+ message.method,
130
+ message.params ?? {},
131
+ );
132
+ reply({ t: "cdp", id: message.id, result: result ?? {} });
133
+ } catch (error) {
134
+ reply({ t: "cdp", id: message.id, error: { message: String(error?.message ?? error) } });
135
+ }
136
+ return;
137
+ }
138
+
139
+ if (message.t !== "ctl") return;
140
+ const { id, op } = message;
141
+ try {
142
+ if (op === "tabs") {
143
+ const tabs = await chrome.tabs.query({});
144
+ reply({
145
+ t: "ctl",
146
+ id,
147
+ result: tabs
148
+ // A tab with no id cannot be driven, and chrome:// pages refuse the
149
+ // debugger, so offering them would only produce failures later.
150
+ .filter((tab) => typeof tab.id === "number" && !isPrivileged(tab.url) && !isOHarness(tab.url))
151
+ .map((tab) => ({ tabId: tab.id, url: tab.url, title: tab.title })),
152
+ });
153
+ return;
154
+ }
155
+ if (op === "info") {
156
+ reply({ t: "ctl", id, result: browserIdentity() });
157
+ return;
158
+ }
159
+ if (op === "newTab") {
160
+ const tab = await chrome.tabs.create({ url: message.url || "about:blank", active: false });
161
+ // A tab that cannot be grouped is still a tab; grouping is for the person's benefit.
162
+ await placeInGroup(tab.id, message.group).catch((error) => console.warn("OHarness could not group a tab:", error));
163
+ reply({ t: "ctl", id, result: { tabId: tab.id } });
164
+ return;
165
+ }
166
+ if (op === "attach") {
167
+ const tabId = Number(message.tabId);
168
+ if (!attached.has(tabId)) {
169
+ await chrome.debugger.attach({ tabId }, PROTOCOL_VERSION);
170
+ attached.add(tabId);
171
+ updateBadge();
172
+ }
173
+ reply({ t: "ctl", id, result: { ok: true } });
174
+ return;
175
+ }
176
+ if (op === "activate") {
177
+ const tabId = Number(message.tabId);
178
+ await chrome.tabs.update(tabId, { active: true });
179
+ reply({ t: "ctl", id, result: { ok: true } });
180
+ return;
181
+ }
182
+ if (op === "closeTab") {
183
+ const tabId = Number(message.tabId);
184
+ attached.delete(tabId);
185
+ updateBadge();
186
+ await chrome.tabs.remove(tabId);
187
+ reply({ t: "ctl", id, result: { ok: true } });
188
+ return;
189
+ }
190
+ if (op === "detach") {
191
+ const tabId = Number(message.tabId);
192
+ if (attached.has(tabId)) {
193
+ await chrome.debugger.detach({ tabId }).catch(() => {});
194
+ attached.delete(tabId);
195
+ updateBadge();
196
+ }
197
+ reply({ t: "ctl", id, result: { ok: true } });
198
+ return;
199
+ }
200
+ reply({ t: "ctl", id, error: `unknown control op "${op}"` });
201
+ } catch (error) {
202
+ reply({ t: "ctl", id, error: String(error?.message ?? error) });
203
+ }
204
+ }
205
+
206
+ /**
207
+ * One tab group per conversation, the way several people sharing a desk keep
208
+ * their papers in separate piles: the person can see which tabs belong to
209
+ * which piece of work, collapse one, or close it as a whole.
210
+ *
211
+ * Only tabs the agent opened are grouped. A tab the person already had open
212
+ * and the agent was pointed at stays exactly where they put it.
213
+ *
214
+ * Which group belongs to which conversation is kept in session storage, so a
215
+ * worker Chrome restarts in the middle of a conversation keeps using the same
216
+ * group rather than starting another.
217
+ *
218
+ * Every OHarness group is green: Chrome offers nine fixed group colours and no
219
+ * custom ones, and green is the one that reads as the brand's lime. The
220
+ * conversation's title, not a colour, is what tells two groups apart.
221
+ */
222
+ const GROUP_COLOR = "green";
223
+
224
+ function groupTitle(title) {
225
+ const text = String(title || "OHarness").replace(/\s+/g, " ").trim();
226
+ return text.length > 28 ? `${text.slice(0, 27)}…` : text;
227
+ }
228
+
229
+ async function placeInGroup(tabId, group) {
230
+ if (!group || typeof group.key !== "string" || !chrome.tabGroups) return;
231
+ const storageKey = `group:${group.key}`;
232
+ const stored = (await chrome.storage.session.get(storageKey))[storageKey];
233
+ const existing = typeof stored === "number" ? await chrome.tabGroups.get(stored).catch(() => null) : null;
234
+ const title = groupTitle(group.title);
235
+ if (existing) {
236
+ await chrome.tabs.group({ groupId: existing.id, tabIds: [tabId] });
237
+ // A conversation is named by its first message, which may come after its
238
+ // first tab; the group catches up the next time it gets a tab.
239
+ const update = {};
240
+ if (group.title && existing.title !== title) update.title = title;
241
+ // Groups made before every group was green catch up too.
242
+ if (existing.color !== GROUP_COLOR) update.color = GROUP_COLOR;
243
+ if (Object.keys(update).length) await chrome.tabGroups.update(existing.id, update);
244
+ return;
245
+ }
246
+ const groupId = await chrome.tabs.group({ tabIds: [tabId] });
247
+ await chrome.tabGroups.update(groupId, { title, color: GROUP_COLOR, collapsed: false });
248
+ // Both ways round: the conversation's group, and — for the side panel,
249
+ // following the person between groups — the group's conversation.
250
+ await chrome.storage.session.set({
251
+ [storageKey]: groupId,
252
+ [`conversation:${groupId}`]: { id: group.key, ...(typeof group.cwd === "string" ? { cwd: group.cwd } : {}) },
253
+ });
254
+ }
255
+
256
+ /** The conversation a tab's group belongs to, if it is one of ours. */
257
+ async function conversationOf(tab) {
258
+ if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) return null;
259
+ const key = `conversation:${tab.groupId}`;
260
+ return (await chrome.storage.session.get(key))[key] ?? null;
261
+ }
262
+
263
+ /** Say something to the side panel, if it is open; nobody listening is fine. */
264
+ function tell(message) {
265
+ chrome.runtime.sendMessage(message).catch(() => {});
266
+ }
267
+
268
+ /**
269
+ * The side panel follows the person: moving to a tab in a conversation's
270
+ * group shows that conversation. Any other tab leaves it where it is.
271
+ */
272
+ async function followActiveTab(tabId) {
273
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
274
+ const conversation = await conversationOf(tab);
275
+ if (conversation) tell({ type: "focus-session", ...conversation });
276
+ }
277
+
278
+ chrome.tabs.onActivated.addListener(({ tabId }) => void followActiveTab(tabId));
279
+ chrome.tabs.onUpdated.addListener((tabId, change, tab) => {
280
+ if ("groupId" in change && tab.active) void followActiveTab(tabId);
281
+ });
282
+
283
+ /**
284
+ * OHarness's own pages — the page a web server serves, open in a tab — are
285
+ * never offered to an agent: driving the thing that drives it is a loop, and
286
+ * the page holds a token that runs commands on this machine.
287
+ */
288
+ function isOHarness(url) {
289
+ return typeof url === "string" && status.servers.some((server) => url.startsWith(server.url));
290
+ }
291
+
292
+ /**
293
+ * Which Chromium this is, so the harness can name the browser a sign-in is
294
+ * imported from. Brands are the honest source; the user-agent string claims to
295
+ * be Chrome in every one of them.
296
+ */
297
+ function browserIdentity() {
298
+ const brands = navigator.userAgentData?.brands ?? [];
299
+ const named = brands.find((entry) => !/not.?a.?brand|^chromium$/i.test(entry.brand));
300
+ const brand = named ?? brands.find((entry) => /^chromium$/i.test(entry.brand));
301
+ return { name: brand?.brand ?? "Chrome", version: brand?.version };
302
+ }
303
+
304
+ /** Pages Chrome will not let an extension debug. */
305
+ function isPrivileged(url) {
306
+ return typeof url === "string" && /^(chrome|edge|about|devtools|chrome-extension):/i.test(url);
307
+ }
308
+
309
+ // Debugger events go back as ordinary CDP messages, tagged with the tab they
310
+ // came from — which is exactly the session id the harness routes on.
311
+ chrome.debugger.onEvent.addListener((source, method, params) => {
312
+ if (typeof source.tabId !== "number") return;
313
+ reply({ t: "cdp", method, params: params ?? {}, sessionId: String(source.tabId) });
314
+ });
315
+
316
+ // Chrome detached on its own: the person pressed Cancel on the banner, closed
317
+ // the tab, or opened DevTools over it. Every harness using the tab is told, in
318
+ // the same words CDP would use, so it attaches again rather than talking to a
319
+ // tab that no longer answers.
320
+ chrome.debugger.onDetach.addListener((source, reason) => {
321
+ if (typeof source.tabId !== "number" || !attached.delete(source.tabId)) return;
322
+ updateBadge();
323
+ reply({ t: "cdp", method: "Inspector.detached", params: { reason }, sessionId: String(source.tabId) });
324
+ });
325
+
326
+ /** Let go of every tab now. The harness attaches again if it still needs one. */
327
+ async function release() {
328
+ for (const tabId of [...attached]) {
329
+ attached.delete(tabId);
330
+ await chrome.debugger.detach({ tabId }).catch(() => {});
331
+ reply({ t: "cdp", method: "Inspector.detached", params: { reason: "released_by_user" }, sessionId: String(tabId) });
332
+ }
333
+ updateBadge();
334
+ }
335
+
336
+ async function describe() {
337
+ const tabs = [];
338
+ for (const tabId of attached) {
339
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
340
+ if (!tab) continue;
341
+ const group = tab.groupId >= 0 && chrome.tabGroups ? await chrome.tabGroups.get(tab.groupId).catch(() => null) : null;
342
+ tabs.push({ tabId, title: tab.title ?? "", url: tab.url ?? "", group: group?.title || null, color: group?.color || null });
343
+ }
344
+ return { ...status, tabs };
345
+ }
346
+
347
+ // The popup asks; this answers. Nothing outside the extension can send these.
348
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
349
+ if (sender.id !== chrome.runtime.id) return false;
350
+ if (message?.type === "servers") {
351
+ respond({ connected: status.connected, servers: status.servers });
352
+ return false;
353
+ }
354
+ if (message?.type === "status") {
355
+ void describe().then(respond);
356
+ return true;
357
+ }
358
+ if (message?.type === "release") {
359
+ void release().then(describe).then(respond);
360
+ return true;
361
+ }
362
+ if (message?.type === "reconnect") {
363
+ attempt = 0;
364
+ connect();
365
+ // Give a missing host the moment it takes to fail, so the popup shows why.
366
+ setTimeout(() => void describe().then(respond), 300);
367
+ return true;
368
+ }
369
+ return false;
370
+ });
371
+
372
+ chrome.alarms.onAlarm.addListener((alarm) => {
373
+ if (alarm.name === RECONNECT_ALARM && !port) connect();
374
+ });
375
+ chrome.alarms.create(RECONNECT_ALARM, { periodInMinutes: 1 });
376
+
377
+ // Connect as soon as the worker starts, and again after Chrome restarts it.
378
+ connect();
379
+ chrome.runtime.onStartup.addListener(() => connect());
380
+ chrome.runtime.onInstalled.addListener(() => connect());
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,39 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "OHarness Browser Bridge",
4
+ "version": "0.5.0",
5
+ "description": "__MSG_extDescription__",
6
+ "default_locale": "en",
7
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7PX0T9J5HEXEmUKxtaNiqOTOXuwzCEq7EhsBuC4khICmOUKCN5Df+9WTMl/8jnGHeHtUPJRX/APQfrUfJqHCgENMhTv1IlAiQBHlAdsIXad4mjZ3VRz7KpFdajMNvZsGBeSjDU6hiOGngWxNiTztKyrBKMyS0yrmcUaGPl9Ji9QWgdV5VzdUz2xgFX57kFxYFHJmZ2K9HmqQ6n1ZFpTlGuziiFDEY94i2bIq0DOAozSsyUjBXxY2mJhAEcaWs2sG1Q+J6sNiQEdrYn3tNO9MZeWaXaTH0VfQeeXuBd98gHj7X4vCMd6MPFiPhDM0rDqBzvgZK/BvnOJTfAiC8sp/uQIDAQAB",
8
+ "permissions": [
9
+ "debugger",
10
+ "tabs",
11
+ "tabGroups",
12
+ "nativeMessaging",
13
+ "storage",
14
+ "alarms",
15
+ "sidePanel"
16
+ ],
17
+ "background": {
18
+ "service_worker": "background.js"
19
+ },
20
+ "action": {
21
+ "default_title": "OHarness Browser Bridge",
22
+ "default_popup": "popup.html",
23
+ "default_icon": {
24
+ "16": "icons/icon-16.png",
25
+ "32": "icons/icon-32.png",
26
+ "48": "icons/icon-48.png",
27
+ "128": "icons/icon-128.png"
28
+ }
29
+ },
30
+ "icons": {
31
+ "16": "icons/icon-16.png",
32
+ "32": "icons/icon-32.png",
33
+ "48": "icons/icon-48.png",
34
+ "128": "icons/icon-128.png"
35
+ },
36
+ "side_panel": {
37
+ "default_path": "sidepanel.html"
38
+ }
39
+ }
@@ -0,0 +1,142 @@
1
+ :root {
2
+ /* The app's own palette (packages/server/src/theme.ts), so the popup reads as
3
+ part of OHarness: ink for the button that does something, the brand lime
4
+ only for the harness being alive — here, driving tabs. */
5
+ --bg: #ffffff;
6
+ --fill: #f7f7f8;
7
+ --fill-strong: #00000012;
8
+ --text: #1a1a1a;
9
+ --muted: #6b7280;
10
+ --accent: #111827;
11
+ --accent-text: #ffffff;
12
+ --brand: #5e8c00;
13
+ --ok: #15803d;
14
+ --warn: #b45309;
15
+ --idle: #9ca3af;
16
+ color-scheme: light dark;
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: #0e1013;
22
+ --fill: #15171c;
23
+ --fill-strong: #ffffff1a;
24
+ --text: #e6e6e6;
25
+ --muted: #8b93a1;
26
+ --accent: #e5e7eb;
27
+ --accent-text: #0e1013;
28
+ --brand: #c6f432;
29
+ --ok: #4ade80;
30
+ --warn: #fbbf24;
31
+ --idle: #6b7280;
32
+ }
33
+ }
34
+
35
+ * { box-sizing: border-box; }
36
+
37
+ body {
38
+ width: 320px;
39
+ margin: 0;
40
+ padding: 14px;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
44
+ }
45
+
46
+ header {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+ margin-bottom: 12px;
51
+ }
52
+
53
+ h1 { margin: 0; font-size: 14px; font-weight: 600; }
54
+ h2 { margin: 14px 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); }
55
+ p { margin: 0; }
56
+
57
+ .status {
58
+ display: flex;
59
+ gap: 10px;
60
+ align-items: flex-start;
61
+ padding: 10px 12px;
62
+ border-radius: 10px;
63
+ background: var(--fill);
64
+ }
65
+
66
+ .dot {
67
+ flex: none;
68
+ width: 8px;
69
+ height: 8px;
70
+ margin-top: 6px;
71
+ border-radius: 50%;
72
+ background: var(--idle);
73
+ }
74
+ .status[data-state="driving"] .dot { background: var(--brand); }
75
+ .status[data-state="ready"] .dot { background: var(--ok); }
76
+ .status[data-state="waiting"] .dot { background: var(--idle); }
77
+ .status[data-state="missing"] .dot { background: var(--warn); }
78
+
79
+ .headline { font-weight: 600; }
80
+ .detail { color: var(--muted); }
81
+
82
+ .setup { margin-top: 12px; }
83
+ .setup ol { margin: 6px 0 8px; padding-left: 18px; }
84
+ .setup li { margin-bottom: 4px; }
85
+ .error { color: var(--muted); font-size: 12px; word-break: break-word; margin-bottom: 8px; }
86
+
87
+ code {
88
+ display: inline-block;
89
+ white-space: nowrap;
90
+ padding: 1px 5px;
91
+ border-radius: 5px;
92
+ background: var(--fill-strong);
93
+ font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
94
+ }
95
+
96
+ ul { list-style: none; margin: 0; padding: 0; }
97
+
98
+ .group {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 6px;
102
+ margin: 10px 0 4px;
103
+ font-size: 12px;
104
+ font-weight: 600;
105
+ }
106
+ .group:first-child { margin-top: 0; }
107
+ .swatch { flex: none; width: 8px; height: 8px; border-radius: 3px; }
108
+
109
+ .tab {
110
+ display: block;
111
+ width: 100%;
112
+ margin-bottom: 4px;
113
+ padding: 8px 10px;
114
+ border: 0;
115
+ border-radius: 8px;
116
+ background: var(--fill);
117
+ color: inherit;
118
+ text-align: left;
119
+ cursor: pointer;
120
+ font: inherit;
121
+ }
122
+ .tab:hover { background: var(--fill-strong); }
123
+ .tab span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
124
+ .tab .url { color: var(--muted); font-size: 11px; }
125
+
126
+ button.primary,
127
+ button.secondary {
128
+ width: 100%;
129
+ margin-top: 6px;
130
+ padding: 8px 10px;
131
+ border: 0;
132
+ border-radius: 8px;
133
+ font: inherit;
134
+ font-weight: 600;
135
+ cursor: pointer;
136
+ }
137
+ button.primary { background: var(--accent); color: var(--accent-text); }
138
+ button.secondary { background: var(--fill-strong); color: var(--text); }
139
+ button:focus-visible { outline: none; filter: brightness(1.08); }
140
+ .tab:focus-visible { outline: none; background: var(--fill-strong); }
141
+
142
+ .note { margin-top: 12px; color: var(--muted); font-size: 11px; }
@@ -0,0 +1,43 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <link rel="stylesheet" href="popup.css">
6
+ </head>
7
+ <body>
8
+ <header>
9
+ <img src="icons/icon-32.png" width="24" height="24" alt="">
10
+ <h1 data-i18n="popupTitle"></h1>
11
+ </header>
12
+
13
+ <section class="status" id="status">
14
+ <span class="dot"></span>
15
+ <div>
16
+ <p class="headline" id="headline"></p>
17
+ <p class="detail" id="detail"></p>
18
+ </div>
19
+ </section>
20
+
21
+ <section id="setup" class="setup" hidden>
22
+ <p data-i18n="setupIntro"></p>
23
+ <ol>
24
+ <li data-i18n="setupDesktop"></li>
25
+ <li><span data-i18n="setupCli"></span> <code>oharness-browser-host install</code></li>
26
+ </ol>
27
+ <p class="error" id="error"></p>
28
+ <button id="retry" class="secondary" data-i18n="retry"></button>
29
+ </section>
30
+
31
+ <section id="driving" hidden>
32
+ <h2 data-i18n="controlledTabs"></h2>
33
+ <ul id="tabs"></ul>
34
+ <button id="release" class="primary" data-i18n="release"></button>
35
+ </section>
36
+
37
+ <button id="panel" class="secondary" type="button" data-i18n="openPanel"></button>
38
+
39
+ <p class="note" data-i18n="bannerNote"></p>
40
+
41
+ <script src="popup.js"></script>
42
+ </body>
43
+ </html>