@nanocollective/roster 0.1.0-alpha.1

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/cli.js +5679 -0
  4. package/docs/README.md +99 -0
  5. package/docs/agents.md +163 -0
  6. package/docs/architecture.md +121 -0
  7. package/docs/commands.md +223 -0
  8. package/docs/concepts.md +112 -0
  9. package/docs/cost.md +61 -0
  10. package/docs/developing.md +147 -0
  11. package/docs/doctor-codes.md +74 -0
  12. package/docs/export.md +113 -0
  13. package/docs/extending.md +97 -0
  14. package/docs/getting-started.md +134 -0
  15. package/docs/hosting.md +72 -0
  16. package/docs/manual-steps.md +163 -0
  17. package/docs/memory.md +71 -0
  18. package/docs/org-yaml.md +143 -0
  19. package/docs/portal.md +342 -0
  20. package/docs/prompts.md +133 -0
  21. package/docs/security.md +122 -0
  22. package/docs/session-workflow.md +112 -0
  23. package/docs/staff-yaml.md +163 -0
  24. package/docs/troubleshooting.md +189 -0
  25. package/docs/upgrading.md +83 -0
  26. package/docs/writing-a-charter.md +83 -0
  27. package/package.json +60 -0
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
  30. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
  31. package/templates/brain/CHARTER.md +49 -0
  32. package/templates/brain/README.md +18 -0
  33. package/templates/brain/drafts/README.md +7 -0
  34. package/templates/brain/log/decisions.md +6 -0
  35. package/templates/brain/memory/INDEX.md +28 -0
  36. package/templates/brain/staff.yaml +44 -0
  37. package/templates/brain/strategy/README.md +7 -0
  38. package/templates/briefs/amend.md +60 -0
  39. package/templates/briefs/charter.md +47 -0
  40. package/templates/briefs/discover.md +61 -0
  41. package/templates/briefs/voice.md +53 -0
  42. package/templates/ops/.github/workflows/session.yaml +333 -0
  43. package/templates/ops/agents.mjs +143 -0
  44. package/templates/ops/compose.mjs +333 -0
  45. package/templates/ops/org/guardrails.md +14 -0
  46. package/templates/ops/org/operating.md +82 -0
  47. package/templates/ops/org/voice.md +40 -0
  48. package/templates/ops/prompts/_identity.md +14 -0
  49. package/templates/ops/prompts/_paths.md +15 -0
  50. package/templates/ops/prompts/daily.md +82 -0
  51. package/templates/ops/prompts/mention.md +53 -0
  52. package/templates/ops/prompts/pr-mention.md +57 -0
  53. package/templates/ops/runner-plan.mjs +65 -0
  54. package/templates/portal/css/base.css +104 -0
  55. package/templates/portal/css/brain.css +106 -0
  56. package/templates/portal/css/diff.css +28 -0
  57. package/templates/portal/css/graph.css +34 -0
  58. package/templates/portal/css/health.css +41 -0
  59. package/templates/portal/css/inbox.css +79 -0
  60. package/templates/portal/css/layout.css +98 -0
  61. package/templates/portal/css/markdown.css +54 -0
  62. package/templates/portal/css/setup.css +106 -0
  63. package/templates/portal/index.html +55 -0
  64. package/templates/portal/js/api.js +74 -0
  65. package/templates/portal/js/app.js +282 -0
  66. package/templates/portal/js/dialog.js +70 -0
  67. package/templates/portal/js/dom.js +106 -0
  68. package/templates/portal/js/icons.js +94 -0
  69. package/templates/portal/js/md.js +386 -0
  70. package/templates/portal/js/refresh.js +59 -0
  71. package/templates/portal/js/router.js +20 -0
  72. package/templates/portal/js/state.js +160 -0
  73. package/templates/portal/js/textdiff.js +96 -0
  74. package/templates/portal/js/views/app.js +128 -0
  75. package/templates/portal/js/views/brain.js +260 -0
  76. package/templates/portal/js/views/changed.js +157 -0
  77. package/templates/portal/js/views/checklist.js +87 -0
  78. package/templates/portal/js/views/docs.js +84 -0
  79. package/templates/portal/js/views/files.js +95 -0
  80. package/templates/portal/js/views/graph.js +436 -0
  81. package/templates/portal/js/views/health.js +158 -0
  82. package/templates/portal/js/views/inbox.js +549 -0
  83. package/templates/portal/js/views/memory.js +135 -0
  84. package/templates/portal/js/views/org.js +175 -0
  85. package/templates/portal/js/views/paste.js +142 -0
  86. package/templates/portal/js/views/prompt.js +412 -0
  87. package/templates/portal/js/views/repos.js +92 -0
  88. package/templates/portal/js/views/setup.js +344 -0
  89. package/templates/portal/js/views/staff.js +290 -0
