@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.30

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 (108) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4817 -2374
  3. package/docs/README.md +19 -11
  4. package/docs/agents.md +328 -13
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/analyst.md +65 -0
  7. package/docs/charters/cmo.md +69 -0
  8. package/docs/charters/community.md +63 -0
  9. package/docs/charters/cto.md +71 -0
  10. package/docs/charters/designer.md +65 -0
  11. package/docs/charters/devops.md +65 -0
  12. package/docs/charters/pm.md +70 -0
  13. package/docs/charters/qa.md +65 -0
  14. package/docs/charters/support.md +60 -0
  15. package/docs/charters/writer.md +63 -0
  16. package/docs/commands.md +95 -11
  17. package/docs/concepts.md +64 -12
  18. package/docs/cost.md +39 -3
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +21 -6
  21. package/docs/export.md +4 -1
  22. package/docs/extending.md +13 -4
  23. package/docs/getting-started.md +133 -79
  24. package/docs/images/brain.jpg +0 -0
  25. package/docs/images/org.jpg +0 -0
  26. package/docs/images/prompt.jpg +0 -0
  27. package/docs/images/setup-org.jpg +0 -0
  28. package/docs/images/setup-plan.jpg +0 -0
  29. package/docs/images/staff.jpg +0 -0
  30. package/docs/manual-steps.md +95 -101
  31. package/docs/memory.md +29 -8
  32. package/docs/org-yaml.md +76 -11
  33. package/docs/portal.md +290 -49
  34. package/docs/prompts.md +77 -11
  35. package/docs/security.md +51 -7
  36. package/docs/session-workflow.md +51 -21
  37. package/docs/staff-yaml.md +17 -7
  38. package/docs/troubleshooting.md +23 -20
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +46 -17
  41. package/package.json +1 -1
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  44. package/templates/brain/CHARTER.md +3 -3
  45. package/templates/brain/README.md +1 -0
  46. package/templates/brain/log/decisions.md +3 -0
  47. package/templates/brain/staff.yaml +0 -1
  48. package/templates/brain/strategy/ideas.md +7 -0
  49. package/templates/briefs/priorities.md +46 -0
  50. package/templates/ops/.github/workflows/session.yaml +117 -40
  51. package/templates/ops/agents.mjs +127 -8
  52. package/templates/ops/compose.mjs +77 -7
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +21 -7
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_identity.md +8 -1
  57. package/templates/ops/prompts/_inflight.md +14 -0
  58. package/templates/ops/prompts/_paths.md +2 -1
  59. package/templates/ops/prompts/daily.md +16 -7
  60. package/templates/ops/prompts/mention.md +18 -2
  61. package/templates/ops/run-record.mjs +144 -0
  62. package/templates/portal/css/base.css +245 -64
  63. package/templates/portal/css/brain.css +30 -20
  64. package/templates/portal/css/diff.css +15 -10
  65. package/templates/portal/css/graph.css +12 -7
  66. package/templates/portal/css/health.css +32 -11
  67. package/templates/portal/css/inbox.css +117 -14
  68. package/templates/portal/css/layout.css +114 -41
  69. package/templates/portal/css/markdown.css +57 -15
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +126 -39
  72. package/templates/portal/index.html +25 -3
  73. package/templates/portal/js/api.js +74 -4
  74. package/templates/portal/js/app.js +156 -14
  75. package/templates/portal/js/dialog.js +129 -4
  76. package/templates/portal/js/dom.js +25 -0
  77. package/templates/portal/js/icons.js +45 -1
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/lightbox.js +273 -0
  80. package/templates/portal/js/md.js +23 -6
  81. package/templates/portal/js/mdedit.js +84 -0
  82. package/templates/portal/js/mention.js +264 -0
  83. package/templates/portal/js/readiness.js +35 -0
  84. package/templates/portal/js/refresh.js +136 -6
  85. package/templates/portal/js/state.js +59 -8
  86. package/templates/portal/js/views/app.js +24 -7
  87. package/templates/portal/js/views/checklist.js +29 -10
  88. package/templates/portal/js/views/credential.js +84 -0
  89. package/templates/portal/js/views/docs.js +94 -4
  90. package/templates/portal/js/views/files.js +58 -14
  91. package/templates/portal/js/views/graph.js +1 -1
  92. package/templates/portal/js/views/health.js +178 -37
  93. package/templates/portal/js/views/hire.js +583 -0
  94. package/templates/portal/js/views/inbox.js +957 -126
  95. package/templates/portal/js/views/memory.js +16 -1
  96. package/templates/portal/js/views/org.js +124 -104
  97. package/templates/portal/js/views/orgedit.js +234 -0
  98. package/templates/portal/js/views/paste.js +87 -21
  99. package/templates/portal/js/views/prompt.js +61 -67
  100. package/templates/portal/js/views/repos.js +20 -15
  101. package/templates/portal/js/views/runonce.js +94 -0
  102. package/templates/portal/js/views/runs.js +165 -0
  103. package/templates/portal/js/views/setup.js +257 -75
  104. package/templates/portal/js/views/staff.js +157 -182
  105. package/templates/portal/js/views/todo.js +62 -0
  106. package/templates/portal/js/yaml.js +134 -0
  107. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  108. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -2,24 +2,37 @@
