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

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 (68) hide show
  1. package/dist/cli.js +848 -164
  2. package/docs/README.md +10 -5
  3. package/docs/agents.md +320 -9
  4. package/docs/commands.md +5 -6
  5. package/docs/concepts.md +27 -9
  6. package/docs/cost.md +3 -2
  7. package/docs/doctor-codes.md +13 -4
  8. package/docs/export.md +2 -1
  9. package/docs/extending.md +11 -2
  10. package/docs/getting-started.md +89 -84
  11. package/docs/images/brain.jpg +0 -0
  12. package/docs/images/org.jpg +0 -0
  13. package/docs/images/prompt.jpg +0 -0
  14. package/docs/images/setup-org.jpg +0 -0
  15. package/docs/images/setup-plan.jpg +0 -0
  16. package/docs/images/staff.jpg +0 -0
  17. package/docs/manual-steps.md +36 -13
  18. package/docs/memory.md +9 -6
  19. package/docs/org-yaml.md +37 -9
  20. package/docs/portal.md +197 -31
  21. package/docs/prompts.md +50 -11
  22. package/docs/security.md +19 -7
  23. package/docs/session-workflow.md +8 -10
  24. package/docs/staff-yaml.md +3 -5
  25. package/docs/troubleshooting.md +17 -14
  26. package/docs/writing-a-charter.md +18 -17
  27. package/package.json +1 -1
  28. package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
  29. package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
  30. package/templates/brain/staff.yaml +0 -1
  31. package/templates/ops/.github/workflows/session.yaml +9 -26
  32. package/templates/ops/agents.mjs +121 -6
  33. package/templates/ops/compose.mjs +61 -4
  34. package/templates/ops/org/operating.md +0 -6
  35. package/templates/ops/prompts/_identity.md +8 -1
  36. package/templates/ops/prompts/mention.md +16 -2
  37. package/templates/portal/css/base.css +122 -8
  38. package/templates/portal/css/brain.css +8 -1
  39. package/templates/portal/css/diff.css +6 -2
  40. package/templates/portal/css/health.css +21 -2
  41. package/templates/portal/css/inbox.css +93 -5
  42. package/templates/portal/css/layout.css +26 -4
  43. package/templates/portal/css/markdown.css +23 -3
  44. package/templates/portal/css/setup.css +11 -6
  45. package/templates/portal/index.html +7 -1
  46. package/templates/portal/js/api.js +33 -0
  47. package/templates/portal/js/app.js +33 -7
  48. package/templates/portal/js/dialog.js +47 -4
  49. package/templates/portal/js/dom.js +25 -0
  50. package/templates/portal/js/icons.js +8 -1
  51. package/templates/portal/js/lightbox.js +273 -0
  52. package/templates/portal/js/md.js +23 -6
  53. package/templates/portal/js/mention.js +264 -0
  54. package/templates/portal/js/refresh.js +136 -6
  55. package/templates/portal/js/state.js +47 -5
  56. package/templates/portal/js/views/checklist.js +20 -7
  57. package/templates/portal/js/views/docs.js +94 -4
  58. package/templates/portal/js/views/files.js +58 -14
  59. package/templates/portal/js/views/health.js +163 -35
  60. package/templates/portal/js/views/inbox.js +882 -96
  61. package/templates/portal/js/views/memory.js +16 -1
  62. package/templates/portal/js/views/org.js +142 -62
  63. package/templates/portal/js/views/prompt.js +50 -63
  64. package/templates/portal/js/views/setup.js +37 -13
  65. package/templates/portal/js/views/staff.js +62 -2
  66. package/templates/portal/js/yaml.js +134 -0
  67. package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
  68. package/templates/ops/prompts/pr-mention.md +0 -57
@@ -3,16 +3,24 @@
3
3
  import { getOrg } from "./api.js";
4
4
  import { $, el, store } from "./dom.js";
