@nanocollective/roster 0.1.0-alpha.54 → 0.1.0-alpha.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/portal.md CHANGED
@@ -18,6 +18,19 @@ Keep the repos checked out beside each other, in the same shape the runner uses.
18
18
 
19
19
  ## The sidebar
20
20
 
21
+ **Search everything** with ⌘K (Ctrl+K off a Mac), from anywhere, or the Search field at the
22
+ top of the sidebar, which opens the same dialog. It searches as you type, across:
23
+
24
+ - issues and pull requests, open and recently closed, which open in the side sheet
25
+ - staff, every fact in every memory, and every note and file in every brain
26
+ - the org files, the docs (every page, not only titles), and runs from the last 30 days, which
27
+ open their log on GitHub
28
+ - the screens themselves, so it doubles as a way to get anywhere
29
+
30
+ Every word has to match; a title that starts with one ranks first. Arrow keys move, Enter opens,
31
+ Escape closes. Everything except the docs is searched in the page, from what the portal has
32
+ already read.
33
+
21
34
  Anything that asks before it acts (a merge, a push, a hire, a retire, a paid run) asks in the
22
35
  page's own dialog, never the browser's `confirm()`, which blocks the whole tab.
23
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.54",
3
+ "version": "0.1.0-alpha.55",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,48 @@
1
+ /* Search everything: the ⌘K dialog and the sidebar field that opens it. */
2
+
3
+ .sidesearch{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;
4
+ margin:0 0 10px;padding:6px 10px;border-radius:8px;border:1px solid var(--line);
5
+ background:var(--bg-elev);color:var(--ink-faint);font:13px var(--sans);cursor:pointer;text-align:left}
6
+ .sidesearch:hover{border-color:var(--border-strong);color:var(--ink-dim)}
7
+ .sidesearch:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
8
+ .sidesearch .ic{--ic:14px;flex:none}
9
+ .sidesearch .t{flex:1}
10
+ .sidesearch kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:5px;
11
+ padding:0 5px;color:var(--ink-faint)}
12
+
13
+ dialog.palette{position:fixed;margin:12vh auto auto;padding:0;width:min(640px,calc(100vw - 32px));
14
+ max-height:min(560px,76vh);display:flex;flex-direction:column;overflow:hidden;
15
+ border:1px solid var(--border-strong);border-radius:var(--r-lg);background:var(--bg-elev);
16
+ color:var(--ink);box-shadow:var(--shadow-lg)}
17
+ dialog.palette:not([open]){display:none}
18
+ dialog.palette[open]{animation:dlgin .16s var(--ease)}
19
+ dialog.palette::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
20
+ @media (prefers-reduced-motion:reduce){dialog.palette[open]{animation:none}}
21
+
22
+ .palhead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
23
+ .palhead .ic{--ic:18px;color:var(--ink-faint);flex:none}
24
+ /* Scoped under the dialog to outrank the portal's own input[type=search] rules, which draw a
25
+ box and a focus ring that belong in a form, not in a search bar that is the whole header. */
26
+ dialog.palette .palinput,dialog.palette .palinput:hover,dialog.palette .palinput:focus{
27
+ flex:1;width:auto;border:0;outline:none;box-shadow:none;background:none;padding:0;
28
+ color:var(--ink);font:16px/1.4 var(--sans)}
29
+ .palinput::-webkit-search-cancel-button{display:none}
30
+
31
+ .palresults{overflow-y:auto;padding:6px;flex:1}
32
+ .palgroup{font:600 10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;
33
+ color:var(--ink-faint);padding:10px 10px 4px}
34
+ .palrow{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;width:100%;
35
+ padding:8px 10px;border-radius:8px;cursor:pointer}
36
+ .palrow[aria-selected="true"]{background:var(--sel)}
37
+ .palrow .ic{--ic:15px;color:var(--ink-dim);flex:none}
38
+ .paltext{display:flex;flex-direction:column;min-width:0}
39
+ .paltitle{font:500 13.5px/1.35 var(--sans);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
40
+ .paltitle mark{background:var(--accent-soft);color:inherit;border-radius:3px;padding:0 1px}
41
+ .palsub{font:12px/1.4 var(--sans);color:var(--ink-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
42
+ .palempty{color:var(--ink-faint);font-size:13px;padding:22px 12px;margin:0;text-align:center}
43
+
44
+ .palfoot{display:flex;gap:16px;padding:8px 14px;border-top:1px solid var(--line);
45
+ font:11.5px var(--sans);color:var(--ink-faint)}
46
+ .palfoot kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:4px;
47
+ padding:0 4px;margin-right:3px}
48
+ @media (max-width:640px){.palfoot{display:none}dialog.palette{margin-top:8vh}}
@@ -14,6 +14,7 @@
14
14
  <link rel="stylesheet" href="/assets/css/brain.css">
15
15
  <link rel="stylesheet" href="/assets/css/inbox.css">
16
16
  <link rel="stylesheet" href="/assets/css/home.css">
17
+ <link rel="stylesheet" href="/assets/css/search.css">
17
18
  <link rel="stylesheet" href="/assets/css/graph.css">
18
19
  <link rel="stylesheet" href="/assets/css/diff.css">
19
20
  <link rel="stylesheet" href="/assets/css/health.css">
@@ -28,6 +29,10 @@
28
29
  <span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
29
30
  </button>
30
31
 
32
+ <button class="sidesearch" id="sidesearch" title="Search everything (⌘K)">
33
+ <span class="ic" data-icon="search"></span><span class="t">Search</span><kbd id="searchkey">⌘K</kbd>
34
+ </button>
35
+
31
36
  <!-- Only while something is left to set up; app.js decides. -->
32
37
  <button class="nav top" id="setupnav" data-view="setup" hidden>
33
38
  <span class="ic" data-icon="task-done"></span> Getting started
@@ -20,6 +20,7 @@ import { viewChanged } from "./views/changed.js";
20
20
  import { viewDocs } from "./views/docs.js";
21
21
  import { viewGraph } from "./views/graph.js";
22
22
  import { viewHealth } from "./views/health.js";
23
+ import { openSearch } from "./search.js";
23
24
  import { viewHome } from "./views/home.js";
24
25
  import { viewInbox, viewPrs } from "./views/inbox.js";
25
26
  import { viewOrg } from "./views/org.js";
@@ -102,7 +103,7 @@ export async function boot() {
102
103
  /* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
103
104
  row of four dead ends beside an empty Staff heading. There is one thing to do on this
104
105
  screen; the navigation comes back with the org. */
105
- for (const el of document.querySelectorAll("#homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
106
+ for (const el of document.querySelectorAll("#sidesearch, #homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
106
107
  el.hidden = true;
107
108
  }
108
109
  for (const slot of document.querySelectorAll("[data-icon]")) {
@@ -144,6 +145,19 @@ export async function boot() {
144
145
  addEventListener("focus", () => {
145
146
  if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
146
147
  });
148
+ /* ⌘K (Ctrl+K off a Mac) opens search from anywhere, even from inside a text box: it is the
149
+ one shortcut that should never be swallowed by whatever has focus. The sidebar's field
150
+ opens the same dialog. */
151
+ addEventListener("keydown", (e) => {
152
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
153
+ e.preventDefault();
154
+ openSearch();
155
+ }
156
+ });
157
+ const side = $("#sidesearch");
158
+ if (side) side.onclick = () => openSearch();
159
+ const key = $("#searchkey");
160
+ if (key && !/Mac|iPhone|iPad/.test(navigator.platform ?? "")) key.textContent = "Ctrl K";
147
161
  addEventListener("keydown", (e) => {
148
162
  if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
149
163
  !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName ?? "")) {
@@ -13,6 +13,10 @@
13
13
  */
14
14
 
15
15
  const PATHS = {
16
+ "search":
17
+ "<circle cx=\"11\" cy=\"11\" r=\"8\" /> <path d=\"m21 21-4.3-4.3\" />",
18
+ "lightbulb":
19
+ "<path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\" /> <path d=\"M9 18h6\" /> <path d=\"M10 22h4\" />",
16
20
  "code":
17
21
  "<polyline points=\"16 18 22 12 16 6\" /> <polyline points=\"8 6 2 12 8 18\" />",
18
22
  "megaphone":
@@ -0,0 +1,366 @@
1
+ /* Search everything: ⌘K, or the search field in the sidebar.
2
+ *
3
+ * One dialog over the whole portal: issues and pull requests, staff, every fact and file in
4
+ * every brain, the org files, the docs, runs, and the screens themselves. All of it except the
5
+ * docs is already in the page, so it is searched here as you type; the docs are searched on the
6
+ * server, which reads every page rather than only titles.
7
+ *
8
+ * `index()` and `rank()` are plain functions over plain data, so what is found, and in which
9
+ * order, can be tested without a page. */
10
+
11
+ import { getOrgLayer, getRuns, searchDocs } from "./api.js";
12
+ import { ago, el } from "./dom.js";
13
+ import { icon, staffIcon } from "./icons.js";
14
+ import { ensureInbox } from "./refresh.js";
15
+ import { go } from "./router.js";
16
+ import { S } from "./state.js";
17
+ import { openThread } from "./views/home.js";
18
+
19
+ /** The groups, in the order they are shown. */
20
+ const GROUPS = ["Screens", "Staff", "Issues", "Pull requests", "Memory", "Files", "Org", "Runs", "Docs"];
21
+ /** How many of each a query shows; an empty query shows only screens and staff. */
22
+ const PER_GROUP = 5;
23
+
24
+ const SCREENS = [
25
+ ["home", "Home", "What needs you, and who is working"],
26
+ ["inbox", "Trackers", "Every open issue on each tracker"],
27
+ ["runs", "Runs", "What ran, and what it cost"],
28
+ ["org", "Org", "The files every staff member reads"],
29
+ ["staff", "Staff", "Hire, set up, retire"],
30
+ ["docs", "Docs", "How roster works"],
31
+ ];
32
+ const STAFF_SCREENS = [
33
+ ["brain", "Brain"],
34
+ ["prompt", "Prompt"],
35
+ ["graph", "Graph"],
36
+ ["changed", "What changed"],
37
+ ["health", "Health"],
38
+ ];
39
+
40
+ /**
41
+ * Every searchable thing, as `{ group, title, sub, text, icon, open }`. `text` is what is
42
+ * matched beyond the title; `open` is what choosing it does.
43
+ */
44
+ export function index(state) {
45
+ const out = [];
46
+ const staff = state.data?.staff ?? [];
47
+
48
+ for (const [view, title, sub] of SCREENS) {
49
+ out.push({ group: "Screens", title, sub, text: view, icon: "square", open: () => go({ view }) });
50
+ }
51
+ for (const s of staff) {
52
+ for (const [view, label] of STAFF_SCREENS) {
53
+ out.push({
54
+ group: "Screens",
55
+ title: `${s.name} · ${label}`,
56
+ sub: s.handle,
57
+ text: `${s.handle} ${label}`,
58
+ icon: staffIcon(s),
59
+ open: () => go({ view, staffHandle: s.handle }),
60
+ });
61
+ }
62
+ }
63
+
64
+ for (const s of staff) {
65
+ out.push({
66
+ group: "Staff",
67
+ title: s.name,
68
+ sub: [s.handle, s.brain].filter(Boolean).join(" · "),
69
+ text: `${s.handle} ${s.brain ?? ""} ${s.mention ?? ""}`,
70
+ icon: staffIcon(s),
71
+ open: () => go({ view: "brain", staffHandle: s.handle, openFile: "mem:*", fileQuery: "" }),
72
+ });
73
+
74
+ for (const f of s.facts ?? []) {
75
+ out.push({
76
+ group: "Memory",
77
+ title: f.statement,
78
+ sub: `${s.name} · ${f.section} · ${f.slug}`,
79
+ text: `${f.slug} ${f.consequence ?? ""} ${f.section}`,
80
+ icon: "lightbulb",
81
+ open: () =>
82
+ go({ view: "brain", staffHandle: s.handle, openFile: "fact:" + f.slug, fileQuery: "" }),
83
+ });
84
+ }
85
+
86
+ for (const surface of s.surfaces ?? []) {
87
+ for (const file of surface.files ?? []) {
88
+ const note = /(^|\/)notes\//.test(file.path);
89
+ out.push({
90
+ group: note ? "Memory" : "Files",
91
+ title: file.path.split("/").pop(),
92
+ sub: `${s.name} · ${file.path}`,
93
+ text: file.path,
94
+ icon: "file",
95
+ open: () => go({ view: "brain", staffHandle: s.handle, openFile: file.path, fileQuery: "" }),
96
+ });
97
+ }
98
+ }
99
+ }
100
+
101
+ const brains = new Map(staff.filter((s) => s.brain).map((s) => [s.brain, s]));
102
+ for (const item of state.inbox?.items ?? []) {
103
+ const owner = brains.get(item.repo);
104
+ const repo = item.repo.split("/")[1] ?? item.repo;
105
+ const where = owner ? owner.name : repo;
106
+ const open = item.state === "OPEN";
107
+ out.push({
108
+ group: item.kind === "pr" ? "Pull requests" : "Issues",
109
+ title: item.title,
110
+ sub: `${where} · #${item.number} · ${open ? "open" : item.state.toLowerCase()} · ${ago(item.updatedAt)}`,
111
+ text: `#${item.number} ${repo}#${item.number} ${(item.labels ?? []).join(" ")} ${item.author}`,
112
+ icon: item.kind === "pr" ? (item.state === "MERGED" ? "merged" : "review") : open ? "issue-open" : "issue-closed",
113
+ // Open work first among equal matches: it is usually what is being looked for.
114
+ weight: open ? 1 : 0,
115
+ open: () => openThread(item),
116
+ });
117
+ }
118
+
119
+ for (const f of state.orgFiles ?? []) {
120
+ out.push({
121
+ group: "Org",
122
+ title: f.path.split("/").pop(),
123
+ sub: f.path,
124
+ text: f.path,
125
+ icon: "file",
126
+ open: () => go({ view: "org", orgOpen: f.path }),
127
+ });
128
+ }
129
+
130
+ for (const s of state.runs?.staff ?? []) {
131
+ for (const r of s.runs ?? []) {
132
+ const word = r.record?.outcome ?? r.conclusion ?? r.status;
133
+ out.push({
134
+ group: "Runs",
135
+ title: `${s.name} · ${r.kind} run · ${word}`,
136
+ sub: `${ago(r.createdAt)} · opens the log on GitHub`,
137
+ text: `${s.handle} ${r.kind} ${word} ${r.id}`,
138
+ icon: "play",
139
+ open: () => window.open(r.url, "_blank", "noopener"),
140
+ });
141
+ }
142
+ }
143
+
144
+ return out;
145
+ }
146
+
147
+ /**
148
+ * The best matches for a query, grouped. Every word has to appear somewhere; a match in the
149
+ * title outranks one elsewhere, and the start of the title outranks the middle of it.
150
+ */
151
+ export function rank(entries, query) {
152
+ const words = String(query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
153
+ const groups = new Map(GROUPS.map((g) => [g, []]));
154
+ if (!words.length) {
155
+ for (const e of entries) {
156
+ if (e.group === "Screens" || e.group === "Staff") groups.get(e.group).push({ e, score: 0 });
157
+ }
158
+ } else {
159
+ for (const e of entries) {
160
+ const title = e.title.toLowerCase();
161
+ const all = `${title} ${String(e.sub ?? "").toLowerCase()} ${String(e.text ?? "").toLowerCase()}`;
162
+ if (!words.every((w) => all.includes(w))) continue;
163
+ let score = e.weight ?? 0;
164
+ for (const w of words) {
165
+ if (title.startsWith(w)) score += 6;
166
+ else if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(title)) score += 4;
167
+ else if (title.includes(w)) score += 2;
168
+ }
169
+ groups.get(e.group)?.push({ e, score });
170
+ }
171
+ }
172
+ const out = [];
173
+ for (const [group, list] of groups) {
174
+ if (!list.length) continue;
175
+ list.sort((a, b) => b.score - a.score);
176
+ const shown = list.slice(0, words.length ? PER_GROUP : 40);
177
+ out.push({ group, entries: shown.map((x) => x.e), more: list.length - shown.length });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /* --------------------------------- the dialog --------------------------------- */
183
+
184
+ let current = null;
185
+
186
+ /** Open the search dialog, or focus it when it is already open. */
187
+ export function openSearch() {
188
+ if (current) {
189
+ current.input.focus();
190
+ return;
191
+ }
192
+ const box = document.createElement("dialog");
193
+ if (typeof box.showModal !== "function") return;
194
+ box.className = "palette";
195
+
196
+ const input = el("input", {
197
+ type: "search",
198
+ className: "palinput",
199
+ placeholder: "Search issues, pull requests, memory, files, runs and docs",
200
+ autocomplete: "off",
201
+ spellcheck: false,
202
+ });
203
+ const results = el("div", { className: "palresults", role: "listbox" });
204
+ const foot = el("div", { className: "palfoot" }, [
205
+ el("span", { innerHTML: "<kbd>↑</kbd><kbd>↓</kbd> move" }),
206
+ el("span", { innerHTML: "<kbd>↵</kbd> open" }),
207
+ el("span", { innerHTML: "<kbd>esc</kbd> close" }),
208
+ ]);
209
+ box.append(el("div", { className: "palhead" }, [icon("search", "ic"), input]), results, foot);
210
+ document.body.append(box);
211
+ box.showModal();
212
+ input.focus();
213
+
214
+ let entries = index(S);
215
+ let flat = [];
216
+ let at = 0;
217
+ let docs = [];
218
+ let docsFor = "";
219
+ let docTimer = null;
220
+
221
+ const close = () => {
222
+ if (!current) return;
223
+ current = null;
224
+ clearTimeout(docTimer);
225
+ box.close();
226
+ box.remove();
227
+ };
228
+ current = { input, close };
229
+ box.addEventListener("close", () => {
230
+ current = null;
231
+ box.remove();
232
+ });
233
+ box.addEventListener("click", (e) => {
234
+ if (e.target === box) close();
235
+ });
236
+
237
+ const choose = (e) => {
238
+ close();
239
+ e.open();
240
+ };
241
+
242
+ const paint = () => {
243
+ const q = input.value.trim();
244
+ const grouped = rank(entries, q);
245
+ if (q && docsFor === q && docs.length) {
246
+ grouped.push({
247
+ group: "Docs",
248
+ entries: docs.slice(0, PER_GROUP).map((h) => ({
249
+ title: h.title,
250
+ sub: h.matches?.[0]?.text ?? h.file,
251
+ icon: "book-open",
252
+ open: () => go({ view: "docs", openDoc: h.file, docQuery: q }),
253
+ })),
254
+ more: Math.max(0, docs.length - PER_GROUP),
255
+ });
256
+ }
257
+ flat = grouped.flatMap((g) => g.entries);
258
+ at = Math.min(at, Math.max(0, flat.length - 1));
259
+ results.replaceChildren();
260
+ if (!flat.length) {
261
+ results.append(el("p", { className: "palempty", textContent: q ? "Nothing matches “" + q + "”." : "" }));
262
+ return;
263
+ }
264
+ let i = 0;
265
+ for (const g of grouped) {
266
+ results.append(
267
+ el("div", { className: "palgroup", textContent: g.group + (g.more ? ` · ${g.more} more` : "") }),
268
+ );
269
+ for (const e of g.entries) {
270
+ const n = i++;
271
+ const row = el("button", { className: "palrow", role: "option" }, [
272
+ icon(e.icon ?? "file", "ic"),
273
+ el("span", { className: "paltext" }, [
274
+ el("span", { className: "paltitle", innerHTML: highlight(e.title, q) }),
275
+ el("span", { className: "palsub", textContent: e.sub ?? "" }),
276
+ ]),
277
+ ]);
278
+ row.setAttribute("aria-selected", String(n === at));
279
+ row.onmouseenter = () => {
280
+ at = n;
281
+ mark();
282
+ };
283
+ row.onclick = () => choose(e);
284
+ results.append(row);
285
+ }
286
+ }
287
+ mark();
288
+ };
289
+
290
+ const mark = () => {
291
+ const rows = results.querySelectorAll(".palrow");
292
+ rows.forEach((r, n) => r.setAttribute("aria-selected", String(n === at)));
293
+ rows[at]?.scrollIntoView({ block: "nearest" });
294
+ };
295
+
296
+ input.addEventListener("input", () => {
297
+ at = 0;
298
+ paint();
299
+ const q = input.value.trim();
300
+ clearTimeout(docTimer);
301
+ if (q.length < 2) return;
302
+ docTimer = setTimeout(() => {
303
+ searchDocs(q)
304
+ .then((r) => {
305
+ docs = r.hits ?? [];
306
+ docsFor = q;
307
+ if (current && input.value.trim() === q) paint();
308
+ })
309
+ .catch(() => {});
310
+ }, 150);
311
+ });
312
+ input.addEventListener("keydown", (e) => {
313
+ if (e.key === "ArrowDown") {
314
+ e.preventDefault();
315
+ at = Math.min(at + 1, flat.length - 1);
316
+ mark();
317
+ } else if (e.key === "ArrowUp") {
318
+ e.preventDefault();
319
+ at = Math.max(at - 1, 0);
320
+ mark();
321
+ } else if (e.key === "Enter") {
322
+ e.preventDefault();
323
+ if (flat[at]) choose(flat[at]);
324
+ }
325
+ });
326
+
327
+ paint();
328
+
329
+ /* What is not in the page yet is fetched once and folded in as it lands: the issues, the org
330
+ files and the runs. The dialog is usable straight away with whatever is already here. */
331
+ const refill = () => {
332
+ if (!current) return;
333
+ entries = index(S);
334
+ paint();
335
+ };
336
+ if (!S.inbox) ensureInbox(false).then(refill).catch(() => {});
337
+ if (!S.orgFiles) {
338
+ getOrgLayer()
339
+ .then((r) => {
340
+ S.orgFiles = r.files ?? [];
341
+ refill();
342
+ })
343
+ .catch(() => {});
344
+ }
345
+ if (!S.runs) {
346
+ getRuns(false)
347
+ .then((r) => {
348
+ S.runs = r;
349
+ refill();
350
+ })
351
+ .catch(() => {});
352
+ }
353
+ }
354
+
355
+ /** The title with each query word marked, escaped first. */
356
+ function highlight(title, q) {
357
+ let html = String(title ?? "")
358
+ .replace(/&/g, "&amp;")
359
+ .replace(/</g, "&lt;")
360
+ .replace(/>/g, "&gt;");
361
+ for (const w of String(q ?? "").toLowerCase().split(/\s+/).filter((x) => x.length > 1)) {
362
+ const re = new RegExp(`(${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "ig");
363
+ html = html.replace(re, "<mark>$1</mark>");
364
+ }
365
+ return html;
366
+ }
@@ -541,7 +541,7 @@ function repaintInPlace() {
541
541
  * repaints Home, since a reply or a close there changes what belongs where. Where there is no
542
542
  * <dialog> (the test shim), it falls back to the thread on Trackers.
543
543
  */
544
- function openThread(item) {
544
+ export function openThread(item) {
545
545
  const ref = { repo: item.repo, number: item.number, kind: item.kind };
546
546
  const body = el("div");
547
547
  const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });