@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.
- package/README.md +70 -84
- package/dist/cli.js +4817 -2374
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +95 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +4 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +133 -79
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +95 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +290 -49
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +17 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +46 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +245 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +114 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +126 -39
- package/templates/portal/index.html +25 -3
- package/templates/portal/js/api.js +74 -4
- package/templates/portal/js/app.js +156 -14
- package/templates/portal/js/dialog.js +129 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +45 -1
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +59 -8
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +957 -126
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +124 -104
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +61 -67
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +257 -75
- package/templates/portal/js/views/staff.js +157 -182
- package/templates/portal/js/views/todo.js +62 -0
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- 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 {
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
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;
|