2
2
 
3
3
  import { getOrg } from "./api.js";
4
4
  import { $, el, store } from "./dom.js";
5
- import { icon, iconHTML } from "./icons.js";
5
+ import { icon, iconHTML, staffIcon } from "./icons.js";
6
+ import { installLightbox } from "./lightbox.js";
6
7
  import { setPeople } from "./md.js";
7
- import { refreshAll, stampLoaded, syncNotice } from "./refresh.js";
8
+ import {
9
+ countInBackground,
10
+ refreshAll,
11
+ refreshQuietly,
12
+ stampCounts,
13
+ stampLoaded,
14
+ syncNotice,
15
+ } from "./refresh.js";
8
16
  import { onRender } from "./router.js";
9
- import { ORG_WIDE, S, VIEWS, applyHash, openCount, writeHash } from "./state.js";
17
+ import { ORG_WIDE, S, VIEWS, applyHash, humanLabel, readHash, writeHash } from "./state.js";
10
18
  import { viewBrain } from "./views/brain.js";
11
19
  import { viewChanged } from "./views/changed.js";
12
20
  import { viewDocs } from "./views/docs.js";
13
21
  import { viewGraph } from "./views/graph.js";
14
22
  import { viewHealth } from "./views/health.js";
15
- import { viewInbox } from "./views/inbox.js";
23
+ import { viewInbox, viewPrs } from "./views/inbox.js";
16
24
  import { viewOrg } from "./views/org.js";
17
25
  import { viewPrompt } from "./views/prompt.js";
18
- import { viewSetup } from "./views/setup.js";
26
+ import { viewRuns } from "./views/runs.js";
27
+ import { sentence, whatsLeft } from "./readiness.js";
28
+ import { paintSetupNav, viewGettingStarted, viewSetup } from "./views/setup.js";
19
29
  import { viewStaff } from "./views/staff.js";
20
30
 
