@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50

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 (102) hide show
  1. package/README.md +70 -84
  2. package/dist/cli.js +4833 -2752
  3. package/docs/README.md +9 -6
  4. package/docs/agents.md +24 -20
  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 +93 -7
  17. package/docs/concepts.md +61 -14
  18. package/docs/cost.md +36 -1
  19. package/docs/developing.md +16 -21
  20. package/docs/doctor-codes.md +10 -2
  21. package/docs/export.md +2 -0
  22. package/docs/extending.md +2 -2
  23. package/docs/getting-started.md +128 -78
  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 +94 -123
  31. package/docs/memory.md +21 -3
  32. package/docs/org-yaml.md +40 -2
  33. package/docs/portal.md +165 -48
  34. package/docs/prompts.md +31 -4
  35. package/docs/security.md +37 -5
  36. package/docs/session-workflow.md +63 -17
  37. package/docs/staff-yaml.md +30 -3
  38. package/docs/troubleshooting.md +8 -8
  39. package/docs/upgrading.md +9 -3
  40. package/docs/writing-a-charter.md +28 -0
  41. package/package.json +18 -20
  42. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +26 -1
  43. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
  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 +4 -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 +236 -15
  51. package/templates/ops/agents.mjs +7 -3
  52. package/templates/ops/compose.mjs +31 -3
  53. package/templates/ops/inflight.mjs +157 -0
  54. package/templates/ops/org/operating.md +43 -4
  55. package/templates/ops/org/voice.md +9 -0
  56. package/templates/ops/prompts/_inflight.md +14 -0
  57. package/templates/ops/prompts/_paths.md +2 -1
  58. package/templates/ops/prompts/daily.md +37 -9
  59. package/templates/ops/prompts/mention.md +21 -0
  60. package/templates/ops/run-record.mjs +146 -0
  61. package/templates/portal/css/base.css +167 -73
  62. package/templates/portal/css/brain.css +23 -20
  63. package/templates/portal/css/diff.css +10 -9
  64. package/templates/portal/css/graph.css +12 -7
  65. package/templates/portal/css/health.css +13 -11
  66. package/templates/portal/css/home.css +93 -0
  67. package/templates/portal/css/inbox.css +45 -25
  68. package/templates/portal/css/layout.css +90 -46
  69. package/templates/portal/css/markdown.css +36 -14
  70. package/templates/portal/css/runs.css +13 -0
  71. package/templates/portal/css/setup.css +116 -34
  72. package/templates/portal/index.html +21 -9
  73. package/templates/portal/js/api.js +44 -4
  74. package/templates/portal/js/app.js +94 -9
  75. package/templates/portal/js/dialog.js +83 -0
  76. package/templates/portal/js/homesort.js +174 -0
  77. package/templates/portal/js/icons.js +37 -0
  78. package/templates/portal/js/inflight.js +18 -0
  79. package/templates/portal/js/md.js +5 -2
  80. package/templates/portal/js/mdedit.js +84 -0
  81. package/templates/portal/js/readiness.js +70 -0
  82. package/templates/portal/js/refresh.js +10 -2
  83. package/templates/portal/js/state.js +11 -5
  84. package/templates/portal/js/views/app.js +24 -7
  85. package/templates/portal/js/views/brain.js +1 -1
  86. package/templates/portal/js/views/checklist.js +10 -4
  87. package/templates/portal/js/views/credential.js +84 -0
  88. package/templates/portal/js/views/graph.js +1 -1
  89. package/templates/portal/js/views/health.js +17 -4
  90. package/templates/portal/js/views/hire.js +593 -0
  91. package/templates/portal/js/views/home.js +546 -0
  92. package/templates/portal/js/views/inbox.js +226 -70
  93. package/templates/portal/js/views/org.js +46 -106
  94. package/templates/portal/js/views/orgedit.js +234 -0
  95. package/templates/portal/js/views/paste.js +87 -21
  96. package/templates/portal/js/views/prompt.js +11 -4
  97. package/templates/portal/js/views/repos.js +20 -15
  98. package/templates/portal/js/views/runonce.js +94 -0
  99. package/templates/portal/js/views/runs.js +170 -0
  100. package/templates/portal/js/views/setup.js +261 -75
  101. package/templates/portal/js/views/staff.js +170 -243
  102. package/templates/portal/js/views/todo.js +62 -0
@@ -1,47 +1,155 @@
1
- /* Hiring and retiring, from the portal.
1
+ /* The Staff screen: a card per staff member, retiring, and the way in to hiring.
2
2
  *
3
- * Both run the CLI's own `buildPlan` and `applyPlan` on the server rather than describing
4
- * either again here. Two descriptions of how to create a repo is one too many, and the one in
5
- * the browser would be the one nobody updated.
6
- *
7
- * Plan then apply, the same as every roster command that changes something: you see every
8
- * file, every label and every existing staff member it would edit, and nothing happens until
9
- * you say so.
3
+ * Hiring itself is in hire.js. Retiring runs the CLI's own `buildRetirePlan` and
4
+ * `applyRetirePlan` on the server, plan then apply: you see what it stops and what it keeps,
5
+ * and nothing happens until you say so.
10
6
  */
11
7
 
12
8
  import { post } from "../api.js";
9
+ import { askYes, sheet } from "../dialog.js";
10
+ import { pending } from "../inflight.js";
13
11
  import { ago, el, esc } from "../dom.js";
14
- import { icon } from "../icons.js";
12
+ import { sentence, staffProgress, whatsLeft } from "../readiness.js";
15
13
  import { refreshAll } from "../refresh.js";
16
14
  import { S } from "../state.js";
17
- import { appPanel } from "./app.js";
18
- import { cronText } from "./health.js";
19
- import { paste } from "./paste.js";
15
+ import { hireFlow, line, rolePicker } from "./hire.js";
16
+
17
+ /**
18
+ * Resolves once nothing is loading and `node` has stopped changing for a moment, or after a few
19
+ * seconds regardless. Quiet alone was not enough: while GitHub was still answering, nothing
20
+ * changed, and the half-built list was shown.
21
+ */
22
+ function settled(node, quiet = 250, most = 6000) {
23
+ return new Promise((resolve) => {
24
+ if (typeof MutationObserver !== "function") return resolve();
25
+ let lastChange = Date.now();
26
+ const obs = new MutationObserver(() => {
27
+ lastChange = Date.now();
28
+ });
29
+ obs.observe(node, { childList: true, subtree: true, attributes: true, characterData: true });
30
+ const started = Date.now();
31
+ const tick = setInterval(() => {
32
+ const now = Date.now();
33
+ if ((pending() === 0 && now - lastChange >= quiet) || now - started >= most) {
34
+ clearInterval(tick);
35
+ obs.disconnect();
36
+ resolve();
37
+ }
38
+ }, 50);
39
+ });
40
+ }
20
41
 
21
- export function viewStaff(m) {
42
+ /* The open dialog, if any. It lives on <body>, so it outlives a repaint of this screen, and a
43
+ background refresh leaves whatever was typed into it alone. */
44
+ let current = null;
45
+
46
+ /**
47
+ * @param {{open?: string, result?: object}} [o] `open` keeps one staff member's steps on
48
+ * screen across a repaint, which is how a finished hire lands on step 2.
49
+ */
50
+ export function viewStaff(m, o = {}) {
22
51
  m.append(el("h1", { textContent: "Staff" }));
23
- m.append(
24
- el("p", {
25
- className: "sub",
26
- textContent:
27
- S.data.staff.length +
28
- " on the roster. Hiring creates a repo, its workflows, its labels and its pinned issue, " +
29
- "and wires it to everyone already here.",
30
- }),
31
- );
52
+ const empty = !S.data.staff.length;
53
+ if (empty) m.append(el("p", { className: "sub", textContent: "No staff yet. Pick a role to hire your first." }));
54
+
55
+ const pane = el("div", { className: "hirepane" });
56
+
57
+ /* Tasks open in a dialog. Where there is no dialog element (the test shim) they draw into
58
+ the pane instead, which is how this screen always drew them. */
59
+ function present(node) {
60
+ const spin = el("div", { className: "sheetloading" }, [el("span", { className: "spinner" })]);
61
+ if (current?.isOpen()) current.set(spin);
62
+ else {
63
+ current = sheet({
64
+ node: spin,
65
+ onClose: () => {
66
+ current = null;
67
+ S.staffOpen = null;
68
+ },
69
+ });
70
+ if (!current) {
71
+ pane.replaceChildren(node);
72
+ return;
73
+ }
74
+ }
75
+ /* The list is built from half a dozen answers arriving one by one, and showing each as it
76
+ landed meant the dialog reflowed four times. It is shown once it has stopped changing. */
77
+ const mine = current;
78
+ settled(node).then(() => {
79
+ if (mine.isOpen() && current === mine) mine.set(node);
80
+ });
81
+ }
82
+ function dismiss() {
83
+ if (current) current.close();
84
+ else pane.replaceChildren();
85
+ S.staffOpen = null;
86
+ if (empty) pick();
87
+ }
32
88
 
33
- const list = el("div", { className: "grid", style: "margin-bottom:18px" });
34
- for (const s of S.data.staff) list.append(card(s));
35
- m.append(list);
89
+ // With nobody hired the roles are the page. After that they are a dialog behind Hire someone.
90
+ function pick() {
91
+ if (empty) {
92
+ pane.replaceChildren(rolePicker({ onPick: (role) => open(role) }));
93
+ return;
94
+ }
95
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
96
+ cancel.onclick = dismiss;
97
+ present(
98
+ el("div", {}, [
99
+ el("h2", { className: "sheettitle", textContent: "Pick a role" }),
100
+ rolePicker({ onPick: (role) => open(role) }),
101
+ el("div", { className: "row", style: "margin-top:14px" }, [cancel]),
102
+ ]),
103
+ );
104
+ }
36
105
 
37
- const hire = el("button", { className: "ghbtn primary", textContent: "Hire someone" });
38
- const pane = el("div");
39
- hire.onclick = () => hireForm(pane);
40
- m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [hire]));
106
+ /* One staff member's steps, hired or not yet. A hire repaints the whole screen, so the new
107
+ card is in the list and the steps reopen on the same person. */
108
+ function open(role, result) {
109
+ // Remembered, so a refresh after a trip to GitHub (creating the App) reopens it.
110
+ S.staffOpen = role;
111
+ present(
112
+ hireFlow({
113
+ role,
114
+ result,
115
+ onHired: (handle, r) => {
116
+ m.replaceChildren();
117
+ viewStaff(m, { open: handle, result: r });
118
+ },
119
+ onClose: dismiss,
120
+ }),
121
+ );
122
+ }
123
+
124
+ if (!empty) {
125
+ const list = el("div", { className: "grid", style: "margin-bottom:18px" });
126
+ for (const s of S.data.staff) list.append(card(s));
127
+ // In the grid, where the next person would go, rather than a button under it.
128
+ const hire = el("button", { className: "hirecard", textContent: "Hire someone" });
129
+ hire.onclick = pick;
130
+ list.append(hire);
131
+ m.append(list);
132
+ }
41
133
  m.append(pane);
42
134
 
135
+ const opened = o.open && S.data.staff.find((s) => s.handle === o.open);
136
+ const kept = !opened && S.staffOpen;
137
+ const keptStaff = kept && S.data.staff.find((s) => s.handle === kept.handle);
138
+ if (empty) pick();
139
+ if (opened) open(roleOf(opened), o.result);
140
+ // An open dialog is still open; only the inline fallback has to be drawn again.
141
+ else if (kept && !current?.isOpen()) open(keptStaff ? roleOf(keptStaff) : kept);
142
+
43
143
  /* --------------------------- one staff member --------------------------- */
44
144
 
