@nanocollective/roster 0.1.0-alpha.3 → 0.1.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -84
- package/dist/cli.js +4817 -2374
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +95 -11
- package/docs/concepts.md +64 -12
- package/docs/cost.md +39 -3
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +21 -6
- package/docs/export.md +4 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +133 -79
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +95 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +290 -49
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +17 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +46 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +7 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +16 -4
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/staff.yaml +0 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +117 -40
- package/templates/ops/agents.mjs +127 -8
- package/templates/ops/compose.mjs +77 -7
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -7
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +18 -2
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +245 -64
- package/templates/portal/css/brain.css +30 -20
- package/templates/portal/css/diff.css +15 -10
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +32 -11
- package/templates/portal/css/inbox.css +117 -14
- package/templates/portal/css/layout.css +114 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +126 -39
- package/templates/portal/index.html +25 -3
- package/templates/portal/js/api.js +74 -4
- package/templates/portal/js/app.js +156 -14
- package/templates/portal/js/dialog.js +129 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +45 -1
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +59 -8
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +178 -37
- package/templates/portal/js/views/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +959 -126
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +124 -104
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +61 -67
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +165 -0
- package/templates/portal/js/views/setup.js +257 -75
- package/templates/portal/js/views/staff.js +157 -182
- package/templates/portal/js/views/todo.js +62 -0
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src2) =>
|
|
149
|
+
// `` 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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Colour behind a textarea, for the files people edit here.
|
|
2
|
+
*
|
|
3
|
+
* The textarea stays the thing you type into, so undo, selection, spellcheck and ⌘S all work
|
|
4
|
+
* as they always did. Its text is made transparent and a <pre> with the same box sits behind
|
|
5
|
+
* it, redrawn on every keystroke. The editors grow to fit rather than scroll, which is what
|
|
6
|
+
* keeps the two lined up without syncing a scroll position.
|
|
7
|
+
*
|
|
8
|
+
* Markdown here is a per-line tokeniser, like yaml.js: enough to read by, and anything it does
|
|
9
|
+
* not recognise is plain text rather than an error.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { el, esc } from "./dom.js";
|
|
13
|
+
import { yamlHTML } from "./yaml.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a textarea so its contents are highlighted. Returns the wrapper, which is what goes on
|
|
17
|
+
* the page in place of the textarea.
|
|
18
|
+
* @param {"md"|"yaml"} lang
|
|
19
|
+
*/
|
|
20
|
+
export function highlighted(ta, lang = "md") {
|
|
21
|
+
const wrap = el("div", { className: "hled" });
|
|
22
|
+
const back = el("pre", { className: "hlback" });
|
|
23
|
+
back.setAttribute?.("aria-hidden", "true");
|
|
24
|
+
ta.classList?.add("hlfront");
|
|
25
|
+
wrap.append(back, ta);
|
|
26
|
+
/* The colours only line up if both boxes set text identically, and the textarea's box comes
|
|
27
|
+
from whichever class it already had, so it is read rather than restated in CSS. */
|
|
28
|
+
const match = () => {
|
|
29
|
+
if (typeof getComputedStyle !== "function" || !ta.isConnected) return;
|
|
30
|
+
const cs = getComputedStyle(ta);
|
|
31
|
+
for (const k of ["font", "lineHeight", "letterSpacing", "tabSize", "padding", "borderWidth", "borderStyle", "borderRadius"]) {
|
|
32
|
+
back.style[k] = cs[k];
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const paint = () => {
|
|
36
|
+
match();
|
|
37
|
+
// A trailing newline in a textarea is a line; in a <pre> it is not, without something after it.
|
|
38
|
+
back.innerHTML = (lang === "yaml" ? yamlHTML(ta.value) : markdownHTML(ta.value)) + "\n ";
|
|
39
|
+
};
|
|
40
|
+
ta.addEventListener("input", paint);
|
|
41
|
+
paint();
|
|
42
|
+
// Setting .value from code fires no input event, so the owner can ask for a repaint.
|
|
43
|
+
wrap.repaint = paint;
|
|
44
|
+
return wrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Escaped, highlighted HTML for markdown source. */
|
|
48
|
+
export function markdownHTML(text) {
|
|
49
|
+
let fenced = false;
|
|
50
|
+
return String(text ?? "")
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => {
|
|
53
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
54
|
+
fenced = !fenced;
|
|
55
|
+
return span("mdfence", line);
|
|
56
|
+
}
|
|
57
|
+
if (fenced) return span("mdcode", line);
|
|
58
|
+
if (/^#{1,6}\s/.test(line)) return span("mdh", line);
|
|
59
|
+
if (/^\s*>/.test(line)) return span("mdq", line);
|
|
60
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) return span("mdfence", line);
|
|
61
|
+
const list = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/.exec(line);
|
|
62
|
+
if (list) return esc(list[1]) + span("mdl", list[2]) + esc(list[3]) + inline(list[4]);
|
|
63
|
+
return inline(line);
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const span = (cls, text) => '<span class="' + cls + '">' + esc(text) + "</span>";
|
|
69
|
+
|
|
70
|
+
/** Inline marks, on one line. Code spans first, so nothing inside one is read as markup. */
|
|
71
|
+
function inline(line) {
|
|
72
|
+
return line
|
|
73
|
+
.split(/(`[^`]+`)/)
|
|
74
|
+
.map((part, i) => (i % 2 ? span("mdc", part) : marks(part)))
|
|
75
|
+
.join("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function marks(text) {
|
|
79
|
+
return esc(text)
|
|
80
|
+
.replace(/\{\{[^}]*\}\}/g, (m) => '<span class="mdt">' + m + "</span>")
|
|
81
|
+
.replace(/(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g, '<span class="mdb">$1$2$1</span>')
|
|
82
|
+
.replace(/(^|[^*\w])([*_])(?=\S)([^*_]+?)(?<=\S)\2(?![*\w])/g, '$1<span class="mdi">$2$3$2</span>')
|
|
83
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<span class="mdk">[$1]</span><span class="mdu">($2)</span>');
|
|
84
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/* Typing `@` should offer the org.
|
|
2
|
+
*
|
|
3
|
+
* A mention is not decoration here: a staff member's workflow gates on their `@handle`, so a
|
|
4
|
+
* reply that misspells it is a reply nobody is woken by, and a reply that spells a bot's login
|
|
5
|
+
* where a handle belongs looks right and does nothing. Both failures are silent, which is the
|
|
6
|
+
* kind this portal exists to stop. The roster is small and it is already on the page, so it is
|
|
7
|
+
* offered rather than remembered.
|
|
8
|
+
*
|
|
9
|
+
* Everyone is here, and each row says what mentioning it actually does: a staff handle wakes
|
|
10
|
+
* them, a human gets a notification, an App gets neither and is listed so you can recognise the
|
|
11
|
+
* name rather than address it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el, esc } from "./dom.js";
|
|
15
|
+
import { humansOf, S } from "./state.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everyone a mention actually reaches, in the order they are worth offering.
|
|
19
|
+
*
|
|
20
|
+
* Staff first: waking one is the reason to type `@` in this app at all. Then the humans, who
|
|
21
|
+
* get a notification.
|
|
22
|
+
*
|
|
23
|
+
* **The Apps are deliberately not here.** `@some-org-cto` is a login, not an inbox: GitHub
|
|
24
|
+
* delivers nothing for mentioning a GitHub App, and an agent wakes on its own handle and not
|
|
25
|
+
* on the name of the identity it posts as. A list you pick from should contain only things
|
|
26
|
+
* that do something; an entry that says "notifies nobody" is a trap with a label on it, and it
|
|
27
|
+
* pushed the entries that work off the bottom of the box.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here knows what any of these people are called. Every row comes off `org.yaml` and
|
|
30
|
+
* the manifests, so an org with a head of ops and a designer gets a head of ops and a designer.
|
|
31
|
+
*/
|
|
32
|
+
export function mentionable() {
|
|
33
|
+
const out = [];
|
|
34
|
+
|
|
35
|
+
for (const s of S.data?.staff ?? []) {
|
|
36
|
+
const at = String(s.mention ?? "@" + s.handle);
|
|
37
|
+
out.push({
|
|
38
|
+
text: at,
|
|
39
|
+
label: s.name ?? s.handle,
|
|
40
|
+
note: "wakes them, usually within a minute",
|
|
41
|
+
kind: "staff",
|
|
42
|
+
terms: [at.replace(/^@/, ""), s.handle, s.name ?? ""],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const h of humansOf()) {
|
|
47
|
+
if (!h.github) continue;
|
|
48
|
+
out.push({
|
|
49
|
+
text: "@" + h.github,
|
|
50
|
+
label: h.name ?? h.github,
|
|
51
|
+
note: h.role ? h.role + " · notifies them" : "notifies them",
|
|
52
|
+
kind: "human",
|
|
53
|
+
terms: [h.github, h.name ?? ""],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What is being typed after an `@`, or null. Only at a word boundary: an email is not a mention. */
|
|
61
|
+
export function queryAt(text, caret) {
|
|
62
|
+
const before = String(text ?? "").slice(0, caret);
|
|
63
|
+
const m = /(?:^|[\s(<[{>,;:"'`])@([A-Za-z0-9][\w.-]*)?$/.exec(before);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const query = m[1] ?? "";
|
|
66
|
+
return { query, start: caret - query.length - 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The matches for a query, best first. Prefix beats substring; order beats neither. */
|
|
70
|
+
export function matches(query, all = mentionable()) {
|
|
71
|
+
const q = query.toLowerCase();
|
|
72
|
+
if (!q) return all;
|
|
73
|
+
const rank = (c) => {
|
|
74
|
+
const terms = c.terms.map((t) => String(t).toLowerCase()).filter(Boolean);
|
|
75
|
+
if (terms.some((t) => t.startsWith(q))) return 0;
|
|
76
|
+
if (terms.some((t) => t.includes(q))) return 1;
|
|
77
|
+
return 2;
|
|
78
|
+
};
|
|
79
|
+
return all
|
|
80
|
+
.map((c, i) => ({ c, i, r: rank(c) }))
|
|
81
|
+
.filter((x) => x.r < 2)
|
|
82
|
+
.sort((a, b) => a.r - b.r || a.i - b.i)
|
|
83
|
+
.map((x) => x.c);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wire a textarea up to the list.
|
|
88
|
+
*
|
|
89
|
+
* Attaching adds listeners and nothing else: the popup is built the first time somebody types
|
|
90
|
+
* an `@`, so a screen that renders fifty boxes pays for none of them, and a DOM without
|
|
91
|
+
* `getComputedStyle` (the test shim) never reaches the part that needs one.
|
|
92
|
+
*/
|
|
93
|
+
export function attachMentions(ta) {
|
|
94
|
+
if (!ta?.addEventListener) return;
|
|
95
|
+
|
|
96
|
+
let pop = null;
|
|
97
|
+
let items = [];
|
|
98
|
+
let at = 0;
|
|
99
|
+
let anchor = null;
|
|
100
|
+
|
|
101
|
+
const close = () => {
|
|
102
|
+
if (pop) pop.hidden = true;
|
|
103
|
+
anchor = null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const open = (found, q) => {
|
|
107
|
+
items = found;
|
|
108
|
+
at = 0;
|
|
109
|
+
anchor = q;
|
|
110
|
+
if (!pop) {
|
|
111
|
+
pop = el("div", { className: "mentions" });
|
|
112
|
+
pop.setAttribute("role", "listbox");
|
|
113
|
+
// mousedown, not click: the textarea must not lose the caret before the pick lands.
|
|
114
|
+
pop.addEventListener("mousedown", (e) => {
|
|
115
|
+
const row = e.target.closest?.("[data-at]");
|
|
116
|
+
if (!row) return;
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
pick(items[Number(row.dataset.at)]);
|
|
119
|
+
});
|
|
120
|
+
host(ta).append(pop);
|
|
121
|
+
}
|
|
122
|
+
pop.hidden = false;
|
|
123
|
+
paint();
|
|
124
|
+
place();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const paint = () => {
|
|
128
|
+
pop.replaceChildren(
|
|
129
|
+
...items.map((c, i) => {
|
|
130
|
+
const row = el("button", { className: "mrow " + c.kind, type: "button" });
|
|
131
|
+
row.dataset.at = String(i);
|
|
132
|
+
row.setAttribute("aria-selected", String(i === at));
|
|
133
|
+
row.innerHTML =
|
|
134
|
+
'<span class="mat">' + esc(c.text) + "</span>" +
|
|
135
|
+
'<span class="mname">' + esc(c.label) + "</span>" +
|
|
136
|
+
'<span class="mnote">' + esc(c.note) + "</span>";
|
|
137
|
+
return row;
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const move = (by) => {
|
|
143
|
+
at = (at + by + items.length) % items.length;
|
|
144
|
+
paint();
|
|
145
|
+
pop.children[at]?.scrollIntoView?.({ block: "nearest" });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const pick = (choice) => {
|
|
149
|
+
if (!choice || !anchor) return;
|
|
150
|
+
const caret = ta.selectionStart ?? ta.value.length;
|
|
151
|
+
const after = ta.value.slice(caret);
|
|
152
|
+
// A space, unless there already is one: nobody wants to type it and nobody wants two.
|
|
153
|
+
const tail = /^\s/.test(after) ? "" : " ";
|
|
154
|
+
ta.value = ta.value.slice(0, anchor.start) + choice.text + tail + after;
|
|
155
|
+
const to = anchor.start + choice.text.length + tail.length;
|
|
156
|
+
ta.setSelectionRange?.(to, to);
|
|
157
|
+
close();
|
|
158
|
+
ta.focus?.();
|
|
159
|
+
// So the box's own listeners — the draft, the grow, the "this is what wakes them" line —
|
|
160
|
+
// see the text that is now in it.
|
|
161
|
+
if (typeof Event === "function") ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const look = () => {
|
|
165
|
+
const caret = ta.selectionStart ?? 0;
|
|
166
|
+
const q = queryAt(ta.value, caret);
|
|
167
|
+
if (!q) return close();
|
|
168
|
+
const found = matches(q.query);
|
|
169
|
+
if (!found.length) return close();
|
|
170
|
+
open(found, q);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
ta.addEventListener("input", look);
|
|
174
|
+
ta.addEventListener("click", close);
|
|
175
|
+
ta.addEventListener("blur", close);
|
|
176
|
+
ta.addEventListener("keydown", (e) => {
|
|
177
|
+
if (!anchor || !pop || pop.hidden) return;
|
|
178
|
+
if (e.key === "ArrowDown") { e.preventDefault(); move(1); return; }
|
|
179
|
+
if (e.key === "ArrowUp") { e.preventDefault(); move(-1); return; }
|
|
180
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
181
|
+
// ⌘⏎ is "send", and it means that whether or not this list is open.
|
|
182
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
// Nothing else on the page may see it: in a dialog, a stray Enter submits.
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
pick(items[at]);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (e.key === "Escape") {
|
|
190
|
+
// Dismiss the list, not the dialog around it.
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
close();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Where the popup lives: the first positioned ancestor, so a modal keeps it on top. */
|
|
198
|
+
function host(node) {
|
|
199
|
+
const parent = node.parentElement ?? node.parentNode;
|
|
200
|
+
if (!parent) return node;
|
|
201
|
+
if (typeof getComputedStyle === "function" && getComputedStyle(parent).position === "static") {
|
|
202
|
+
parent.style.position = "relative";
|
|
203
|
+
}
|
|
204
|
+
return parent;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Under the caret, not under the box.
|
|
209
|
+
*
|
|
210
|
+
* Measured with a mirror: a div with the textarea's own metrics, holding the text up to the
|
|
211
|
+
* caret and a marker after it. There is no other way to ask a textarea where its caret is.
|
|
212
|
+
* If any of that is unavailable, it falls back to the bottom of the box, which is worse but
|
|
213
|
+
* never wrong.
|
|
214
|
+
*/
|
|
215
|
+
function place() {
|
|
216
|
+
const under = ta.offsetTop + ta.offsetHeight;
|
|
217
|
+
let top = under;
|
|
218
|
+
let left = ta.offsetLeft;
|
|
219
|
+
let line = 18;
|
|
220
|
+
if (typeof getComputedStyle === "function" && ta.parentElement) {
|
|
221
|
+
const style = getComputedStyle(ta);
|
|
222
|
+
const mirror = el("div", { className: "mmirror" });
|
|
223
|
+
for (const prop of [
|
|
224
|
+
"fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing", "textTransform",
|
|
225
|
+
"lineHeight", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
|
|
226
|
+
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
|
|
227
|
+
]) {
|
|
228
|
+
mirror.style[prop] = style[prop];
|
|
229
|
+
}
|
|
230
|
+
mirror.style.width = ta.offsetWidth + "px";
|
|
231
|
+
mirror.style.left = ta.offsetLeft + "px";
|
|
232
|
+
mirror.style.top = ta.offsetTop + "px";
|
|
233
|
+
mirror.textContent = ta.value.slice(0, ta.selectionStart ?? 0);
|
|
234
|
+
const mark = el("span", { textContent: "" });
|
|
235
|
+
mirror.append(mark);
|
|
236
|
+
ta.parentElement.append(mirror);
|
|
237
|
+
line = Number.parseFloat(style.lineHeight) || 18;
|
|
238
|
+
top = ta.offsetTop + mark.offsetTop - (ta.scrollTop ?? 0) + line;
|
|
239
|
+
left = ta.offsetLeft + mark.offsetLeft;
|
|
240
|
+
mirror.remove();
|
|
241
|
+
// A caret near the bottom of a long box would put the list off the card.
|
|
242
|
+
if (top > under) top = under;
|
|
243
|
+
}
|
|
244
|
+
pop.style.top = Math.round(top) + "px";
|
|
245
|
+
pop.style.left = Math.round(left) + "px";
|
|
246
|
+
|
|
247
|
+
/* Both edges, measured after placing, because until it is placed it has no size.
|
|
248
|
+
A `<dialog>` scrolls its own box, so anything hanging outside it is not merely ugly:
|
|
249
|
+
it is clipped, and the entries you were about to pick are the ones that go missing. */
|
|
250
|
+
const host = pop.offsetParent ?? pop.parentElement;
|
|
251
|
+
const wide = host?.clientWidth ?? 0;
|
|
252
|
+
if (wide && left + pop.offsetWidth > wide - 8) {
|
|
253
|
+
pop.style.left = Math.max(0, Math.round(wide - pop.offsetWidth - 8)) + "px";
|
|
254
|
+
}
|
|
255
|
+
const tall = host?.clientHeight ?? 0;
|
|
256
|
+
if (tall && top + pop.offsetHeight > tall - 8) {
|
|
257
|
+
// Above the line being typed if it fits there, which is where an editor would put it.
|
|
258
|
+
// Otherwise as low as it can sit and still be whole.
|
|
259
|
+
const above = top - line - pop.offsetHeight;
|
|
260
|
+
pop.style.top =
|
|
261
|
+
Math.round(above >= 0 ? above : Math.max(0, tall - pop.offsetHeight - 8)) + "px";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|