21
31
  const SCREEN = {
32
+ setup: viewGettingStarted,
22
33
  inbox: viewInbox,
34
+ prs: viewPrs,
35
+ runs: viewRuns,
23
36
  org: viewOrg,
24
37
  staff: viewStaff,
25
38
  docs: viewDocs,
@@ -41,9 +54,42 @@ const SCREEN = {
41
54
  */
42
55
  const NEEDS = ["org", "name", "opsName", "staff"];
43
56
 
57
+ /** The org-wide screens in the sidebar, by button. */
58
+ const NAV = [
59
+ ["#setupnav", "setup"],
60
+ ["#inboxnav", "inbox"],
61
+ ["#prsnav", "prs"],
62
+ ["#runsnav", "runs"],
63
+ ["#staffnav", "staff"],
64
+ ["#orgnav", "org"],
65
+ ["#docsnav", "docs"],
66
+ ];
67
+
44
68
  export async function boot() {
45
69
  initTheme();
46
70
 
71
+ /* Bound before the first request rather than after it. The sidebar is markup, so it is on
72
+ screen and clickable while /api/org is still building the export, and a click in that
73
+ window used to do nothing at all: the first click on Staff after a load was simply lost.
74
+ Now it is remembered and honoured once there is something to render. */
75
+ let early = null;
76
+ for (const [id, view] of NAV) {
77
+ const b = $(id);
78
+ if (!b) continue;
79
+ b.onclick = () => {
80
+ if (!S.data) {
81
+ early = view;
82
+ return;
83
+ }
84
+ S.view = view;
85
+ render();
86
+ };
87
+ }
88
+
89
+ /* Before the first render, and once: it is delegated, so it covers every image any screen
90
+ draws from here on, including the setup screen. */
91
+ installLightbox();
92
+
47
93
  /* Before there is a tenant the server answers every data route with `mode: setup`. That is
48
94
  not an error state: it is the first thing a new user ever sees, and the whole page becomes
49
95
  the thing that fixes it. */
@@ -54,7 +100,7 @@ export async function boot() {
54
100
  /* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
55
101
  row of four dead ends beside an empty Staff heading. There is one thing to do on this
56
102
  screen; the navigation comes back with the org. */
57
- for (const el of document.querySelectorAll("#inboxnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
103
+ for (const el of document.querySelectorAll("#inboxnav, #prsnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
58
104
  el.hidden = true;
59
105
  }
60
106
  for (const slot of document.querySelectorAll("[data-icon]")) {
@@ -66,6 +112,8 @@ export async function boot() {
66
112
 
67
113
  S.data = first;
68
114
  S.loadedAt = new Date();
115
+ S.staffOpen = null; // an open hire list survives a refresh, not a page load
116
+ S.readiness = null;
69
117
  S.staffHandle = S.data.staff[0]?.handle ?? null;
70
118
  $("#orgname").textContent = S.data.name + " · " + S.data.staff.length + " staff";
71
119
 
@@ -76,18 +124,23 @@ export async function boot() {
76
124
  slot.innerHTML = iconHTML(slot.dataset.icon);
77
125
  }
78
126
  onRender(render);
127
+ const linked = readHash() !== null;
79
128
  applyHash();
129
+ /* An org nobody has been hired into has an empty Inbox, which says nothing about what to do
130
+ next. It opens on what is left instead, unless a link or a click asked for somewhere else. */
131
+ if (early) S.view = early;
132
+ else if (!linked && (S.data.unfinished ?? []).includes("hire")) S.view = "setup";
133
+ initStaffFold();
80
134
  paintSidebar();
81
135
 
82
- $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
83
- $("#staffnav").onclick = () => { S.view = "staff"; render(); };
84
- $("#orgnav").onclick = () => { S.view = "org"; render(); };
85
- $("#docsnav").onclick = () => { S.view = "docs"; render(); };
86
136
  $("#refreshall").onclick = () => refreshAll(false);
87
137
 
88
- // Coming back to the tab after a run should show the run.
138
+ /* Coming back to the tab after a run should show the run — and coming back to it thirty
139
+ seconds after leaving it should show nothing at all. Clicking into the window used to
140
+ re-read every repo on GitHub and repaint the screen under you; now it looks first and
141
+ only repaints when something actually moved. */
89
142
  addEventListener("focus", () => {
90
- if (S.loadedAt && Date.now() - S.loadedAt > 30000) refreshAll(false);
143
+ if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
91
144
  });
92
145
  addEventListener("keydown", (e) => {
93
146
  if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
@@ -98,6 +151,11 @@ export async function boot() {
98
151
  });
99
152
  setInterval(stampLoaded, 30000);
100
153
 
154
+ /* What is waiting on you is the first thing this page should be able to tell you, and it used
155
+ to be the one thing it would not say until you clicked Inbox. One request, shared with
156
+ whichever screen renders next. */
157
+ countInBackground();
158
+
101
159
  // Only the state changes here. This used to re-run half of boot on every navigation,
102
160
  // which stacked up a focus listener, a keydown listener and an interval each time.
103
161
  addEventListener("hashchange", () => {
@@ -159,7 +217,44 @@ function learnPeople() {
159
217
  */
160
218
  let paintedRoster = null;
161
219
 
220
+ /**
221
+ * The Staff heading is a fold, and folded it carries the count.
222
+ *
223
+ * It starts folded, so the count is not decoration: a bare "STAFF" heading over nothing is how
224
+ * a loaded org would look if the export came back empty, and those two states have to be
225
+ * tellable apart at a glance. The count sits on the heading rather than beside it so there is
226
+ * one thing to click.
227
+ */
228
+ let staffOpen = true;
229
+
230
+ function paintStaffFold() {
231
+ const b = $("#stafffold");
232
+ if (b) b.setAttribute("aria-expanded", String(staffOpen));
233
+ const list = $("#stafflist");
234
+ if (list) list.hidden = !staffOpen;
235
+ const n = $("#staffcount");
236
+ if (n) {
237
+ n.textContent = "(" + (S.data?.staff?.length ?? 0) + ")";
238
+ n.hidden = staffOpen;
239
+ }
240
+ }
241
+
242
+ function initStaffFold() {
243
+ // Folded until you say otherwise: the count on the heading is enough to know the roster is
244
+ // there, and the four org-wide screens above it are where a session starts.
245
+ staffOpen = store("roster.staffopen") === "1";
246
+ paintStaffFold();
247
+ const b = $("#stafffold");
248
+ if (!b) return;
249
+ b.onclick = () => {
250
+ staffOpen = !staffOpen;
251
+ store("roster.staffopen", staffOpen ? "1" : "0");
252
+ paintStaffFold();
253
+ };
254
+ }
255
+
162
256
  function paintSidebar() {
257
+ paintInboxLinks();
163
258
  const host = $("#stafflist");
164
259
  host.replaceChildren();
165
260
  paintedRoster = S.data.staff.map((s) => s.handle).join(" ");
@@ -170,11 +265,20 @@ function paintSidebar() {
170
265
  const head = el("button", { className: "nav staffrow" });
171
266
  head.dataset.staff = s.handle;
172
267
  head.setAttribute("aria-expanded", String(open));
268
+ const dot = el("span", { className: "readydot", hidden: true });
173
269
  head.append(
174
270
  icon("chevron", "caret"),
271
+ icon(staffIcon(s), "ic"),
175
272
  el("span", { className: "t", textContent: s.name }),
273
+ dot,
176
274
  el("span", { className: "hh", textContent: s.handle }),
177
275
  );
276
+ whatsLeft(s).then((left) => {
277
+ if (!left.length) return;
278
+ dot.hidden = false;
279
+ head.title = "Not ready to run: " + sentence(left);
280
+ paintSetupNav();
281
+ });
178
282
 
179
283
  const views = el("div", { className: "staffviews" });
180
284
  views.hidden = !open;
@@ -207,10 +311,48 @@ function paintSidebar() {
207
311
 
208
312
  host.append(head, views);
209
313
  }
314
+
315
+ // A refresh can hire or retire someone, and the folded heading is where that number shows.
316
+ paintStaffFold();
210
317
  }
211
318
 
212
319
  /** Which rows are lit, and which staff member is unfolded. */
320
+ /* The inbox's filters, as links under Inbox: everything, what is on you, decisions, then one
321
+ per staff member. They replaced two dropdowns on the Inbox screen itself. */
322
+ function paintInboxLinks() {
323
+ const sub = $("#inboxsub");
324
+ if (!sub) return;
325
+ sub.replaceChildren();
326
+ // Inbox itself is everything. Under it: each staff member, then the product repos' issues.
327
+ const links = [
328
+ ...S.data.staff.map((s) => [s.handle, s.name, staffIcon(s)]),
329
+ ["@issues", "Issues", "issue-open"],
330
+ ];
331
+ for (const [who, label, glyph] of links) {
332
+ const b = el("button", { className: "nav subnav inboxlink" }, [
333
+ icon(glyph, "ic"),
334
+ el("span", { className: "t", textContent: label }),
335
+ ]);
336
+ b.dataset.who = who;
337
+ b.onclick = () => {
338
+ S.view = "inbox";
339
+ // A second click on the one you are on goes back to everything.
340
+ S.inboxStaff = S.inboxStaff === who ? "" : who;
341
+ S.inboxFilter = "";
342
+ S.inboxOpen = null;
343
+ render();
344
+ };
345
+ sub.append(b);
346
+ }
347
+ }
348
+
213
349
  function markSidebar() {
350
+ const sub = $("#inboxsub");
351
+ if (sub) sub.hidden = S.view !== "inbox";
352
+ for (const b of document.querySelectorAll(".inboxlink")) {
353
+ const on = S.view === "inbox" && b.dataset.who === (S.inboxStaff || "");
354
+ b.setAttribute("aria-current", String(on));
355
+ }
214
356
  for (const b of document.querySelectorAll(".nav")) {
215
357
  if (!b.dataset.view) continue;
216
358
  const mine = !b.dataset.staff || b.dataset.staff === S.staffHandle;
@@ -235,9 +377,9 @@ function render() {
235
377
  // A refresh can add or remove a staff member, and the sidebar is built once.
236
378
  const roster = S.data.staff.map((s) => s.handle).join(" ");
237
379
  if (roster !== paintedRoster) paintSidebar();
380
+ paintSetupNav();
238
381
  markSidebar();
239
- const ic = $("#inboxcount");
240
- if (ic) ic.textContent = S.inbox ? String(openCount()) : "";
382
+ stampCounts();
241
383
  writeHash(true);
242
384
  stampLoaded();
243
385
 
@@ -7,11 +7,47 @@
7
7
  */
8
8
 
9
9
  import { el, grow } from "./dom.js";
10
+ import { attachMentions } from "./mention.js";
10
11
 
11
12
  /**
13
+ * Whether a click was on the backdrop, which is not the same question as "is the target the
14
+ * dialog".
15
+ *
16
+ * Picking a name from the `@` list used to close the whole dialog and throw away what you had
17
+ * typed. The list hides itself on mousedown so the caret survives the pick, so by the time the
18
+ * `click` lands the row is gone and the event retargets to the nearest thing still under the
19
+ * pointer, which is the dialog. A target test alone reads that as a backdrop click.
20
+ *
21
+ * So the pointer has to actually be outside the dialog's own box. `detail` of 0 is a click
22
+ * synthesised by the keyboard, which reports 0,0 and would otherwise look like the top corner
23
+ * of the page.
24
+ */
25
+ export function onBackdrop(e, rect) {
26
+ if (!rect || e.currentTarget !== e.target || !e.detail) return false;
27
+ return (
28
+ e.clientX < rect.left ||
29
+ e.clientX > rect.right ||
30
+ e.clientY < rect.top ||
31
+ e.clientY > rect.bottom
32
+ );
33
+ }
34
+
35
+ /**
36
+ * @param decorate given the textarea, returns a node to sit under it. This is how the reply
37
+ * box carries its attachments: the control has to write into the box it is beside.
38
+ * @param allowEmpty saying nothing is an answer for some of these. Closing an issue without
39
+ * a parting comment is the normal case, not a cancelled dialog.
12
40
  * @returns the text, or null if they cancelled.
13
41
  */
14
- export function askText({ title, hint, value = "", placeholder = "", confirm = "Continue" }) {
42
+ export function askText({
43
+ title,
44
+ hint,
45
+ value = "",
46
+ placeholder = "",
47
+ confirm = "Continue",
48
+ decorate,
49
+ allowEmpty = false,
50
+ }) {
15
51
  const box = document.createElement("dialog");
16
52
  // The shim in the tests has no dialog element. Falling back keeps a view renderable there
17
53
  // rather than throwing halfway through a paint.
@@ -25,6 +61,12 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
25
61
 
26
62
  const ta = el("textarea", { value, placeholder, rows: 5 });
27
63
  box.append(ta);
64
+ /* Before the ⌘⏎ handler below, so that when the `@` list is open its own Enter wins. Every
65
+ dialog this function opens is prose that may need to reach somebody, and a mention is how
66
+ it reaches them. */
67
+ attachMentions(ta);
68
+ const under = decorate?.(ta);
69
+ if (under) box.append(under);
28
70
 
29
71
  const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
30
72
  const go = el("button", { className: "ghbtn primary", textContent: confirm });
@@ -42,12 +84,13 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
42
84
  answer = text;
43
85
  box.close();
44
86
  };
87
+ const said = () => ta.value.trim() || (allowEmpty ? "" : null);
45
88
  cancel.onclick = () => done(null);
46
- go.onclick = () => done(ta.value.trim() || null);
89
+ go.onclick = () => done(said());
47
90
  ta.addEventListener("keydown", (e) => {
48
91
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
49
92
  e.preventDefault();
50
- done(ta.value.trim() || null);
93
+ done(said());
51
94
  }
52
95
  });
53
96
  // Escape and the backdrop both close it, and both mean no.
@@ -56,7 +99,7 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
56
99
  resolve(answer);
57
100
  });
58
101
  box.addEventListener("click", (e) => {
59
- if (e.target === box) done(null);
102
+ if (onBackdrop(e, box.getBoundingClientRect?.())) done(null);
60
103
  });
61
104
 
62
105
  document.body.append(box);
@@ -68,3 +111,85 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
68
111
  ta.setSelectionRange?.(ta.value.length, ta.value.length);
69
112
  });
70
113
  }
114
+
115
+ /**
116
+ * Asking before something that cannot be taken back: a merge, a push, a new repo, a paid run.
117
+ *
118
+ * Native `confirm()` looked like the browser rather than this page, and it blocks the whole
119
+ * tab, which froze browser automation mid-run. This is the same `<dialog>` as askText, so the
120
+ * backdrop, Escape and the focus trap come free. The wording is the caller's: `title` is the
121
+ * question, `hint` says what will happen.
122
+ *
123
+ * @returns true only for the confirm button. Escape, Cancel and the backdrop are all no.
124
+ */
125
+ export function askYes({ title, hint, confirm = "Continue" }) {
126
+ const box = document.createElement("dialog");
127
+ // The test shim has no dialog element; see askText.
128
+ if (typeof box.showModal !== "function") {
129
+ return Promise.resolve(Boolean(globalThis.confirm?.(hint ? title + "\n\n" + hint : title)));
130
+ }
131
+
132
+ box.className = "ask";
133
+ box.append(el("h3", { textContent: title }));
134
+ if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
135
+
136
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
137
+ const go = el("button", { className: "ghbtn primary", textContent: confirm });
138
+ box.append(el("div", { className: "row askrow" }, [cancel, go]));
139
+
140
+ return new Promise((resolve) => {
141
+ let answer = false;
142
+ const done = (yes) => {
143
+ answer = yes;
144
+ box.close();
145
+ };
146
+ cancel.onclick = () => done(false);
147
+ go.onclick = () => done(true);
148
+ box.addEventListener("close", () => {
149
+ box.remove();
150
+ resolve(answer);
151
+ });
152
+ box.addEventListener("click", (e) => {
153
+ if (onBackdrop(e, box.getBoundingClientRect?.())) done(false);
154
+ });
155
+ document.body.append(box);
156
+ box.showModal();
157
+ // Cancel has the focus, so an Enter pressed out of habit does not merge anything.
158
+ cancel.focus();
159
+ });
160
+ }
161
+
162
+ /**
163
+ * A larger dialog for a whole task: hiring someone, or finishing their setup. Native <dialog>
164
+ * again, appended to <body> rather than to the screen, so a background refresh repainting the
165
+ * screen leaves it open with whatever was typed into it.
166
+ *
167
+ * @returns {{set: (node: Node) => void, close: () => void, isOpen: () => boolean} | null}
168
+ * null where there is no dialog element (the test shim), and the caller draws inline instead.
169
+ */
170
+ export function sheet({ node, onClose }) {
171
+ const box = document.createElement("dialog");
172
+ if (typeof box.showModal !== "function") return null;
173
+ box.className = "sheet";
174
+ const body = el("div", { className: "sheetbody" });
175
+ body.append(node);
176
+ box.append(body);
177
+ let open = true;
178
+ const close = () => {
179
+ if (!open) return;
180
+ open = false;
181
+ box.close();
182
+ box.remove();
183
+ onClose?.();
184
+ };
185
+ box.addEventListener("cancel", (e) => {
186
+ e.preventDefault();
187
+ close();
188
+ });
189
+ box.addEventListener("click", (e) => {
190
+ if (onBackdrop(e, box.getBoundingClientRect())) close();
191
+ });
192
+ document.body.append(box);
193
+ box.showModal();
194
+ return { set: (n) => body.replaceChildren(n), close, isOpen: () => open };
195
+ }
@@ -35,6 +35,31 @@ export function markCurrent(list, btn) {
35
35
  btn.setAttribute("aria-current", "true");
36
36
  }
37
37
 
38
+ /**
39
+ * Placeholder shapes, for the seconds a screen spends asking GitHub.
40
+ *
41
+ * The inbox reads every repo in the org, which is several seconds on a cold cache, and what
42
+ * stood there meanwhile was the word "Asking GitHub…" in an otherwise empty box beside an
43
+ * empty box. Nothing about that says the shape of what is coming, so the page looked broken
44
+ * rather than busy.
45
+ *
46
+ * `kind` names the shape: "row" for a list item, "line" for a paragraph, "head" for a title.
47
+ * They are decorative, so they are hidden from assistive technology and the live region
48
+ * elsewhere on the screen does the announcing.
49
+ */
50
+ export function skeleton(kind, n = 1) {
51
+ const out = [];
52
+ for (let i = 0; i < n; i++) {
53
+ const box = el("div", { className: "sk sk-" + kind });
54
+ box.setAttribute("aria-hidden", "true");
55
+ if (kind === "row") {
56
+ box.append(el("div", { className: "skbar t" }), el("div", { className: "skbar m" }));
57
+ }
58
+ out.push(box);
59
+ }
60
+ return out;
61
+ }
62
+
38
63
  export function store(k, v) {
39
64
  try {
40
65
  return v === undefined ? localStorage.getItem(k) : localStorage.setItem(k, v);
@@ -1,6 +1,6 @@
1
1
  /* Icons.
2
2
  *
3
- * Twenty-four Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
3
+ * Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
4
4
  * a CDN or a font. The portal is local-first and offline, so an icon that needs the network
5
5
  * is an icon that is sometimes a blank square.
6
6
  *
@@ -13,6 +13,26 @@
13
13
  */
14
14
 
15
15
  const PATHS = {
16
+ "code":
17
+ "<polyline points=\"16 18 22 12 16 6\" /> <polyline points=\"8 6 2 12 8 18\" />",
18
+ "megaphone":
19
+ "<path d=\"m3 11 18-5v12L3 14v-3z\" /> <path d=\"M11.6 16.8a3 3 0 1 1-5.8-1.6\" />",
20
+ "life-buoy":
21
+ "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m4.93 4.93 4.24 4.24\" /> <path d=\"m14.83 9.17 4.24-4.24\" /> <path d=\"m14.83 14.83 4.24 4.24\" /> <path d=\"m9.17 14.83-4.24 4.24\" /> <circle cx=\"12\" cy=\"12\" r=\"4\" />",
22
+ "compass":
23
+ "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <polygon points=\"16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76\" />",
24
+ "brush":
25
+ "<path d=\"m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08\" /> <path d=\"M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z\" />",
26
+ "bug":
27
+ "<path d=\"m8 2 1.88 1.88\" /> <path d=\"M14.12 3.88 16 2\" /> <path d=\"M9 7.13v-1a3.003 3.003 0 1 1 6 0v1\" /> <path d=\"M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6\" /> <path d=\"M12 20v-9\" /> <path d=\"M6.53 9C4.6 8.8 3 7.1 3 5\" /> <path d=\"M6 13H2\" /> <path d=\"M3 21c0-2.1 1.7-3.9 3.8-4\" /> <path d=\"M20.97 5c0 2.1-1.6 3.8-3.5 4\" /> <path d=\"M22 13h-4\" /> <path d=\"M17.2 17c2.1.1 3.8 1.9 3.8 4\" />",
28
+ "server":
29
+ "<rect width=\"20\" height=\"8\" x=\"2\" y=\"2\" rx=\"2\" ry=\"2\" /> <rect width=\"20\" height=\"8\" x=\"2\" y=\"14\" rx=\"2\" ry=\"2\" /> <line x1=\"6\" x2=\"6.01\" y1=\"6\" y2=\"6\" /> <line x1=\"6\" x2=\"6.01\" y1=\"18\" y2=\"18\" />",
30
+ "book-open":
31
+ "<path d=\"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z\" /> <path d=\"M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z\" />",
32
+ "bar-chart":
33
+ "<line x1=\"12\" x2=\"12\" y1=\"20\" y2=\"10\" /> <line x1=\"18\" x2=\"18\" y1=\"20\" y2=\"4\" /> <line x1=\"6\" x2=\"6\" y1=\"20\" y2=\"16\" />",
34
+ "users":
35
+ "<path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\" /> <circle cx=\"9\" cy=\"7\" r=\"4\" /> <path d=\"M22 21v-2a4 4 0 0 0-3-3.87\" /> <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" />",
16
36
  "check":
17
37
  "<path d=\"M20 6 9 17l-5-5\" />",
18
38
  "chevron":
@@ -53,8 +73,15 @@ const PATHS = {
53
73
  "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
54
74
  "moon":
55
75
  "<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />",
76
+ "paperclip":
77
+ "<path d=\"m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\" />",
56
78
  "person":
57
79
  "<circle cx=\"12\" cy=\"8\" r=\"5\" /> <path d=\"M20 21a8 8 0 0 0-16 0\" />",
80
+ "play":
81
+ "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\" />",
82
+ // The pair for the lightbox's zoom controls. `dash` above is the other half.
83
+ "plus":
84
+ "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
58
85
  "refresh":
59
86
  "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" /> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" /> <path d=\"M8 16H3v5\" />",
60
87
  "rename":
@@ -89,6 +116,23 @@ export function icon(name, cls = "") {
89
116
  return span.firstChild ?? span;
90
117
  }
91
118
 
119
+ /* A staff member's icon: `icon:` in their staff.yaml, or one picked from their role. */
120
+ const ROLE_ICONS = {
121
+ cto: "code",
122
+ cmo: "megaphone",
123
+ support: "life-buoy",
124
+ pm: "compass",
125
+ designer: "brush",
126
+ qa: "bug",
127
+ devops: "server",
128
+ writer: "book-open",
129
+ analyst: "bar-chart",
130
+ community: "users",
131
+ };
132
+ export function staffIcon(s) {
133
+ return s?.icon && hasIcon(s.icon) ? s.icon : (ROLE_ICONS[s?.handle] ?? "person");
134
+ }
135
+
92
136
  export function hasIcon(name) {
93
137
  return Object.hasOwn(PATHS, name);
94
138
  }
@@ -0,0 +1,18 @@
1
+ /* How many requests the page has open. The setup dialog waits for this to reach zero before it
2
+ * shows itself, because the list it draws is only final once every answer is back. */
3
+
4
+ let open = 0;
5
+
6
+ if (typeof globalThis.fetch === "function" && !globalThis.fetch.counted) {
7
+ const real = globalThis.fetch.bind(globalThis);
8
+ const counted = (...args) => {
9
+ open++;
10
+ return real(...args).finally(() => {
11
+ open--;
12
+ });
13
+ };
14
+ counted.counted = true;
15
+ globalThis.fetch = counted;
16
+ }
17
+
18
+ export const pending = () => open;