145
+ /** " · works with Chief Marketing Officer". The labels and wiring behind it are roster's. */
146
+ function worksWith(s) {
147
+ const names = (s.peers ?? [])
148
+ .map((p) => S.data.staff.find((x) => x.handle === p.handle)?.name ?? p.handle)
149
+ .filter(Boolean);
150
+ return names.length ? " · works with " + esc(names.join(", ")) : "";
151
+ }
152
+
45
153
  function card(s) {
46
154
  const d = el("div", { className: "kv staffcard" });
47
155
  const rig = s.rig ?? {};
@@ -50,148 +158,40 @@ export function viewStaff(m) {
50
158
  '<div class="v">' + esc(s.name) +
51
159
  "<small>" + esc(s.brain ?? s.dir) + " · " + s.facts.length + " facts · " +
52
160
  (rig.lastCommit ? "last commit " + esc(ago(rig.lastCommit.date)) : "no commits") +
161
+ worksWith(s) +
53
162
  "</small></div>";
54
163
 
55
164
  const status = el("span", { className: "meta" });
56
165
  const go = el("button", { className: "ghbtn", textContent: "Retire" });
57
- go.onclick = () => retireForm(pane, s, status);
58
-
59
- /* The charter is the one file `hire` deliberately does not write, and until now the only
60
- route to it was a CLI command printing a brief. This is the same brief, with every file
61
- it refers to already inside it and somewhere to put the answer. */
62
- const write = el("button", { className: "ghbtn", textContent: "Write the charter" });
63
- write.onclick = () => {
64
- pane.replaceChildren(
65
- el("div", { className: "card" }, [
66
- paste({
67
- kind: "charter",
68
- staff: s.handle,
69
- title: "Write " + s.name + "'s charter with your own AI",
70
- onSaved: () => refreshAll(false),
71
- }),
72
- ]),
73
- );
74
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
75
- };
76
-
77
- /* The step `hire` has always had to hand back: there is no API that creates a GitHub App,
78
- so it is a browser hand-off either way. It may as well be this browser. */
79
- const app = el("button", { className: "ghbtn", textContent: "GitHub App" });
80
- app.onclick = () => {
81
- pane.replaceChildren(
82
- el("div", { className: "card" }, [
83
- el("h3", { textContent: s.name + "'s identity" }),
84
- appPanel({ staff: s.handle, name: s.name, scope: "private" }),
85
- appPanel({ staff: s.handle, name: s.name, scope: "public" }),
86
- ]),
87
- );
88
- pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
89
- };
90
-
91
- d.append(el("div", { className: "row", style: "margin-top:10px" }, [write, app, go, status]));
92
- return d;
93
- }
94
-
95
- /* ------------------------------- hiring -------------------------------- */
96
-
97
- function hireForm(host) {
98
- const box = el("div", { className: "card" });
99
- box.append(
100
- el("h3", { textContent: "Hire someone" }),
101
- el("p", {
102
- className: "sub",
103
- style: "margin-bottom:14px",
104
- textContent:
105
- "Only the handle is required. Everything else is copied from whoever is already " +
106
- "here, because app slugs carry a house naming scheme and the public identity is " +
107
- "genuinely shared.",
108
- }),
109
- );
110
-
111
- const handle = field("handle", "cfo", "lowercase, digits and dashes");
112
- const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
113
- const dir = field("dir", "finance", "directory and repo name; defaults to the handle");
114
- const schedule = scheduleField();
115
- for (const f of [handle, name, dir, schedule]) box.append(f.row);
116
-
117
- const status = el("span", { className: "meta" });
118
- const plan = el("button", { className: "ghbtn primary", textContent: "Show the plan" });
119
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
120
- cancel.onclick = () => host.replaceChildren();
121
- box.append(el("div", { className: "row", style: "margin-top:12px" }, [plan, cancel, status]));
122
-
123
- const out = el("div");
124
- box.append(out);
125
- host.replaceChildren(box);
126
- handle.input.focus();
127
-
128
- plan.onclick = async () => {
129
- if (!handle.input.value.trim()) {
130
- handle.input.focus();
131
- return;
132
- }
133
- status.textContent = "planning…";
134
- status.className = "meta";
135
- out.replaceChildren();
136
- const params = new URLSearchParams({ handle: handle.input.value.trim() });
137
- for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule]]) {
138
- const value = (f.value ? f.value() : f.input.value).trim();
139
- if (value) params.set(key, value);
140
- }
141
- try {
142
- const data = await (await fetch("/api/staff/plan?" + params, { cache: "no-store" })).json();
143
- if (data.error) throw new Error(data.error);
144
- status.textContent = "";
145
- out.replaceChildren(hirePlan(data.plan, params));
146
- } catch (e) {
147
- status.textContent = e.message;
148
- status.className = "meta err";
149
- }
166
+ go.onclick = () => {
167
+ const host = el("div");
168
+ present(host);
169
+ retireForm(host, s, status);
150
170
  };
151
- }
152
171
 
