@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +4775 -2495
- package/docs/README.md +19 -11
- package/docs/agents.md +328 -13
- package/docs/architecture.md +13 -5
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/support.md +60 -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 +2 -1
- package/docs/extending.md +13 -4
- package/docs/getting-started.md +121 -80
- 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 +94 -101
- package/docs/memory.md +29 -8
- package/docs/org-yaml.md +76 -11
- package/docs/portal.md +261 -47
- package/docs/prompts.md +77 -11
- package/docs/security.md +51 -7
- package/docs/session-workflow.md +51 -21
- package/docs/staff-yaml.md +16 -7
- package/docs/troubleshooting.md +23 -20
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +33 -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/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 +238 -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 +93 -41
- package/templates/portal/css/markdown.css +57 -15
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +83 -39
- package/templates/portal/index.html +24 -3
- package/templates/portal/js/api.js +65 -4
- package/templates/portal/js/app.js +112 -12
- package/templates/portal/js/dialog.js +94 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- 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/refresh.js +136 -6
- package/templates/portal/js/state.js +55 -8
- package/templates/portal/js/views/app.js +23 -5
- package/templates/portal/js/views/checklist.js +29 -10
- package/templates/portal/js/views/credential.js +98 -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/inbox.js +938 -98
- 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 +213 -0
- package/templates/portal/js/views/paste.js +33 -7
- 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 +311 -83
- package/templates/portal/js/views/staff.js +139 -22
- 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
|
@@ -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({
|
|
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(
|
|
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(
|
|
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
|
|
102
|
+
if (onBackdrop(e, box.getBoundingClientRect?.())) done(null);
|
|
60
103
|
});
|
|
61
104
|
|
|
62
105
|
document.body.append(box);
|
|
@@ -68,3 +111,50 @@ export function askText({ title, hint, value = "", placeholder = "", confirm = "
|
|
|
68
111
|
ta.setSelectionRange?.(ta.value.length, ta.value.length);
|
|
69
112
|
});
|
|
70
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Asking before something that cannot be taken back: a merge, a push, a new repo, a paid run.
|
|
117
|
+
*
|
|
118
|
+
* Native `confirm()` looked like the browser rather than this page, and it blocks the whole
|
|
119
|
+
* tab, which froze browser automation mid-run. This is the same `<dialog>` as askText, so the
|
|
120
|
+
* backdrop, Escape and the focus trap come free. The wording is the caller's: `title` is the
|
|
121
|
+
* question, `hint` says what will happen.
|
|
122
|
+
*
|
|
123
|
+
* @returns true only for the confirm button. Escape, Cancel and the backdrop are all no.
|
|
124
|
+
*/
|
|
125
|
+
export function askYes({ title, hint, confirm = "Continue" }) {
|
|
126
|
+
const box = document.createElement("dialog");
|
|
127
|
+
// The test shim has no dialog element; see askText.
|
|
128
|
+
if (typeof box.showModal !== "function") {
|
|
129
|
+
return Promise.resolve(Boolean(globalThis.confirm?.(hint ? title + "\n\n" + hint : title)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
box.className = "ask";
|
|
133
|
+
box.append(el("h3", { textContent: title }));
|
|
134
|
+
if (hint) box.append(el("p", { className: "askhint", textContent: hint }));
|
|
135
|
+
|
|
136
|
+
const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
|
|
137
|
+
const go = el("button", { className: "ghbtn primary", textContent: confirm });
|
|
138
|
+
box.append(el("div", { className: "row askrow" }, [cancel, go]));
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let answer = false;
|
|
142
|
+
const done = (yes) => {
|
|
143
|
+
answer = yes;
|
|
144
|
+
box.close();
|
|
145
|
+
};
|
|
146
|
+
cancel.onclick = () => done(false);
|
|
147
|
+
go.onclick = () => done(true);
|
|
148
|
+
box.addEventListener("close", () => {
|
|
149
|
+
box.remove();
|
|
150
|
+
resolve(answer);
|
|
151
|
+
});
|
|
152
|
+
box.addEventListener("click", (e) => {
|
|
153
|
+
if (onBackdrop(e, box.getBoundingClientRect?.())) done(false);
|
|
154
|
+
});
|
|
155
|
+
document.body.append(box);
|
|
156
|
+
box.showModal();
|
|
157
|
+
// Cancel has the focus, so an Enter pressed out of habit does not merge anything.
|
|
158
|
+
cancel.focus();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|