@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (96) hide show
  1. package/README.md +65 -84
  2. package/dist/cli.js +4733 -2490
  3. package/docs/README.md +19 -11
  4. package/docs/agents.md +328 -13
  5. package/docs/architecture.md +13 -5
  6. package/docs/charters/cmo.md +69 -0
  7. package/docs/charters/cto.md +71 -0
  8. package/docs/charters/support.md +60 -0
  9. package/docs/commands.md +90 -11
  10. package/docs/concepts.md +64 -12
  11. package/docs/cost.md +39 -3
  12. package/docs/developing.md +16 -21
  13. package/docs/doctor-codes.md +21 -6
  14. package/docs/export.md +2 -1
  15. package/docs/extending.md +13 -4
  16. package/docs/getting-started.md +118 -80
  17. package/docs/images/brain.jpg +0 -0
  18. package/docs/images/org.jpg +0 -0
  19. package/docs/images/prompt.jpg +0 -0
  20. package/docs/images/setup-org.jpg +0 -0
  21. package/docs/images/setup-plan.jpg +0 -0
  22. package/docs/images/staff.jpg +0 -0
  23. package/docs/manual-steps.md +94 -101
  24. package/docs/memory.md +29 -8
  25. package/docs/org-yaml.md +76 -11
  26. package/docs/portal.md +261 -47
  27. package/docs/prompts.md +77 -11
  28. package/docs/security.md +51 -7
  29. package/docs/session-workflow.md +51 -21
  30. package/docs/staff-yaml.md +16 -7
  31. package/docs/troubleshooting.md +23 -20
  32. package/docs/upgrading.md +6 -0
  33. package/docs/writing-a-charter.md +33 -17
  34. package/package.json +1 -1
  35. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
  36. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
  37. package/templates/brain/CHARTER.md +3 -3
  38. package/templates/brain/README.md +1 -0
  39. package/templates/brain/log/decisions.md +3 -0
  40. package/templates/brain/staff.yaml +0 -1
  41. package/templates/brain/strategy/ideas.md +7 -0
  42. package/templates/ops/.github/workflows/session.yaml +117 -40
  43. package/templates/ops/agents.mjs +127 -8
  44. package/templates/ops/compose.mjs +77 -7
  45. package/templates/ops/inflight.mjs +157 -0
  46. package/templates/ops/org/operating.md +21 -7
  47. package/templates/ops/org/voice.md +9 -0
  48. package/templates/ops/prompts/_identity.md +8 -1
  49. package/templates/ops/prompts/_inflight.md +14 -0
  50. package/templates/ops/prompts/_paths.md +2 -1
  51. package/templates/ops/prompts/daily.md +16 -7
  52. package/templates/ops/prompts/mention.md +18 -2
  53. package/templates/ops/run-record.mjs +144 -0
  54. package/templates/portal/css/base.css +238 -64
  55. package/templates/portal/css/brain.css +30 -20
  56. package/templates/portal/css/diff.css +15 -10
  57. package/templates/portal/css/graph.css +12 -7
  58. package/templates/portal/css/health.css +32 -11
  59. package/templates/portal/css/inbox.css +117 -14
  60. package/templates/portal/css/layout.css +93 -41
  61. package/templates/portal/css/markdown.css +57 -15
  62. package/templates/portal/css/runs.css +13 -0
  63. package/templates/portal/css/setup.css +83 -39
  64. package/templates/portal/index.html +24 -3
  65. package/templates/portal/js/api.js +65 -4
  66. package/templates/portal/js/app.js +112 -12
  67. package/templates/portal/js/dialog.js +94 -4
  68. package/templates/portal/js/dom.js +25 -0
  69. package/templates/portal/js/icons.js +8 -1
  70. package/templates/portal/js/lightbox.js +273 -0
  71. package/templates/portal/js/md.js +23 -6
  72. package/templates/portal/js/mdedit.js +84 -0
  73. package/templates/portal/js/mention.js +264 -0
  74. package/templates/portal/js/refresh.js +136 -6
  75. package/templates/portal/js/state.js +55 -8
  76. package/templates/portal/js/views/app.js +23 -5
  77. package/templates/portal/js/views/checklist.js +29 -10
  78. package/templates/portal/js/views/credential.js +98 -0
  79. package/templates/portal/js/views/docs.js +94 -4
  80. package/templates/portal/js/views/files.js +58 -14
  81. package/templates/portal/js/views/graph.js +1 -1
  82. package/templates/portal/js/views/health.js +178 -37
  83. package/templates/portal/js/views/inbox.js +938 -98
  84. package/templates/portal/js/views/memory.js +16 -1
  85. package/templates/portal/js/views/org.js +124 -104
  86. package/templates/portal/js/views/orgedit.js +213 -0
  87. package/templates/portal/js/views/paste.js +33 -7
  88. package/templates/portal/js/views/prompt.js +61 -67
  89. package/templates/portal/js/views/repos.js +20 -15
  90. package/templates/portal/js/views/runonce.js +94 -0
  91. package/templates/portal/js/views/runs.js +165 -0
  92. package/templates/portal/js/views/setup.js +311 -83
  93. package/templates/portal/js/views/staff.js +143 -22
  94. package/templates/portal/js/yaml.js +134 -0
  95. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  96. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -9,8 +9,19 @@ import { el, toClipboard } from "../dom.js";