153
- function hirePlan(plan, params) {
154
- const box = el("div", { className: "plan" });
155
- box.append(el("div", { className: "planhead", textContent: "This would create" }));
156
-
157
- const s = plan.staff;
158
- box.append(
159
- line("ok", s.brain + ", private"),
160
- line("ok", plan.files.length + " files, including three caller workflows"),
161
- line("ok", plan.labels.length + " labels: " + plan.labels.join(", ")),
162
- line("ok", "a pinned status issue"),
163
- line("ok", "runs at " + s.schedule + ", on " + s.model),
172
+ /* Every setup step (the App, the charter, the credential, a run) is in the one list, so the
173
+ card has one way in. It says Finish setting up until setup looks finished: the charter
174
+ from disk at once, and the App's secrets and a first run from GitHub when it answers. */
175
+ const finish = el("button", { className: "ghbtn", textContent: "Set up" });
176
+ finish.onclick = () => open(roleOf(s));
177
+ const warn = el("p", { className: "warnline", hidden: true });
178
+ whatsLeft(s).then((left) => {
179
+ if (!left.length) return;
180
+ finish.textContent = "Finish setting up";
181
+ finish.className = "ghbtn primary";
182
+ d.classList.add("notready");
183
+ warn.hidden = false;
184
+ const p = staffProgress(s.handle);
185
+ warn.textContent =
186
+ "Not ready to run. " + (p ? `${p.done} of ${p.total} done. ` : "") +
187
+ "Still to do: " + sentence(left) + ".";
188
+ });
189
+ d.append(warn);
190
+
191
+ d.append(
192
+ el("div", { className: "row", style: "margin-top:10px" }, [finish, go, status]),
164
193
  );
165
- for (const p of plan.peers) {
166
- box.append(line("ok", p.dir + "/staff.yaml gains a peer entry and a " + p.label + " label"));
167
- }
168
- box.append(line("ok", "org.yaml gains a staff entry and a repo entry"));
169
-
170
- if (plan.secrets?.length) {
171
- box.append(el("div", { className: "planhead", textContent: "You will still have to" }));
172
- box.append(
173
- line("todo", "create the GitHub App: roster app " + s.handle),
174
- line("todo", "put " + plan.secrets.join(", ") + " on the new repo"),
175
- );
176
- }
177
- for (const w of plan.warnings ?? []) box.append(line("warn", w));
178
-
179
- const status = el("span", { className: "meta" });
180
- const go = el("button", { className: "ghbtn primary", textContent: "Hire " + s.handle });
181
- go.onclick = () =>
182
- apply(
183
- {
184
- action: "hire",
185
- handle: s.handle,
186
- flags: Object.fromEntries(params),
187
- },
188
- "Create " + s.brain + " and wire it up?\n\nThis creates a repository on GitHub.",
189
- go,
190
- status,
191
- box,
192
- );
193
- box.append(el("div", { className: "row", style: "margin-top:14px" }, [go, status]));
194
- return box;
194
+ return d;
195
195
  }
196
196
 
197
197
  /* ------------------------------- retiring ------------------------------- */
@@ -236,11 +236,11 @@ export function viewStaff(m) {
236
236
  const status = el("span", { className: "meta" });
237
237
  const go = el("button", { className: "ghbtn", textContent: "Retire " + plan.handle });
238
238
  const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
239
- cancel.onclick = () => host.replaceChildren();
239
+ cancel.onclick = dismiss;
240
240
  go.onclick = () =>
241
241
  apply(
242
242
  { action: "retire", handle: plan.handle },
243
- "Retire " + plan.name + "?\n\n" + (plan.brain ?? plan.dir) + " is not touched.",
243
+ { title: "Retire " + plan.name + "?", hint: (plan.brain ?? plan.dir) + " is not touched.", confirm: "Retire " + plan.handle },
244
244
  go,
245
245
  status,
246
246
  box,
@@ -252,7 +252,7 @@ export function viewStaff(m) {
252
252
  /* -------------------------------- apply -------------------------------- */
253
253
 
254
254
  async function apply(payload, ask, btn, status, box) {
255
- if (!confirm(ask)) return;
255
+ if (!(await askYes(ask))) return;
256
256
  btn.disabled = true;
257
257
  status.textContent = "working…";
258
258
  status.className = "meta";
@@ -271,80 +271,7 @@ export function viewStaff(m) {
271
271
  }
272
272
  }
273
273
 
274
- function field(label, placeholder, hint) {
275
- const input = el("input", { type: "search", placeholder, value: "", style: "width:100%" });
276
- input.dataset.field = label;
277
- const row = el("div", { className: "ffield" });
278
- row.append(
279
- el("label", { textContent: label }),
280
- input,
281
- el("small", { textContent: hint }),
282
- );
283
- return { row, input };
284
- }
285
-
286
- /**
287
- * When the daily run happens, as a time and a set of days rather than as cron.
288
- *
289
- * The value on the wire is still a cron expression, because that is what goes in the workflow
290
- * and what `roster hire` takes. But nobody hiring their first staff member knows that
291
- * `0 9 * * 1-5` is nine in the morning on weekdays, and a field that demands it is a field
292
- * that gets a wrong answer or an empty one.
293
- *
294
- * UTC, said out loud, with the local equivalent beside it: GitHub schedules in UTC, and an
295
- * agent that starts an hour off twice a year is worse than one you had to think about once.
296
- */
297
- function scheduleField() {
298
- const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
299
- const days = el("select");
300
- days.append(
301
- el("option", { value: "1-5", textContent: "Weekdays" }),
302
- el("option", { value: "*", textContent: "Every day" }),
303
- el("option", { value: "1", textContent: "Mondays" }),
304
- el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
305
- );
306
- // Said rather than relying on "the first option is selected", which is true of a rendered
307
- // <select> and not of one that has only been built.
308
- days.value = "1-5";
309
-
310
- const said = el("small");
311
- const value = () => {
312
- if (!time.value) return "";
313
- const [h, m] = time.value.split(":");
314
- return `${Number(m)} ${Number(h)} * * ${days.value}`;
315
- };
316
- const say = () => {
317
- const cron = value();
318
- said.textContent = cron
319
- ? cronText(cron) + local(time.value)
320
- : "Leave it empty for a slot clear of everyone else.";
321
- };
322
- time.oninput = say;
323
- days.onchange = say;
324
- say();
325
-
326
- const row = el("div", { className: "ffield" });
327
- row.append(
328
- el("label", { textContent: "schedule" }),
329
- el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
330
- said,
331
- );
332
- return { row, input: time, value };
333
- }
334
-
335
- /** What that UTC time is where the person reading it is, when the two differ. */
336
- function local(hhmm) {
337
- if (!hhmm) return "";
338
- const [h, m] = hhmm.split(":").map(Number);
339
- const at = new Date(Date.UTC(2026, 0, 5, h, m));
340
- const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
341
- return here === hhmm ? "" : " · " + here + " where you are";
342
- }
343
-
344
- const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
345
-
346
- function line(kind, text) {
347
- const d = el("div", { className: "planline " + kind });
348
- d.append(icon(MARK[kind] ?? "dot", "ic"), el("span", { textContent: text }));
349
- return d;
274
+ /** The flow's view of somebody already hired. */
275
+ function roleOf(s) {
276
+ return { handle: s.handle, name: s.name, dir: s.dir };
350
277
  }
@@ -0,0 +1,62 @@
1
+ /* The numbered step card shared by Getting started and the hiring flow on the Staff screen. */
2
+
3
+ import { el } from "../dom.js";
4
+
5
+ /**
6
+ * One numbered thing to do. Done ones fold to a single ticked line, and Change opens them again,
7
+ * so the list shows what is left without hiding what was done.
8
+ *
9
+ * `setLocked(reason)` greys a step that cannot start yet and says why in the pill; `null`
10
+ * unlocks it. A locked step shows no body, because a form you cannot use yet is a trap.
11
+ */
12
+ export function todo(n, title, done) {
13
+ const card = el("section", { className: "todo" });
14
+ const num = el("span", { className: "todon" });
15
+ const pill = el("span", { className: "todopill" });
16
+ const change = el("button", { className: "ghbtn todochange", textContent: "Change" });
17
+ const head = el("div", { className: "todohead" }, [num, el("h3", { textContent: title }), pill, change]);
18
+ const hintEl = el("p", { className: "sub todohint" });
19
+ const body = el("div", { className: "todobody" });
20
+ card.append(head, hintEl, body);
21
+ let open = false;
22
+ let locked = null;
23
+ let isDone = done;
24
+ const paint = () => {
25
+ card.classList.toggle("done", isDone && !locked);
26
+ card.classList.toggle("locked", Boolean(locked));
27
+ num.textContent = isDone && !locked ? "✓" : String(n);
28
+ pill.textContent = locked ?? (isDone ? "Done" : "To do");
29
+ change.hidden = !isDone || Boolean(locked);
30
+ const shown = !locked && (!isDone || open);
31
+ body.hidden = !shown;
32
+ hintEl.hidden = !shown || !hintEl.textContent;
33
+ };
34
+ const setDone = (d) => {
35
+ isDone = Boolean(d);
36
+ paint();
37
+ };
38
+ change.onclick = () => {
39
+ open = !open;
40
+ change.textContent = open ? "Close" : "Change";
41
+ paint();
42
+ };
43
+ paint();
44
+ return {
45
+ card,
46
+ body,
47
+ setDone,
48
+ isDone: () => isDone,
49
+ setLocked: (reason) => {
50
+ locked = reason || null;
51
+ paint();
52
+ },
53
+ renumber: (m) => {
54
+ n = m;
55
+ paint();
56
+ },
57
+ hint: (text) => {
58
+ hintEl.textContent = text;
59
+ paint();
60
+ },
61
+ };
62
+ }