@@ -0,0 +1,54 @@
1
+ /* One renderer, two sizes: .cbody is a comment in a thread, .doc is a file being read.
2
+ Everything shared lives on .md so a fix reaches both. */
3
+
4
+ .md{line-height:1.65}
5
+ .md>:first-child{margin-top:0}
6
+ .md p{margin:0 0 10px}
7
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{color:var(--ink);font-weight:600;line-height:1.3;
8
+ margin:22px 0 8px;letter-spacing:-.012em}
9
+ .md h1{font-size:20px} .md h2{font-size:17px} .md h3{font-size:15px}
10
+ .md h4,.md h5,.md h6{font-size:13.5px;color:var(--ink-dim)}
11
+ .md h1,.md h2{padding-bottom:6px;border-bottom:1px solid var(--line)}
12
+ .md ul,.md ol{margin:0 0 10px;padding-left:20px}
13
+ .md li{margin:3px 0}
14
+ .md li>ul,.md li>ol{margin:4px 0 0}
15
+ .md ul.tasks{list-style:none;padding-left:1px}
16
+ .md .box{color:var(--ink-faint);margin-right:6px;display:inline-flex;--ic:14px;
17
+ vertical-align:-3px}
18
+ .md code{font:12px var(--mono);color:var(--accent);background:var(--panel-2);
19
+ padding:1px 4px;border-radius:4px;word-break:break-word}
20
+ .md pre{background:var(--panel-2);border:1px solid var(--line);border-radius:7px;
21
+ padding:11px 13px;font:12px/1.62 var(--mono);margin:10px 0;color:var(--ink-dim);
22
+ overflow-x:auto;white-space:pre}
23
+ .md pre code{background:none;padding:0;color:inherit;font-size:inherit}
24
+ .md a{color:var(--will)}
25
+ .md blockquote{margin:10px 0;padding:2px 0 2px 12px;border-left:2px solid var(--line);color:var(--ink-dim)}
26
+ .md hr{border:0;border-top:1px solid var(--line);margin:16px 0}
27
+ .md img{max-width:100%;border-radius:6px;background:var(--panel-2)}
28
+ .md .ctable{overflow-x:auto;margin:10px 0;max-width:100%}
29
+ .md table{border-collapse:collapse;font-size:12.5px;width:100%}
30
+ .md th,.md td{border:1px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top}
31
+ .md th{background:var(--panel-2);color:var(--ink);font-weight:600;white-space:nowrap}
32
+ .md td{color:var(--ink-dim)}
33
+ .md td b,.md td strong{color:var(--ink)}
34
+ .cbody{margin-top:8px;font-size:13.5px;max-width:74ch}
35
+ .cbody h1{font-size:17px} .cbody h2{font-size:15.5px} .cbody h3{font-size:14px}
36
+ .doc{font-size:14px;max-width:80ch}
37
+
38
+ /* A reference to another issue or PR, as a chip rather than a raw link: half of a status
39
+ issue is #113, and a wall of blue URLs is the thing that makes them unreadable. */
40
+ .ref{display:inline-block;font:11.5px var(--mono);color:var(--will);background:var(--panel-2);
41
+ border:1px solid var(--line);border-radius:20px;padding:0 7px;text-decoration:none;
42
+ white-space:nowrap;line-height:1.65}
43
+ .ref:hover{border-color:var(--will);background:color-mix(in srgb,var(--will) 14%,transparent)}
44
+ .ref.pr{color:var(--measured)}
45
+ .ref.pr:hover{border-color:var(--measured);background:color-mix(in srgb,var(--measured) 14%,transparent)}
46
+
47
+ /* @cto and @you are how work is handed between people here, so they are marked
48
+ rather than left as prose. `.you` is anyone on this org's roster; a stranger stays plain. */
49
+ .at{display:inline-block;font:11.5px var(--mono);color:var(--ink-dim);background:var(--panel-2);
50
+ border:1px solid var(--line);border-radius:20px;padding:0 7px;text-decoration:none;
51
+ white-space:nowrap;line-height:1.65}
52
+ .at:hover{color:var(--ink);border-color:var(--ink-faint)}
53
+ .at.you{color:var(--derived);border-color:color-mix(in srgb,var(--derived) 40%,transparent)}
54
+ .at.you:hover{background:color-mix(in srgb,var(--derived) 14%,transparent);border-color:var(--derived)}
@@ -0,0 +1,106 @@
1
+ /* The setup screen, and the copy-a-prompt panel it shares with the authoring screens. */
2
+
3
+ .step{background:var(--panel);border:1px solid var(--line);border-radius:10px;
4
+ padding:18px 20px 20px;margin-bottom:14px}
5
+ .step.done{border-color:var(--accent-dim)}
6
+ .stephead{display:flex;align-items:center;gap:11px;margin-bottom:10px}
7
+ .stephead h2{margin:0;font:600 16px var(--sans);letter-spacing:-.01em}
8
+ .stepn{flex:none;width:23px;height:23px;border-radius:50%;display:grid;place-items:center;
9
+ background:var(--panel-2);border:1px solid var(--line);color:var(--ink-faint);
10
+ font:600 11px var(--mono)}
11
+ .step.done .stepn{background:var(--accent-dim);border-color:var(--accent-dim);color:var(--bg)}
12
+
13
+ /* ---------- the form ---------- */
14
+
15
+ .setupform{display:grid;gap:13px;margin:14px 0 18px;max-width:520px}
16
+ .field{display:grid;gap:4px}
17
+ .field>span{font:600 12px var(--sans);color:var(--ink-dim)}
18
+ .field input,.field select{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);
19
+ border-radius:7px;padding:8px 11px;font:13px var(--sans);outline:none;width:100%}
20
+ .field input:focus,.field select:focus{border-color:var(--accent-dim)}
21
+ .field small{color:var(--ink-faint);font-size:11.5px;line-height:1.45}
22
+
23
+ .choices{display:grid;gap:8px;margin:10px 0 4px;max-width:640px}
24
+ .choice{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;align-items:start;
25
+ background:var(--panel-2);border:1px solid var(--line);border-radius:8px;
26
+ padding:11px 13px;cursor:pointer}
27
+ .choice:has(input:checked){border-color:var(--accent-dim)}
28
+ .choice input{grid-row:1/3;margin-top:3px;accent-color:var(--accent)}
29
+ .choice b{font:600 13px var(--sans)}
30
+ .choice small{color:var(--ink-dim);font-size:12px;line-height:1.45}
31
+ .choice code{grid-column:2;font:10.5px var(--mono);color:var(--ink-faint)}
32
+
33
+ .row{display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-top:6px}
34
+ .btn{background:var(--panel-2);border:1px solid var(--line);color:var(--ink);border-radius:7px;
35
+ padding:8px 13px;font:13px var(--sans);cursor:pointer;text-decoration:none;display:inline-block}
36
+ .btn:hover{border-color:var(--accent-dim)}
37
+ .btn.primary{background:var(--accent-dim);border-color:var(--accent-dim);color:var(--bg);font-weight:600}
38
+ .btn[disabled]{opacity:.5;cursor:default}
39
+
40
+ .cmd{background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:10px 12px;
41
+ font:12px/1.6 var(--mono);color:var(--ink);overflow-x:auto;white-space:pre-wrap;
42
+ word-break:break-word;margin:9px 0}
43
+
44
+ .planout{margin-top:12px}
45
+ .filelist{display:grid;gap:2px;margin:9px 0;max-height:240px;overflow-y:auto;
46
+ background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:10px 12px}
47
+ .filelist code{font:11px var(--mono);color:var(--ink-dim)}
48
+
49
+ .manual{background:var(--panel-2);border:1px solid var(--line);border-radius:8px;
50
+ padding:13px 15px;margin:11px 0}
51
+ .manual b{display:block;font:600 13px var(--sans);margin-bottom:4px}
52
+ .manual p{margin:0 0 6px;font-size:13px}
53
+ .manual small{display:block;color:var(--ink-faint);font-size:11.5px;line-height:1.5;margin-bottom:9px}
54
+
55
+ /* ---------- copy a prompt, paste the answer back ---------- */
56
+
57
+ .paste{border-top:1px solid var(--line);margin-top:16px;padding-top:15px}
58
+ .paste h3{margin:0 0 5px;font:600 14px var(--sans)}
59
+ .briefpreview{background:var(--panel-2);border:1px solid var(--line);border-radius:7px;
60
+ padding:12px 14px;font:11.5px/1.6 var(--mono);max-height:340px;overflow:auto;
61
+ white-space:pre-wrap;word-break:break-word;margin:10px 0}
62
+ .pastebox{width:100%;background:var(--panel-2);border:1px solid var(--line);color:var(--ink);
63
+ border-radius:7px;padding:10px 12px;font:12px/1.6 var(--mono);outline:none;resize:vertical;
64
+ margin:4px 0}
65
+ .pastebox:focus{border-color:var(--accent-dim)}
66
+
67
+ .pasteresult{margin-top:12px}
68
+ /* A model that replied in prose is the common case and not the person's mistake, so this reads
69
+ as a next step rather than as a failure. */
70
+ .problem{background:var(--panel-2);border:1px solid var(--warn);border-radius:8px;
71
+ padding:12px 14px;margin-bottom:10px}
72
+ .problem b{display:block;font:600 13px var(--sans);margin-bottom:7px}
73
+
74
+ .pastefile{background:var(--panel-2);border:1px solid var(--line);border-radius:8px;
75
+ padding:12px 14px;margin-bottom:10px}
76
+ .pastefile.saved{border-color:var(--accent-dim)}
77
+ .pastefile b{font:600 13px var(--mono)}
78
+ .pastefile .meta{margin-left:9px}
79
+ .pastefile .diff{max-height:300px;overflow:auto;margin:9px 0}
80
+
81
+ /* ---------- the doctor checklist ---------- */
82
+
83
+ .check{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;align-items:start;
84
+ padding:10px 0;border-top:1px solid var(--line)}
85
+ .check:first-child{border-top:none}
86
+ .checkmark{font:600 13px var(--mono);line-height:1.5}
87
+ .check.fail .checkmark{color:var(--err)}
88
+ .check.warn .checkmark{color:var(--warn)}
89
+ .check b{font:600 13px var(--sans);margin-right:8px}
90
+ .check p{margin:3px 0 0;font-size:12.5px;color:var(--ink-dim);line-height:1.5}
91
+
92
+ /* ---------- the repo picker ---------- */
93
+
94
+ .repolist{display:grid;gap:1px;max-height:280px;overflow-y:auto;
95
+ border:1px solid var(--line);border-radius:7px;background:var(--line)}
96
+ .repo{display:flex;align-items:center;justify-content:space-between;gap:12px;
97
+ background:var(--panel);padding:9px 12px}
98
+ .repo.added{background:var(--panel-2)}
99
+ .repo b{font:600 12.5px var(--mono)}
100
+ .repo .meta{margin-left:8px}
101
+ .repo .sub{margin:2px 0 0;font-size:11.5px}
102
+
103
+ /* Which of the two things this is: a new org, or one that already runs roster. */
104
+ .verdict{background:var(--panel-2);border:1px solid var(--line);border-radius:7px;
105
+ padding:9px 12px;font-size:12.5px}
106
+ .verdict p{margin:0}
@@ -0,0 +1,55 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>roster</title>
7
+ <!-- Inline, so a local page with no network still has a tab icon and the console stays clean. -->
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%237cc7a4'/%3E%3C/svg%3E">
9
+ <!-- Stylesheets and modules are served from /assets by `roster portal`. No build step:
10
+ editing one of these files and reloading is the whole edit loop. -->
11
+ <link rel="stylesheet" href="/assets/css/base.css">
12
+ <link rel="stylesheet" href="/assets/css/layout.css">
13
+ <link rel="stylesheet" href="/assets/css/markdown.css">
14
+ <link rel="stylesheet" href="/assets/css/brain.css">
15
+ <link rel="stylesheet" href="/assets/css/inbox.css">
16
+ <link rel="stylesheet" href="/assets/css/graph.css">
17
+ <link rel="stylesheet" href="/assets/css/diff.css">
18
+ <link rel="stylesheet" href="/assets/css/health.css">
19
+ <link rel="stylesheet" href="/assets/css/setup.css">
20
+ </head>
21
+ <body>
22
+ <div class="app">
23
+ <aside class="side">
24
+ <div class="brand">roster<small id="orgname">loading…</small></div>
25
+ <button class="refreshall" id="refreshall" title="Reload everything from disk (r)">
26
+ <span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
27
+ </button>
28
+
29
+ <button class="nav top" id="inboxnav" data-view="inbox">
30
+ <span class="ic" data-icon="inbox"></span> Inbox
31
+ <span class="n" id="inboxcount"></span>
32
+ </button>
33
+ <button class="nav" id="orgnav" data-view="org">
34
+ <span class="ic" data-icon="file"></span> Org
35
+ </button>
36
+ <button class="nav" id="staffnav" data-view="staff">
37
+ <span class="ic" data-icon="person"></span> Staff
38
+ </button>
39
+ <button class="nav" id="docsnav" data-view="docs">
40
+ <span class="ic" data-icon="docs"></span> Docs
41
+ </button>
42
+
43
+ <div class="sect">Staff</div>
44
+ <div id="stafflist"></div>
45
+
46
+ <div class="sidefoot">
47
+ <button class="nav" id="theme">Theme</button>
48
+ </div>
49
+ </aside>
50
+ <main id="main"><p class="sub">Loading…</p></main>
51
+ </div>
52
+
53
+ <script type="module" src="/assets/js/app.js"></script>
54
+ </body>
55
+ </html>
@@ -0,0 +1,74 @@
1
+ /* Everything the page asks the local server for, in one place. */
2
+
3
+ const json = async (url) => (await fetch(url, { cache: "no-store" })).json();
4
+
5
+ export const getOrg = () => json("/api/org");
6
+ export const getDocs = () => json("/api/docs");
7
+ export const getInbox = (force) => json("/api/inbox" + (force ? "?refresh=1" : ""));
8
+ export const getSync = () => json("/api/sync").catch(() => null);
9
+
10
+ export const getDoc = (page) =>
11
+ fetch("/api/doc?page=" + encodeURIComponent(page), { cache: "no-store" }).then((r) => r.text());
12
+
13
+ export const fileUrl = (path) => "/api/file?path=" + encodeURIComponent(path);
14
+ export const getFile = (path) => fetch(fileUrl(path)).then((r) => r.text());
15
+
16
+ export const getThread = (repo, number, kind) =>
17
+ json(
18
+ "/api/thread?repo=" + encodeURIComponent(repo) + "&number=" + number + "&kind=" + kind,
19
+ );
20
+
21
+ export const diffUrl = (dir, sha, path) =>
22
+ "/api/diff?dir=" + encodeURIComponent(dir) + "&sha=" + encodeURIComponent(sha) +
23
+ (path ? "&path=" + encodeURIComponent(path) : "");
24
+
25
+ /* Writes need a POST with a custom header, which is what stops a random page in the
26
+ browser from reaching this server. */
27
+ export async function post(payload, url = "/api/act") {
28
+ const res = await fetch(url, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json", "x-roster": "1" },
31
+ body: JSON.stringify(payload),
32
+ });
33
+ const data = await res.json().catch(() => ({}));
34
+ if (!res.ok || data.error) throw new Error(data.error || "failed with " + res.status);
35
+ return data;
36
+ }
37
+
38
+ /* ------------------------------- setup and authoring ------------------------------- */
39
+
40
+ export const getSetup = () => json("/api/setup/status");
41
+
42
+ export const planTenant = (params) =>
43
+ json("/api/setup/plan?" + new URLSearchParams(params).toString());
44
+
45
+ export const createTenant = (params) => post(params, "/api/setup/apply");
46
+
47
+ /** The copyable prompt, with every file it refers to carried inside it. */
48
+ export const getBrief = (kind, staff) =>
49
+ json("/api/brief?kind=" + encodeURIComponent(kind) + (staff ? "&staff=" + encodeURIComponent(staff) : ""));
50
+
51
+ /** Parse what came back from the model. Reads only: saving is a second, deliberate step. */
52
+ export const parsePaste = (kind, staff, answer) =>
53
+ fetch("/api/paste", {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify({ kind, staff, answer }),
57
+ }).then((r) => r.json());
58
+
59
+ export const saveFile = (path, text, message) => post({ path, text, message }, "/api/save");
60
+
61
+ /** The scan every checklist is derived from. */
62
+ export const getDoctor = (offline) => json("/api/doctor" + (offline ? "?offline=1" : ""));
63
+
64
+ /** Those findings as one brief for a coding agent, plus the split into who can do what. */
65
+ export const getFix = (offline) => json("/api/fix" + (offline ? "?offline=1" : ""));
66
+
67
+ export const listRepos = (org) => json("/api/setup/repos?org=" + encodeURIComponent(org));
68
+ export const addRepo = (name, role) => post({ name, role }, "/api/setup/add-repo");
69
+ export const startApp = (staff, scope) => post({ staff, scope }, "/api/setup/app");
70
+ export const appResult = (state) => json("/api/setup/app-result?state=" + encodeURIComponent(state));
71
+
72
+ /** Does this org already run roster. "Create" and "join" are different answers. */
73
+ export const checkOrg = (org) => json("/api/setup/check-org?org=" + encodeURIComponent(org));
74
+ export const joinOrg = (org) => post({ org }, "/api/setup/join");
@@ -0,0 +1,282 @@
1
+ /* The shell: boot, the sidebar, and dispatching a view into <main>. */
2
+
3
+ import { getOrg } from "./api.js";
4
+ import { $, el, store } from "./dom.js";
5
+ import { icon, iconHTML } from "./icons.js";
6
+ import { setPeople } from "./md.js";
7
+ import { refreshAll, stampLoaded, syncNotice } from "./refresh.js";
8
+ import { onRender } from "./router.js";
9
+ import { ORG_WIDE, S, VIEWS, applyHash, openCount, writeHash } from "./state.js";
10
+ import { viewBrain } from "./views/brain.js";
11
+ import { viewChanged } from "./views/changed.js";
12
+ import { viewDocs } from "./views/docs.js";
13
+ import { viewGraph } from "./views/graph.js";
14
+ import { viewHealth } from "./views/health.js";
15
+ import { viewInbox } from "./views/inbox.js";
16
+ import { viewOrg } from "./views/org.js";
17
+ import { viewPrompt } from "./views/prompt.js";
18
+ import { viewSetup } from "./views/setup.js";
19
+ import { viewStaff } from "./views/staff.js";
20
+
21
+ const SCREEN = {
22
+ inbox: viewInbox,
23
+ org: viewOrg,
24
+ staff: viewStaff,
25
+ docs: viewDocs,
26
+ brain: viewBrain,
27
+ memory: viewBrain,
28
+ prompt: viewPrompt,
29
+ graph: viewGraph,
30
+ changed: viewChanged,
31
+ health: viewHealth,
32
+ };
33
+
34
+ /**
35
+ * Fields of the org export this page cannot render without.
36
+ *
37
+ * The portal reads its stylesheets and modules per request, so editing one and reloading
38
+ * works. Its *server* is loaded once, at start. A portal left running across an upgrade
39
+ * therefore serves new assets against an old API, and the first sign of it was an Org screen
40
+ * asking for `undefined/org.yaml`. A missing field is version skew, and skew should say so.
41
+ */
42
+ const NEEDS = ["org", "name", "opsName", "staff"];
43
+
44
+ export async function boot() {
45
+ initTheme();
46
+
47
+ /* Before there is a tenant the server answers every data route with `mode: setup`. That is
48
+ not an error state: it is the first thing a new user ever sees, and the whole page becomes
49
+ the thing that fixes it. */
50
+ const first = await getOrg();
51
+ if (first && first.mode === "setup") {
52
+ document.body.dataset.setup = "1";
53
+ $("#orgname").textContent = "no org yet";
54
+ for (const slot of document.querySelectorAll("[data-icon]")) {
55
+ slot.innerHTML = iconHTML(slot.dataset.icon);
56
+ }
57
+ await viewSetup($("#main"));
58
+ return;
59
+ }
60
+
61
+ S.data = first;
62
+ S.loadedAt = new Date();
63
+ S.staffHandle = S.data.staff[0]?.handle ?? null;
64
+ $("#orgname").textContent = S.data.name + " · " + S.data.staff.length + " staff";
65
+
66
+ learnPeople();
67
+ // The shell's own icons are markup, so they are filled in once the module is running
68
+ // rather than pasted into index.html as inline SVG nobody would ever read.
69
+ for (const slot of document.querySelectorAll("[data-icon]")) {
70
+ slot.innerHTML = iconHTML(slot.dataset.icon);
71
+ }
72
+ onRender(render);
73
+ applyHash();
74
+ paintSidebar();
75
+
76
+ $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
77
+ $("#staffnav").onclick = () => { S.view = "staff"; render(); };
78
+ $("#orgnav").onclick = () => { S.view = "org"; render(); };
79
+ $("#docsnav").onclick = () => { S.view = "docs"; render(); };
80
+ $("#refreshall").onclick = () => refreshAll(false);
81
+
82
+ // Coming back to the tab after a run should show the run.
83
+ addEventListener("focus", () => {
84
+ if (S.loadedAt && Date.now() - S.loadedAt > 30000) refreshAll(false);
85
+ });
86
+ addEventListener("keydown", (e) => {
87
+ if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
88
+ !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName ?? "")) {
89
+ e.preventDefault();
90
+ refreshAll(false);
91
+ }
92
+ });
93
+ setInterval(stampLoaded, 30000);
94
+
95
+ // Only the state changes here. This used to re-run half of boot on every navigation,
96
+ // which stacked up a focus listener, a keydown listener and an interval each time.
97
+ addEventListener("hashchange", () => {
98
+ S.applyingHash = true;
99
+ applyHash();
100
+ S.applyingHash = false;
101
+ render();
102
+ });
103
+
104
+ render();
105
+ }
106
+
107
+ /** Say it plainly, rather than rendering half a page built on values that are not there. */
108
+ function stale(missing) {
109
+ const m = $("#main");
110
+ m.replaceChildren();
111
+ const box = el("div", { className: "notice" });
112
+ box.innerHTML =
113
+ "<b>This page is newer than the portal serving it.</b><br>" +
114
+ "The stylesheets and modules are read per request, but the server is not: it was loaded " +
115
+ "when <code>roster portal</code> started. Stop it and start it again.<br><br>" +
116
+ "<span class=\"meta\">/api/org is not sending: " + missing.join(", ") + "</span>";
117
+ m.append(box);
118
+ $("#orgname").textContent = "restart needed";
119
+ }
120
+
121
+ /* Who `@cto` and `@you` are, so a mention in a thread can point somewhere useful
122
+ instead of at github.com/cto, which is nobody.
123
+ A staff mention goes to their repo on GitHub. It used to open their Brain screen here, which
124
+ read as the page hijacking a click: you are in the middle of a thread, and a mention is
125
+ about the work, not about their memory. */
126
+ function learnPeople() {
127
+ const people = new Map();
128
+ for (const s of S.data.staff) {
129
+ if (!s.brain) continue;
130
+ const to = { href: "https://github.com/" + s.brain, title: s.name + " · " + s.brain };
131
+ people.set(String(s.handle).toLowerCase(), to);
132
+ if (s.mention) people.set(String(s.mention).replace(/^@/, "").toLowerCase(), to);
133
+ for (const b of s.bots ?? []) people.set(String(b).toLowerCase(), to);
134
+ }
135
+ const human = S.data.human ?? {};
136
+ if (human.github) {
137
+ people.set(String(human.github).toLowerCase(), {
138
+ href: "https://github.com/" + human.github,
139
+ title: human.name ?? human.github,
140
+ });
141
+ }
142
+ setPeople(people);
143
+ }
144
+
145
+ /* ------------------------------- the sidebar ------------------------------ */
146
+
147
+ /**
148
+ * Staff are rows that unfold into their own views.
149
+ *
150
+ * This used to be a dropdown, then the chosen name repeated underneath it, then four
151
+ * unlabelled views — three pieces you had to assemble into "these belong to that person".
152
+ * Nesting says it instead.
153
+ */
154
+ let paintedRoster = null;
155
+
156
+ function paintSidebar() {
157
+ const host = $("#stafflist");
158
+ host.replaceChildren();
159
+ paintedRoster = S.data.staff.map((s) => s.handle).join(" ");
160
+
161
+ for (const s of S.data.staff) {
162
+ const open = s.handle === S.staffHandle;
163
+
164
+ const head = el("button", { className: "nav staffrow" });
165
+ head.dataset.staff = s.handle;
166
+ head.setAttribute("aria-expanded", String(open));
167
+ head.append(
168
+ icon("chevron", "caret"),
169
+ el("span", { className: "t", textContent: s.name }),
170
+ el("span", { className: "hh", textContent: s.handle }),
171
+ );
172
+
173
+ const views = el("div", { className: "staffviews" });
174
+ views.hidden = !open;
175
+ for (const [id, label] of VIEWS) {
176
+ const b = el("button", { className: "nav subnav", textContent: label });
177
+ b.dataset.view = id;
178
+ b.dataset.staff = s.handle;
179
+ b.onclick = () => {
180
+ if (S.staffHandle !== s.handle) S.openFile = null;
181
+ S.staffHandle = s.handle;
182
+ S.view = id;
183
+ render();
184
+ };
185
+ views.append(b);
186
+ }
187
+
188
+ head.onclick = () => {
189
+ // Clicking the staff member you are already on folds them away; clicking another
190
+ // switches to them and keeps whichever view you were reading.
191
+ if (S.staffHandle === s.handle && !views.hidden) {
192
+ views.hidden = true;
193
+ head.setAttribute("aria-expanded", "false");
194
+ return;
195
+ }
196
+ S.staffHandle = s.handle;
197
+ S.openFile = null;
198
+ if (ORG_WIDE.has(S.view)) S.view = "brain";
199
+ render();
200
+ };
201
+
202
+ host.append(head, views);
203
+ }
204
+ }
205
+
206
+ /** Which rows are lit, and which staff member is unfolded. */
207
+ function markSidebar() {
208
+ for (const b of document.querySelectorAll(".nav")) {
209
+ if (!b.dataset.view) continue;
210
+ const mine = !b.dataset.staff || b.dataset.staff === S.staffHandle;
211
+ b.setAttribute("aria-current", String(mine && b.dataset.view === S.view));
212
+ }
213
+ for (const head of document.querySelectorAll(".staffrow")) {
214
+ const open = head.dataset.staff === S.staffHandle && !ORG_WIDE.has(S.view);
215
+ head.setAttribute("aria-expanded", String(open));
216
+ head.setAttribute("aria-selected", String(head.dataset.staff === S.staffHandle));
217
+ if (head.nextElementSibling) head.nextElementSibling.hidden = !open;
218
+ }
219
+ }
220
+
221
+ /* --------------------------------- render --------------------------------- */
222
+
223
+ function render() {
224
+ /* Checked on every paint rather than once at boot, because `refreshAll` re-fetches the
225
+ export and a portal can go stale under a page that is already open. */
226
+ const missing = NEEDS.filter((k) => S.data?.[k] === undefined);
227
+ if (missing.length) return stale(missing);
228
+
229
+ // A refresh can add or remove a staff member, and the sidebar is built once.
230
+ const roster = S.data.staff.map((s) => s.handle).join(" ");
231
+ if (roster !== paintedRoster) paintSidebar();
232
+ markSidebar();
233
+ const ic = $("#inboxcount");
234
+ if (ic) ic.textContent = S.inbox ? String(openCount()) : "";
235
+ writeHash(true);
236
+ stampLoaded();
237
+
238
+ const m = $("#main");
239
+ m.replaceChildren();
240
+ const notice = syncNotice();
241
+ if (notice) m.append(notice);
242
+ (SCREEN[S.view] ?? viewBrain)(m);
243
+ }
244
+
245
+ /* --------------------------------- theme ---------------------------------- */
246
+
247
+ /* Three states on purpose: "system" is the default and has to be reachable again after
248
+ someone has tried both others. */
249
+ const THEMES = ["system", "light", "dark"];
250
+
251
+ function applyTheme(name) {
252
+ if (name === "system") document.documentElement.removeAttribute("data-theme");
253
+ else document.documentElement.setAttribute("data-theme", name);
254
+ const b = $("#theme");
255
+ if (b) {
256
+ b.replaceChildren(
257
+ icon(name === "light" ? "sun" : name === "dark" ? "moon" : "monitor", "ic"),
258
+ "Theme",
259
+ el("span", { className: "n", textContent: name }),
260
+ );
261
+ }
262
+ }
263
+
264
+ function initTheme() {
265
+ let theme = store("roster.theme") ?? "system";
266
+ if (!THEMES.includes(theme)) theme = "system";
267
+ applyTheme(theme);
268
+ const b = $("#theme");
269
+ if (b) {
270
+ b.onclick = () => {
271
+ theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
272
+ store("roster.theme", theme);
273
+ applyTheme(theme);
274
+ };
275
+ }
276
+ }
277
+
278
+ export { render };
279
+
280
+ boot().catch((e) => {
281
+ $("#main").innerHTML = '<p class="empty err">' + String(e?.message ?? e) + "</p>";
282
+ });
@@ -0,0 +1,70 @@
1
+ /* Asking for more than a sentence.
2
+ *
3
+ * `prompt()` is one line with no wrapping, which is the wrong shape for describing a change to
4
+ * a prompt: the useful answers are two or three sentences and often a bullet list. Native
5
+ * `<dialog>` gives the backdrop, the focus trap and Escape without any of it being written
6
+ * here, which is the whole reason to use it rather than a div.
7
+ */
8
+
9
+ import { el, grow } from "./dom.js";
10
+
11
+ /**
12
+ * @returns the text, or null if they cancelled.
13
+ */
14
+ export function askText({ title, hint, value = "", placeholder = "", confirm = "Continue" }) {
15
+ const box = document.createElement("dialog");
16
+ // The shim in the tests has no dialog element. Falling back keeps a view renderable there
17
+ // rather than throwing halfway through a paint.
18
+ if (typeof box.showModal !== "function") {
19
+ return Promise.resolve(globalThis.prompt?.(title, value) ?? null);
20
+ }
21
+
22
+ box.className = "ask";
23
+ box.append(el("h3", { textContent: title }));
24
+ if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
25
+
26
+ const ta = el("textarea", { value, placeholder, rows: 5 });
27
+ box.append(ta);
28
+
29
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
30
+ const go = el("button", { className: "ghbtn primary", textContent: confirm });
31
+ box.append(
32
+ el("div", { className: "row askrow" }, [
33
+ el("span", { className: "meta", textContent: "⌘⏎ to " + confirm.toLowerCase() }),
34
+ cancel,
35
+ go,
36
+ ]),
37
+ );
38
+
39
+ return new Promise((resolve) => {
40
+ let answer = null;
41
+ const done = (text) => {
42
+ answer = text;
43
+ box.close();
44
+ };
45
+ cancel.onclick = () => done(null);
46
+ go.onclick = () => done(ta.value.trim() || null);
47
+ ta.addEventListener("keydown", (e) => {
48
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
49
+ e.preventDefault();
50
+ done(ta.value.trim() || null);
51
+ }
52
+ });
53
+ // Escape and the backdrop both close it, and both mean no.
54
+ box.addEventListener("close", () => {
55
+ box.remove();
56
+ resolve(answer);
57
+ });
58
+ box.addEventListener("click", (e) => {
59
+ if (e.target === box) done(null);
60
+ });
61
+
62
+ document.body.append(box);
63
+ box.showModal();
64
+ grow(ta);
65
+ ta.focus();
66
+ // Put the caret at the end of a pre-filled answer rather than selecting it, so typing
67
+ // adds to what a finding already said instead of replacing it.
68
+ ta.setSelectionRange?.(ta.value.length, ta.value.length);
69
+ });
70
+ }