9
9
 
10
10
  const LEVEL_ORDER = { fail: 0, warn: 1, ok: 2 };
11
11
 
12
+ /**
13
+ * @param opts.skip finding ids the caller already shows as a step of its own, so they are not
14
+ * reported twice. With `opts.hideWhenClean`, a list left empty draws nothing at all.
15
+ * @param opts.shell when Health draws this, the section it lives in: `{actions, say}`. The
16
+ * setup screen passes nothing and gets the layout it has always had. Its own screen is a
17
+ * wizard, where a chunky button and a paragraph under it are right; on Health it is one
18
+ * section of four, and has to look like the other three.
19
+ */
12
20
  export async function checklist(host, opts = {}) {
13
- host.replaceChildren(el("p", { className: "sub", textContent: "Checking…" }));
21
+ const inSection = Boolean(opts.shell);
22
+ const btnClass = inSection ? "ghbtn" : "btn";
23
+ const noteClass = inSection ? "hnote" : "sub";
24
+ host.replaceChildren(el("p", { className: noteClass, textContent: "Checking…" }));
14
25
 
15
26
  let report;
16
27
  try {
@@ -23,12 +34,18 @@ export async function checklist(host, opts = {}) {
23
34
  const findings = (report.findings ?? []).slice().sort(
24
35
  (a, b) => (LEVEL_ORDER[a.level] ?? 3) - (LEVEL_ORDER[b.level] ?? 3),
25
36
  );
26
- const bad = findings.filter((f) => f.level !== "ok");
37
+ const skip = new Set(opts.skip ?? []);
38
+ const bad = findings.filter((f) => f.level !== "ok" && !skip.has(f.id));
27
39
 
28
40
  host.replaceChildren();
41
+ if (!bad.length && opts.hideWhenClean) {
42
+ opts.onEmpty?.();
43
+ return;
44
+ }
45
+ opts.shell?.say(bad.length ? String(bad.length) : "clean");
29
46
  host.append(
30
47
  el("p", {
31
- className: "sub",
48
+ className: noteClass,
32
49
  textContent: bad.length
33
50
  ? `${bad.filter((f) => f.level === "fail").length} failing, ${bad.filter((f) => f.level === "warn").length} to look at.` +
34
51
  (report.online ? "" : " Local checks only.")
@@ -51,7 +68,7 @@ export async function checklist(host, opts = {}) {
51
68
 
52
69
  /* The isitagentready move: the findings as instructions, for the agent you already have
53
70
  pointed at this workspace. What only a person can do is deliberately not in that text. */
54
- const copy = el("button", { className: "btn primary", textContent: "Copy all instructions" });
71
+ const copy = el("button", { className: btnClass + " primary", textContent: "Copy all instructions" });
55
72
  const note = el("span", { className: "meta" });
56
73
  copy.onclick = async () => {
57
74
  copy.disabled = true;
@@ -70,17 +87,19 @@ export async function checklist(host, opts = {}) {
70
87
  }
71
88
  };
72
89
 
73
- const again = el("button", { className: "btn", textContent: "Check again" });
90
+ const again = el("button", { className: btnClass, textContent: "Check again" });
74
91
  again.onclick = () => checklist(host, opts);
75
92
 
76
- host.append(el("div", { className: "row" }, [copy, again, note]));
93
+ const feet = opts.shell?.actions ?? host.appendChild(el("div", { className: "row" }));
94
+ if (opts.shell) feet.replaceChildren();
95
+ feet.append(copy, again, note);
77
96
  if (bad.length) {
78
- host.append(
97
+ feet.append(
79
98
  el("p", {
80
- className: "sub",
99
+ className: noteClass,
100
+ style: "flex-basis:100%",
81
101
  textContent:
82
- "Paste it into Cursor, Claude Code or anything else that can edit files here, then " +
83
- "check again. The ids above should be gone.",
102
+ "Paste them into your coding agent, then check again.",
84
103
  }),
85
104
  );
86
105
  }
@@ -0,0 +1,98 @@
1
+ /* The agent's credential, stored once for the org.
2
+ *
3
+ * The value goes from this box to the local server in a POST, and from there to gh on its
4
+ * standard input. It is not kept in the page after it is sent, and nothing echoes it back. */
5
+
6
+ import { getCredential, storeCredential } from "../api.js";
7
+ import { el } from "../dom.js";
8
+
9
+ /** @param opts.bare drawn inside a step that already has a title. `onStatus(done)` hears whether one is stored. */
10
+ export function credentialPanel(opts = {}) {
11
+ const box = el("div", { className: opts.bare ? "" : "manual" });
12
+ if (!opts.bare) box.append(el("b", { textContent: "Agent credential" }));
13
+ const body = el("div");
14
+ box.append(body);
15
+ body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
16
+
17
+ getCredential()
18
+ .then((c) => {
19
+ opts.onStatus?.(Boolean(c.orgSecret));
20
+ draw(body, c, opts);
21
+ })
22
+ .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
23
+ return box;
24
+ }
25
+
26
+ function draw(body, c, opts = {}) {
27
+ body.replaceChildren();
28
+ if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
29
+
30
+ if (!(c.brains ?? []).length) {
31
+ // Inside a step, the step's own hint already says when this opens.
32
+ if (opts.bare) return;
33
+ body.append(
34
+ el("p", {
35
+ className: "sub",
36
+ textContent:
37
+ "Available after your first hire. You only add it once.",
38
+ }),
39
+ );
40
+ return;
41
+ }
42
+
43
+ if (c.orgSecret) {
44
+ body.append(
45
+ el("p", {
46
+ className: "sub",
47
+ textContent:
48
+ c.name + " is already set. Paste a new one only to replace it.",
49
+ }),
50
+ );
51
+ }
52
+
53
+ body.append(
54
+ el("p", {
55
+ className: "sub",
56
+ textContent:
57
+ (c.plan.mode === "org"
58
+ ? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
59
+ : "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
60
+ "It's never written to disk.",
61
+ }),
62
+ );
63
+
64
+ const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
65
+ input.style.width = "100%";
66
+ const go = el("button", { className: "btn primary", textContent: "Store it" });
67
+ const out = el("div", { style: "margin-top:9px" });
68
+ body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
69
+
70
+ go.onclick = async () => {
71
+ const value = input.value.trim();
72
+ if (!value) {
73
+ input.focus();
74
+ return;
75
+ }
76
+ go.disabled = true;
77
+ out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
78
+ try {
79
+ const r = await storeCredential(value, false);
80
+ input.value = "";
81
+ if (!r.ok) throw new Error(r.error);
82
+ opts.onStatus?.(true);
83
+ out.replaceChildren(
84
+ ...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
85
+ el("p", {
86
+ textContent:
87
+ r.mode === "org"
88
+ ? "Saved."
89
+ : "Saved on " + r.repos.join(", ") + ".",
90
+ }),
91
+ );
92
+ } catch (err) {
93
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
94
+ } finally {
95
+ go.disabled = false;
96
+ }
97
+ };
98
+ }
@@ -1,8 +1,8 @@
1
1
  /* The framework's own documentation, read where you already are. Same renderer as a brain
2
2
  file, so a link between pages behaves like a link between files rather than a dead end. */
3
3
 
4
- import { getDoc, getDocs } from "../api.js";
5
- import { el, slug } from "../dom.js";
4
+ import { getDoc, getDocs, searchDocs } from "../api.js";
5
+ import { el, esc, skeleton, slug } from "../dom.js";
6
6
  import { mdlite } from "../md.js";
7
7
  import { S, writeHash } from "../state.js";
8
8
 
@@ -16,6 +16,18 @@ export function viewDocs(m) {
16
16
  }),
17
17
  );
18
18
 
19
+ /* Titles are not enough. Twenty-odd pages is too many to scan and few enough for the server
20
+ to read in full on every keystroke, and what you are looking for — "which page explains the
21
+ mention gate" — is a sentence in a paragraph rather than a word in a heading. So the box
22
+ searches the text, and the list becomes the results while there is a query in it. */
23
+ const find = el("input", {
24
+ type: "search",
25
+ placeholder: "Search the docs…",
26
+ value: S.docQuery ?? "",
27
+ style: "width:100%;margin-bottom:12px",
28
+ });
29
+ m.append(find);
30
+
19
31
  const split = el("div", { className: "split" });
20
32
  const list = el("div", { className: "tree" });
21
33
  const viewer = el("div", { className: "viewer" });
@@ -24,14 +36,44 @@ export function viewDocs(m) {
24
36
 
25
37
  if (S.docs) paint();
26
38
  else {
27
- list.append(el("p", { className: "empty", textContent: "Loading…" }));
39
+ list.replaceChildren(...skeleton("row", 8));
28
40
  getDocs()
29
41
  .then((d) => { S.docs = d; paint(); })
30
42
  .catch((e) => list.replaceChildren(el("p", { className: "empty err", textContent: e.message })));
31
43
  }
32
44
 
45
+ /* Debounced, because every keystroke is a read of every page. 140ms is below the point
46
+ where the list feels like it is lagging behind the typing. */
47
+ let timer = null;
48
+ find.oninput = () => {
49
+ S.docQuery = find.value;
50
+ writeHash(false);
51
+ clearTimeout(timer);
52
+ timer = setTimeout(run, 140);
53
+ };
54
+ if (S.docQuery) run();
55
+
56
+ async function run() {
57
+ const q = (S.docQuery ?? "").trim();
58
+ if (!q) { paint(); return; }
59
+ let hits;
60
+ try {
61
+ hits = (await searchDocs(q)).hits ?? [];
62
+ } catch (e) {
63
+ list.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
64
+ return;
65
+ }
66
+ // A slow answer to a query you have already moved on from must not land.
67
+ if ((S.docQuery ?? "").trim() !== q) return;
68
+ paintHits(hits, q);
69
+ }
70
+
33
71
  function paint() {
34
72
  list.replaceChildren();
73
+ if (!S.docs?.length) {
74
+ list.append(el("p", { className: "empty", textContent: "No docs here." }));
75
+ return;
76
+ }
35
77
  for (const d of S.docs) {
36
78
  const b = el("button", { className: "tfile tmem", title: d.file });
37
79
  b.dataset.key = d.file;
@@ -43,6 +85,54 @@ export function viewDocs(m) {
43
85
  show(S.openDoc ?? S.docs[0].file);
44
86
  }
45
87
 
88
+ /** The same list, narrowed, with the lines that matched under each row. */
89
+ function paintHits(hits, q) {
90
+ list.replaceChildren();
91
+ list.append(
92
+ el("div", {
93
+ className: "tdir first",
94
+ textContent: hits.length
95
+ ? hits.length + (hits.length === 1 ? " page" : " pages") + " mention that"
96
+ : "nothing mentions that",
97
+ }),
98
+ );
99
+ if (!hits.length) {
100
+ list.append(
101
+ el("p", {
102
+ className: "empty",
103
+ textContent: "Every word has to appear somewhere on the page. Try fewer.",
104
+ }),
105
+ );
106
+ return;
107
+ }
108
+ for (const h of hits) {
109
+ const b = el("button", { className: "tfile thit", title: h.file });
110
+ b.dataset.key = h.file;
111
+ const t = el("span", { className: "t" });
112
+ t.append(el("span", { className: "hitname", textContent: h.title }));
113
+ for (const line of h.matches ?? []) {
114
+ t.append(el("span", { className: "hitline", innerHTML: mark(line.text, q) }));
115
+ }
116
+ b.append(t);
117
+ b.setAttribute("aria-current", String(S.openDoc === h.file));
118
+ b.onclick = () => open(h.file);
119
+ list.append(b);
120
+ }
121
+ // Opening the first result is what makes typing feel like searching rather than filtering.
122
+ if (!hits.some((h) => h.file === S.openDoc)) open(hits[0].file);
123
+ }
124
+
125
+ /** The words that were asked for, shown in the line they were found in. */
126
+ function mark(text, q) {
127
+ const terms = q.toLowerCase().split(/\s+/).filter(Boolean)
128
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
129
+ let html = esc(text);
130
+ if (terms.length) {
131
+ html = html.replace(new RegExp("(" + terms.join("|") + ")", "gi"), "<em>$1</em>");
132
+ }
133
+ return html;
134
+ }
135
+
46
136
  function open(file) {
47
137
  S.openDoc = file;
48
138
  writeHash(false);
@@ -53,7 +143,7 @@ export function viewDocs(m) {
53
143
  }
54
144
 
55
145
  async function show(file) {
56
- viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
146
+ viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
57
147
  let text;
58
148
  try {
59
149
  text = await getDoc(file);
@@ -1,11 +1,18 @@
1
- /* Rendering one file out of a brain: markdown, image, CSV, JSON, or source. */
1
+ /* Rendering one file out of a brain: markdown, image, video, CSV, JSON, or source. */
2
2
 
3
3
  import { el, kb } from "../dom.js";
4
4
  import { fileUrl, getFile } from "../api.js";
5
+ import { icon } from "../icons.js";
5
6
  import { mdlite } from "../md.js";
7
+ import { yamlPre } from "../yaml.js";
6
8
 
7
9
  const IMG = ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"];
8
10
 
11
+ /* A brain records as often as it screenshots, and a recording read as source is a screenful
12
+ of binary. It plays here for the same reason an image displays here: it is the file. */
13
+ const VIDEO = ["mp4", "m4v", "webm", "mov", "ogv"];
14
+ const AUDIO = ["mp3", "m4a", "wav", "oga"];
15
+
9
16
  export async function showFile(viewer, s, f, pick) {
10
17
  viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
11
18
  const path = s.dir + "/" + f.path;
@@ -18,7 +25,33 @@ export async function showFile(viewer, s, f, pick) {
18
25
  head.textContent = stamp(f.bytes);
19
26
 
20
27
  if (IMG.includes(f.ext)) {
21
- viewer.replaceChildren(head, el("img", { src: fileUrl(path), alt: f.path }));
28
+ /* `zoomable` is the opt-in the lightbox looks for. The gallery tiles above are not
29
+ marked, because their click already means "open this file", and this is where it lands. */
30
+ viewer.replaceChildren(
31
+ head,
32
+ el("img", { className: "zoomable", src: fileUrl(path), alt: f.path }),
33
+ );
34
+ return;
35
+ }
36
+
37
+ if (VIDEO.includes(f.ext) || AUDIO.includes(f.ext)) {
38
+ const player = el(VIDEO.includes(f.ext) ? "video" : "audio", {
39
+ src: fileUrl(path),
40
+ controls: true,
41
+ preload: "metadata",
42
+ className: "player",
43
+ });
44
+ // Nothing else here streams, so say what to do when a codec the browser cannot decode
45
+ // turns up — a .mov is often h.265, and a silent black rectangle explains nothing.
46
+ const fallback = el("p", { className: "meta", hidden: true });
47
+ player.onerror = () => {
48
+ fallback.hidden = false;
49
+ fallback.textContent = "This browser cannot play " + f.ext + ". The file itself is fine — ";
50
+ fallback.append(
51
+ el("a", { href: fileUrl(path), download: f.path.split("/").pop(), textContent: "download it" }),
52
+ );
53
+ };
54
+ viewer.replaceChildren(head, player, fallback);
22
55
  return;
23
56
  }
24
57
 
@@ -56,6 +89,12 @@ export async function showFile(viewer, s, f, pick) {
56
89
  return;
57
90
  }
58
91
 
92
+ // A manifest is the one kind of source in a brain that is read closely rather than skimmed.
93
+ if (f.ext === "yaml" || f.ext === "yml") {
94
+ viewer.replaceChildren(head, yamlPre(text));
95
+ return;
96
+ }
97
+
59
98
  viewer.replaceChildren(head, el("pre", { className: "code", textContent: text }));
60
99
  }
61
100
 
@@ -69,26 +108,31 @@ function csvTable(text) {
69
108
  }
70
109
 
71
110
  export function showGallery(viewer, s, surface) {
72
- const imgs = surface.files.filter((f) => IMG.includes(f.ext));
73
- if (!imgs.length) {
74
- viewer.replaceChildren(el("p", { className: "empty", textContent: "No images here." }));
111
+ const shots = surface.files.filter((f) => IMG.includes(f.ext) || VIDEO.includes(f.ext));
112
+ if (!shots.length) {
113
+ viewer.replaceChildren(el("p", { className: "empty", textContent: "Nothing to show here." }));
75
114
  return;
76
115
  }
77
116
  const g = el("div", { className: "gallery" });
78
- for (const f of imgs.slice(0, 200)) {
117
+ for (const f of shots.slice(0, 200)) {
79
118
  const fig = el("figure");
80
- const img = el("img", {
81
- src: fileUrl(s.dir + "/" + f.path),
82
- loading: "lazy",
83
- alt: f.path,
84
- });
85
- img.onclick = () => showFile(viewer, s, f);
86
- fig.append(img, el("figcaption", { textContent: f.path.split("/").pop() }));
119
+ const src = fileUrl(s.dir + "/" + f.path);
120
+ /* A video tile is its own first frame rather than a placeholder, which is what makes a
121
+ wall of recordings tell you anything. `preload="metadata"` is enough for that and does
122
+ not pull the whole file for every tile on the page. */
123
+ const tile = IMG.includes(f.ext)
124
+ ? el("img", { src, loading: "lazy", alt: f.path })
125
+ : el("video", { src, preload: "metadata", muted: true, playsInline: true });
126
+ tile.onclick = () => showFile(viewer, s, f);
127
+ fig.append(tile);
128
+ if (!IMG.includes(f.ext)) fig.append(el("span", { className: "playmark" }, [icon("play", "ic")]));
129
+ fig.append(el("figcaption", { textContent: f.path.split("/").pop() }));
87
130
  g.append(fig);
88
131
  }
132
+ const vids = shots.filter((f) => VIDEO.includes(f.ext)).length;
89
133
  viewer.replaceChildren(
90
134
  el("div", { className: "meta", style: "margin-bottom:12px" }, [
91
- imgs.length + " images in " + surface.path,
135
+ shots.length - vids + " images" + (vids ? " and " + vids + " videos" : "") + " in " + surface.path,
92
136
  ]),
93
137
  g,
94
138
  );
@@ -53,7 +53,7 @@ function drawGraph(canvas, wrap, s) {
53
53
  }
54
54
  const groups = [...members.keys()].sort((a, b) => members.get(b).length - members.get(a).length);
55
55
 
56
- const PALETTE = ["#7cc7a4","#7aa2f7","#c99bd6","#d8a657","#6fc3c9","#e07b7b","#b3c46b","#e0a1c0","#9d9ff5","#8fb0d9"];
56
+ const PALETTE = ["#30d158","#0a84ff","#bf5af2","#ff9f0a","#64d2ff","#ff453a","#ffcc00","#ff375f","#5e5ce6","#ac8e68"];
57
57
  const colour = new Map(groups.map((g, i) => [g, PALETTE[i % PALETTE.length]]));
58
58
 
59
59
  // The ring grows with the number of groups so they never crowd, and each group keeps its