5
5
  import { icon, iconHTML } from "./icons.js";
6
+ import { installLightbox } from "./lightbox.js";
6
7
  import { setPeople } from "./md.js";
7
- import { refreshAll, stampLoaded, syncNotice } from "./refresh.js";
8
+ import {
9
+ countInBackground,
10
+ refreshAll,
11
+ refreshQuietly,
12
+ stampCounts,
13
+ stampLoaded,
14
+ syncNotice,
15
+ } from "./refresh.js";
8
16
  import { onRender } from "./router.js";
9
- import { ORG_WIDE, S, VIEWS, applyHash, openCount, writeHash } from "./state.js";
17
+ import { ORG_WIDE, S, VIEWS, applyHash, 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
26
  import { viewSetup } from "./views/setup.js";
@@ -20,6 +28,7 @@ import { viewStaff } from "./views/staff.js";
20
28
 
21
29
  const SCREEN = {
22
30
  inbox: viewInbox,
31
+ prs: viewPrs,
23
32
  org: viewOrg,
24
33
  staff: viewStaff,
25
34
  docs: viewDocs,
@@ -43,6 +52,9 @@ const NEEDS = ["org", "name", "opsName", "staff"];
43
52
 
44
53
  export async function boot() {
45
54
  initTheme();
55
+ /* Before the first render, and once: it is delegated, so it covers every image any screen
56
+ draws from here on, including the setup screen. */
57
+ installLightbox();
46
58
 
47
59
  /* Before there is a tenant the server answers every data route with `mode: setup`. That is
48
60
  not an error state: it is the first thing a new user ever sees, and the whole page becomes
@@ -51,6 +63,12 @@ export async function boot() {
51
63
  if (first && first.mode === "setup") {
52
64
  document.body.dataset.setup = "1";
53
65
  $("#orgname").textContent = "no org yet";
66
+ /* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
67
+ row of four dead ends beside an empty Staff heading. There is one thing to do on this
68
+ screen; the navigation comes back with the org. */
69
+ for (const el of document.querySelectorAll("#inboxnav, #prsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
70
+ el.hidden = true;
71
+ }
54
72
  for (const slot of document.querySelectorAll("[data-icon]")) {
55
73
  slot.innerHTML = iconHTML(slot.dataset.icon);
56
74
  }
@@ -74,14 +92,18 @@ export async function boot() {
74
92
  paintSidebar();
75
93
 
76
94
  $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
95
+ $("#prsnav").onclick = () => { S.view = "prs"; render(); };
77
96
  $("#staffnav").onclick = () => { S.view = "staff"; render(); };
78
97
  $("#orgnav").onclick = () => { S.view = "org"; render(); };
79
98
  $("#docsnav").onclick = () => { S.view = "docs"; render(); };
80
99
  $("#refreshall").onclick = () => refreshAll(false);
81
100
 
82
- // Coming back to the tab after a run should show the run.
101
+ /* Coming back to the tab after a run should show the run — and coming back to it thirty
102
+ seconds after leaving it should show nothing at all. Clicking into the window used to
103
+ re-read every repo on GitHub and repaint the screen under you; now it looks first and
104
+ only repaints when something actually moved. */
83
105
  addEventListener("focus", () => {
84
- if (S.loadedAt && Date.now() - S.loadedAt > 30000) refreshAll(false);
106
+ if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
85
107
  });
86
108
  addEventListener("keydown", (e) => {
87
109
  if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
@@ -92,6 +114,11 @@ export async function boot() {
92
114
  });
93
115
  setInterval(stampLoaded, 30000);
94
116
 
117
+ /* What is waiting on you is the first thing this page should be able to tell you, and it used
118
+ to be the one thing it would not say until you clicked Inbox. One request, shared with
119
+ whichever screen renders next. */
120
+ countInBackground();
121
+
95
122
  // Only the state changes here. This used to re-run half of boot on every navigation,
96
123
  // which stacked up a focus listener, a keydown listener and an interval each time.
97
124
  addEventListener("hashchange", () => {
@@ -230,8 +257,7 @@ function render() {
230
257
  const roster = S.data.staff.map((s) => s.handle).join(" ");
231
258
  if (roster !== paintedRoster) paintSidebar();
232
259
  markSidebar();
233
- const ic = $("#inboxcount");
234
- if (ic) ic.textContent = S.inbox ? String(openCount()) : "";
260
+ stampCounts();
235
261
  writeHash(true);
236
262
  stampLoaded();
237
263
 
@@ -7,11 +7,47 @@
7
7
  */
8
8
 
9
9
  import { el, grow } from "./dom.js";
10
+ import { attachMentions } from "./mention.js";
10
11
 
11
12
  /**
13
+ * Whether a click was on the backdrop, which is not the same question as "is the target the
14
+ * dialog".
15
+ *
16
+ * Picking a name from the `@` list used to close the whole dialog and throw away what you had
17
+ * typed. The list hides itself on mousedown so the caret survives the pick, so by the time the
18
+ * `click` lands the row is gone and the event retargets to the nearest thing still under the
19
+ * pointer, which is the dialog. A target test alone reads that as a backdrop click.
20
+ *
21
+ * So the pointer has to actually be outside the dialog's own box. `detail` of 0 is a click
22
+ * synthesised by the keyboard, which reports 0,0 and would otherwise look like the top corner
23
+ * of the page.
24
+ */
25
+ export function onBackdrop(e, rect) {
26
+ if (!rect || e.currentTarget !== e.target || !e.detail) return false;
27
+ return (
28
+ e.clientX < rect.left ||
29
+ e.clientX > rect.right ||
30
+ e.clientY < rect.top ||
31
+ e.clientY > rect.bottom
32
+ );
33
+ }
34
+
35
+ /**
36
+ * @param decorate given the textarea, returns a node to sit under it. This is how the reply
37
+ * box carries its attachments: the control has to write into the box it is beside.
38
+ * @param allowEmpty saying nothing is an answer for some of these. Closing an issue without
39
+ * a parting comment is the normal case, not a cancelled dialog.
12
40
  * @returns the text, or null if they cancelled.
13
41
  */
14
- export function askText({ title, hint, value = "", placeholder = "", confirm = "Continue" }) {
42
+ export function askText({
43
+ title,
44
+ hint,
45
+ value = "",
46
+ placeholder = "",
47
+ confirm = "Continue",
48
+ decorate,
49
+ allowEmpty = false,
50
+ }) {
15
51
  const box = document.createElement("dialog");
16
52
  // The shim in the tests has no dialog element. Falling back keeps a view renderable there
17
53
  // rather than throwing halfway through a paint.
@@ -25,6 +61,12 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
25
61
 
26
62
  const ta = el("textarea", { value, placeholder, rows: 5 });
27
63
  box.append(ta);
64
+ /* Before the ⌘⏎ handler below, so that when the `@` list is open its own Enter wins. Every
65
+ dialog this function opens is prose that may need to reach somebody, and a mention is how
66
+ it reaches them. */
67
+ attachMentions(ta);
68
+ const under = decorate?.(ta);
69
+ if (under) box.append(under);
28
70
 
29
71
  const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
30
72
  const go = el("button", { className: "ghbtn primary", textContent: confirm });
@@ -42,12 +84,13 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
42
84
  answer = text;
43
85
  box.close();
44
86
  };
87
+ const said = () => ta.value.trim() || (allowEmpty ? "" : null);
45
88
  cancel.onclick = () => done(null);
46
- go.onclick = () => done(ta.value.trim() || null);
89
+ go.onclick = () => done(said());
47
90
  ta.addEventListener("keydown", (e) => {
48
91
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
49
92
  e.preventDefault();
50
- done(ta.value.trim() || null);
93
+ done(said());
51
94
  }
52
95
  });
53
96
  // Escape and the backdrop both close it, and both mean no.
@@ -56,7 +99,7 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
56
99
  resolve(answer);
57
100
  });
58
101
  box.addEventListener("click", (e) => {
59
- if (e.target === box) done(null);
102
+ if (onBackdrop(e, box.getBoundingClientRect?.())) done(null);
60
103
  });
61
104
 
62
105
  document.body.append(box);
@@ -35,6 +35,31 @@ export function markCurrent(list, btn) {
35
35
  btn.setAttribute("aria-current", "true");
36
36
  }
37
37
 
38
+ /**
39
+ * Placeholder shapes, for the seconds a screen spends asking GitHub.
40
+ *
41
+ * The inbox reads every repo in the org, which is several seconds on a cold cache, and what
42
+ * stood there meanwhile was the word "Asking GitHub…" in an otherwise empty box beside an
43
+ * empty box. Nothing about that says the shape of what is coming, so the page looked broken
44
+ * rather than busy.
45
+ *
46
+ * `kind` names the shape: "row" for a list item, "line" for a paragraph, "head" for a title.
47
+ * They are decorative, so they are hidden from assistive technology and the live region
48
+ * elsewhere on the screen does the announcing.
49
+ */
50
+ export function skeleton(kind, n = 1) {
51
+ const out = [];
52
+ for (let i = 0; i < n; i++) {
53
+ const box = el("div", { className: "sk sk-" + kind });
54
+ box.setAttribute("aria-hidden", "true");
55
+ if (kind === "row") {
56
+ box.append(el("div", { className: "skbar t" }), el("div", { className: "skbar m" }));
57
+ }
58
+ out.push(box);
59
+ }
60
+ return out;
61
+ }
62
+
38
63
  export function store(k, v) {
39
64
  try {
40
65
  return v === undefined ? localStorage.getItem(k) : localStorage.setItem(k, v);
@@ -1,6 +1,6 @@
1
1
  /* Icons.
2
2
  *
3
- * Twenty-four Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
3
+ * Lucide glyphs (lucide.dev, ISC), vendored as path data rather than pulled from
4
4
  * a CDN or a font. The portal is local-first and offline, so an icon that needs the network
5
5
  * is an icon that is sometimes a blank square.
6
6
  *
@@ -53,8 +53,15 @@ const PATHS = {
53
53
  "<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
54
  "moon":
55
55
  "<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\" />",
56
+ "paperclip":
57
+ "<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
58
  "person":
57
59
  "<circle cx=\"12\" cy=\"8\" r=\"5\" /> <path d=\"M20 21a8 8 0 0 0-16 0\" />",
60
+ "play":
61
+ "<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\" />",
62
+ // The pair for the lightbox's zoom controls. `dash` above is the other half.
63
+ "plus":
64
+ "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
58
65
  "refresh":
59
66
  "<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
67
  "rename":
@@ -0,0 +1,273 @@
1
+ /* Looking properly at a picture.
2
+ *
3
+ * An image inside a document is laid out for the page it is in, which for a screenshot of a
4
+ * user interface means unreadable: the thing you wanted to see is the small print. Every other
5
+ * surface here has an answer for "show me that bigger" and this one did not.
6
+ *
7
+ * So any image the portal renders as *content* opens over the page, fitted to the window, and
8
+ * zooms. Gallery tiles are deliberately not included: they already have a click, which opens
9
+ * the file, and the file's own view is one of the images this does catch.
10
+ *
11
+ * Deliberately not a `<dialog>`. The rest of the portal uses one, and it is right for a form,
12
+ * but this needs its own key handling, wheel handling and pointer capture, and `showModal`
13
+ * brings a focus trap and a backdrop that then have to be worked around rather than used.
14
+ */
15
+
16
+ import { el } from "./dom.js";
17
+ import { icon } from "./icons.js";
18
+
19
+ /** Images that open. `.md img` is every rendered document; `zoomable` is opt-in elsewhere. */
20
+ const SELECTOR = ".md img, img.zoomable";
21
+
22
+ const MIN_SCALE = 0.1;
23
+
24
+ /**
25
+ * How far in it will go, and why it is not just a number.
26
+ *
27
+ * A scaled `<img>` is a composited layer, and the compositor allocates it at the *rendered*
28
+ * size. A flat ceiling of 16 turned a 1544x784 screenshot into a 310-megapixel layer, about
29
+ * 1.2GB of texture, and froze the tab for the best part of a minute. That is not a zoom level
30
+ * anybody asked for; it is a number that was never checked against a real picture.
31
+ *
32
+ * So the ceiling comes off the image. The budget is what may be rendered at once; the floor of
33
+ * 1 keeps actual size reachable however large the source is, and the cap of 8 stops a 100px
34
+ * icon being allowed to fill a wall.
35
+ */
36
+ const MAX_RENDERED_PIXELS = 16e6;
37
+ const ABSOLUTE_MAX = 8;
38
+
39
+ export function maxScaleFor(natural) {
40
+ const px = (natural.width || 1) * (natural.height || 1);
41
+ return Math.min(ABSOLUTE_MAX, Math.max(1, Math.sqrt(MAX_RENDERED_PIXELS / px)));
42
+ }
43
+
44
+ let open = null;
45
+
46
+ /**
47
+ * Zoom about a point, as arithmetic.
48
+ *
49
+ * The pixel under the cursor has to stay under the cursor. Without that the image drifts away
50
+ * from whatever you were trying to look at, which is the difference between a zoom and a
51
+ * nuisance, and it is the one part of this that is wrong in a way no screenshot shows.
52
+ *
53
+ * Pure, and separate from the element, so it can be checked rather than eyeballed.
54
+ *
55
+ * @param at {scale, tx, ty}, the current transform
56
+ * @param px,py the fixed point, in stage coordinates
57
+ * @param max the ceiling, from `maxScaleFor`
58
+ * @returns the new transform, or `at` unchanged when it is already against a stop
59
+ */
60
+ export function zoomAbout(at, factor, px, py, max = ABSOLUTE_MAX) {
61
+ const scale = Math.min(max, Math.max(MIN_SCALE, at.scale * factor));
62
+ if (scale === at.scale) return at;
63
+ const k = scale / at.scale;
64
+ return { scale, tx: px - (px - at.tx) * k, ty: py - (py - at.ty) * k };
65
+ }
66
+
67
+ /**
68
+ * The fitted transform: the whole thing visible, centred, and never enlarged to get there.
69
+ *
70
+ * Blowing a 200px image up to fill a 1400px window is not "fitting" it, it is making it worse,
71
+ * so the scale is capped at 1 and a small picture sits small in the middle.
72
+ */
73
+ export function fitInside(natural, stage) {
74
+ const [w, h] = [natural.width || 1, natural.height || 1];
75
+ const [bw, bh] = [stage.width || 1, stage.height || 1];
76
+ const scale = Math.min(bw / w, bh / h, 1);
77
+ return { scale, tx: (bw - w * scale) / 2, ty: (bh - h * scale) / 2 };
78
+ }
79
+
80
+ /**
81
+ * One delegated listener, installed at boot.
82
+ *
83
+ * Per-image handlers would have to be attached by every render site, and there are four of
84
+ * them: markdown in a doc page, markdown in a brain document, an image surface, and the file
85
+ * viewer. A render that forgot would be an image that silently does not open, which is exactly
86
+ * the sort of thing nobody notices for a month.
87
+ */
88
+ export function installLightbox() {
89
+ document.addEventListener?.("click", (e) => {
90
+ const img = e.target?.closest?.(SELECTOR);
91
+ if (!img || !img.currentSrc && !img.getAttribute?.("src")) return;
92
+ // A picture inside a link is the link's to handle. Following it is what was asked for.
93
+ if (img.closest("a")) return;
94
+ e.preventDefault();
95
+ show(img.currentSrc || img.getAttribute("src"), img.getAttribute("alt") || "");
96
+ });
97
+
98
+ /* Navigating away closes it. The overlay is appended to the body rather than into the view,
99
+ so without this it outlives the page it was opened from: a doc image left open over the
100
+ Inbox, with no obvious relationship to anything on screen. */
101
+ addEventListener?.("hashchange", close);
102
+ }
103
+
104
+ /** Open one. Exported so a caller with a src and no element can use it too. */
105
+ export function show(src, alt = "") {
106
+ close();
107
+
108
+ const img = el("img", { src, alt, draggable: false });
109
+ const stage = el("div", { className: "lbstage" }, [img]);
110
+
111
+ const caption = alt ? el("figcaption", { className: "lbcap", textContent: alt }) : null;
112
+ const shut = el("button", {
113
+ className: "lbx",
114
+ title: "Close (Esc)",
115
+ "aria-label": "Close",
116
+ }, [icon("close", "ic")]);
117
+ const hint = el("span", {
118
+ className: "lbhint",
119
+ textContent: "scroll to zoom · drag to move · click to fit",
120
+ });
121
+
122
+ /* Zoom has always worked from the keyboard and the wheel. Neither is discoverable, and on a
123
+ mouse without a usable wheel the keyboard was the only way in at all.
124
+ The percentage is a button rather than a readout: "how big is this" and "put it back" are
125
+ the same question, and it is the one control here that says what the state is. */
126
+ const out = el("button", {
127
+ className: "lbctl",
128
+ title: "Zoom out (-)",
129
+ "aria-label": "Zoom out",
130
+ }, [icon("dash", "ic")]);
131
+ const level = el("button", { className: "lbctl lblevel", title: "Fit to the window (0)" });
132
+ const into = el("button", {
133
+ className: "lbctl",
134
+ title: "Zoom in (+)",
135
+ "aria-label": "Zoom in",
136
+ }, [icon("plus", "ic")]);
137
+ const zoombar = el("div", { className: "lbzoom" }, [out, level, into]);
138
+
139
+ const box = el("div", {
140
+ className: "lightbox",
141
+ role: "dialog",
142
+ "aria-modal": "true",
143
+ "aria-label": alt || "image",
144
+ }, [stage, shut, hint, zoombar, ...(caption ? [caption] : [])]);
145
+
146
+ /* Scale and offset are kept here rather than read back off the transform, because reading a
147
+ matrix back and re-deriving them accumulates error over a few dozen wheel events. */
148
+ let scale = 1;
149
+ let fit = 1;
150
+ let ceiling = ABSOLUTE_MAX;
151
+ let tx = 0;
152
+ let ty = 0;
153
+
154
+ const draw = () => {
155
+ img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
156
+ box.dataset.zoomed = String(scale > fit * 1.001);
157
+ /* Percentage of actual size, not of fitted: 100% has to mean one image pixel per screen
158
+ pixel, or the number is about the window rather than about the picture. */
159
+ level.textContent = `${Math.round(scale * 100)}%`;
160
+ // Disabled at the stops, because a button that does nothing reads as a broken one.
161
+ out.disabled = scale <= MIN_SCALE + 1e-9;
162
+ into.disabled = scale >= ceiling - 1e-9;
163
+ };
164
+
165
+ const reset = () => {
166
+ const natural = {
167
+ width: img.naturalWidth || img.width,
168
+ height: img.naturalHeight || img.height,
169
+ };
170
+ const at = fitInside(natural, stage.getBoundingClientRect?.() ?? { width: 0, height: 0 });
171
+ fit = at.scale;
172
+ ceiling = maxScaleFor(natural);
173
+ ({ scale, tx, ty } = at);
174
+ draw();
175
+ };
176
+
177
+ const zoomAt = (factor, px, py) => {
178
+ ({ scale, tx, ty } = zoomAbout({ scale, tx, ty }, factor, px, py, ceiling));
179
+ draw();
180
+ };
181
+
182
+ const centre = () => {
183
+ const r = stage.getBoundingClientRect?.() ?? { width: 0, height: 0 };
184
+ return [r.width / 2, r.height / 2];
185
+ };
186
+
187
+ img.addEventListener("load", reset);
188
+ if (img.complete) reset();
189
+
190
+ box.addEventListener(
191
+ "wheel",
192
+ (e) => {
193
+ e.preventDefault();
194
+ const r = stage.getBoundingClientRect();
195
+ // ctrl+wheel is what a trackpad pinch arrives as, and it comes in much larger steps.
196
+ const step = e.ctrlKey ? 0.01 : 0.0015;
197
+ zoomAt(Math.exp(-e.deltaY * step), e.clientX - r.left, e.clientY - r.top);
198
+ },
199
+ { passive: false },
200
+ );
201
+
202
+ /* Drag to pan, and the same gesture decides what a release means: a press that moved is a
203
+ pan, a press that did not is a click. Without the distance test, panning an image always
204
+ ended by toggling the zoom you had just set. */
205
+ let from = null;
206
+ let moved = false;
207
+ img.addEventListener("pointerdown", (e) => {
208
+ from = { x: e.clientX, y: e.clientY, tx, ty };
209
+ moved = false;
210
+ img.setPointerCapture?.(e.pointerId);
211
+ });
212
+ img.addEventListener("pointermove", (e) => {
213
+ if (!from) return;
214
+ const dx = e.clientX - from.x;
215
+ const dy = e.clientY - from.y;
216
+ if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
217
+ tx = from.tx + dx;
218
+ ty = from.ty + dy;
219
+ draw();
220
+ });
221
+ img.addEventListener("pointerup", (e) => {
222
+ const wasDrag = moved;
223
+ from = null;
224
+ img.releasePointerCapture?.(e.pointerId);
225
+ if (wasDrag) return;
226
+ // Fitted: go to actual size under the cursor. Zoomed at all: back to fitted.
227
+ const r = stage.getBoundingClientRect();
228
+ if (scale > fit * 1.001) reset();
229
+ else zoomAt(1 / fit, e.clientX - r.left, e.clientY - r.top);
230
+ });
231
+
232
+ // The buttons zoom about the middle of the stage, which is the only fixed point they have.
233
+ out.onclick = () => zoomAt(0.8, ...centre());
234
+ into.onclick = () => zoomAt(1.25, ...centre());
235
+ level.onclick = reset;
236
+
237
+ // The backdrop is the way out, and the image is not the backdrop.
238
+ box.addEventListener("pointerdown", (e) => {
239
+ if (e.target === box || e.target === stage) close();
240
+ });
241
+ shut.onclick = close;
242
+
243
+ const keys = (e) => {
244
+ if (e.key === "Escape") return close();
245
+ if (e.key === "0") return reset();
246
+ if (e.key === "+" || e.key === "=") return zoomAt(1.25, ...centre());
247
+ if (e.key === "-" || e.key === "_") return zoomAt(0.8, ...centre());
248
+ };
249
+ addEventListener("keydown", keys);
250
+
251
+ const onResize = () => reset();
252
+ addEventListener("resize", onResize);
253
+
254
+ const returnTo = document.activeElement;
255
+ open = { box, keys, onResize, returnTo };
256
+
257
+ document.body.append(box);
258
+ // The page behind must not scroll under an overlay: the scrollbar is still live otherwise,
259
+ // and a wheel event that misses the image scrolls the document instead of zooming.
260
+ document.body.dataset.lightbox = "1";
261
+ shut.focus?.();
262
+ }
263
+
264
+ export function close() {
265
+ if (!open) return;
266
+ const { box, keys, onResize, returnTo } = open;
267
+ open = null;
268
+ removeEventListener("keydown", keys);
269
+ removeEventListener("resize", onResize);
270
+ box.remove?.();
271
+ delete document.body.dataset.lightbox;
272
+ returnTo?.focus?.();
273
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { esc } from "./dom.js";
8
8
  import { iconHTML } from "./icons.js";
9
+ import { yamlHTMLFromEscaped } from "./yaml.js";
9
10
 
10
11
  /* Who this org is, so `@cto` can be told from `@some-stranger`. Registered once at boot
11
12
  rather than threaded through every call site, because every call site would pass the
@@ -144,10 +145,13 @@ export function mdlite(src, opts = {}) {
144
145
  chips(
145
146
  s
146
147
  .replace(/`([^`]+)`/g, "<code>$1</code>")
147
- .replace(
148
- /!\[([^\]]*)\]\(([^)\s]+)\)/g,
149
- (_m, alt, src2) =>
150
- '<img src="' + mdAsset(src2, opts) + '" alt="' + alt + '" loading="lazy">',
148
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src2) =>
149
+ // `![](clip.mp4)` is how a recording gets written into a note, and GitHub plays it.
150
+ // An <img> pointing at a video is a broken-image icon, so it becomes a player.
151
+ /\.(mp4|m4v|webm|mov|ogv)(\?|#|$)/i.test(src2)
152
+ ? '<video src="' + mdAsset(src2, opts) + '" controls preload="metadata" class="player">' +
153
+ "</video>"
154
+ : '<img src="' + mdAsset(src2, opts) + '" alt="' + alt + '" loading="lazy">',
151
155
  )
152
156
  // Non-greedy and allowing an inner asterisk, because "**bold with *this* inside**" is
153
157
  // ordinary in these files and the old pattern silently left the stars on the page.
@@ -187,11 +191,19 @@ export function mdlite(src, opts = {}) {
187
191
  }
188
192
 
189
193
  if (line.trim().startsWith("```")) {
194
+ // The fence's language was thrown away, which is most of why a YAML example in the docs
195
+ // read as a grey block while the same file in the Org screen is coloured.
196
+ const lang = line.trim().slice(3).trim().toLowerCase();
190
197
  const buf = [];
191
198
  i++;
192
199
  while (i < lines.length && !lines[i].trim().startsWith("```")) buf.push(lines[i++]);
193
200
  i++;
194
- out.push("<pre><code>" + buf.join("\n") + "</code></pre>");
201
+ const body = buf.join("\n");
202
+ out.push(
203
+ "<pre" + (lang ? ' class="lang-' + esc(lang) + '"' : "") + "><code>" +
204
+ (lang === "yaml" || lang === "yml" ? yamlHTMLFromEscaped(body) : body) +
205
+ "</code></pre>",
206
+ );
195
207
  continue;
196
208
  }
197
209
 
@@ -340,7 +352,12 @@ function mdPath(href, opts) {
340
352
  }
341
353
 
342
354
  function mdAsset(src, opts) {
343
- const path = /^(https?:|data:)/.test(src) ? null : mdPath(src, opts);
355
+ if (/^(https?:|data:)/.test(src)) return src;
356
+ /* A screenshot in the docs is not in anybody's brain repo, so it cannot go through
357
+ /api/file, which resolves inside the workspace. It comes off the framework's own docs
358
+ directory instead, through a route that serves nothing else. */
359
+ if (opts.docs) return "/api/docasset?path=" + encodeURIComponent(src.replace(/^\.?\//, ""));
360
+ const path = mdPath(src, opts);
344
361
  return path ? "/api/file?path=" + encodeURIComponent(opts.file.staffDir + "/" + path) : src;
345
362
  }
346
363