@justai/cuts 0.133.0 → 0.139.0

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.
@@ -0,0 +1,300 @@
1
+ // queries: sheet behaviour · drag to dismiss · sheet script · where did Sheet.astro's script go ·
2
+ // testing a component's behaviour · component script is not type checked
3
+ //
4
+ // THE SHEET'S BEHAVIOUR, OUT OF THE COMPONENT AND INTO A MODULE THAT CAN BE READ.
5
+ //
6
+ // It lived inside `Sheet.astro` — 288 lines of TypeScript that NOTHING checked. A hoisted Astro
7
+ // script goes to esbuild, which STRIPS types without ever reading them, and this package had no
8
+ // tsconfig and no `astro check` anywhere in its CI. Every annotation in here bought exactly nothing
9
+ // until the file moved out and a compiler could open it.
10
+ //
11
+ // It is also the only way any of this becomes testable. A behaviour that exists only inside a
12
+ // template can be exercised in one way: render the whole page and poke it. A module can be called.
13
+ import { chromeCopy } from "@justai/core";
14
+
15
+ // The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
16
+ // an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
17
+ //
18
+ // Note what is NOT here any more: a matchMedia guard. The drag worked only below 768px while this
19
+ // component still had a desktop branch; now that there is one behaviour, there is one code path,
20
+ // and a mouse can pull the grabber exactly as a finger does.
21
+ (function () {
22
+ // queries: sheet says Cancel in English · the picker's cancel is not translated · Done stays
23
+ // English · translate the sheet bar · why does the cut translate itself
24
+ //
25
+ // THE BAR SAYS ITS OWN WORDS, IN THE PAGE'S LANGUAGE.
26
+ //
27
+ // Found on an Arabic page: every word in the picker in Arabic, and `Cancel` in English in the
28
+ // sheet's own bar — because the cut rendered the literal and left translating it to whoever
29
+ // mounted it.
30
+ //
31
+ // WHY AT RUNTIME rather than at build. There is no locale to read at build. The fleet routes
32
+ // languages through a `[lang]` PARAMETER, not Astro's i18n config, so `Astro.currentLocale` is
33
+ // undefined in every persona that has one — and the two surfaces that need this most (`files`
34
+ // and `/me`) are ONE bundle serving thirteen languages, so no build-time answer could be right
35
+ // for them anyway. `<html lang>` is the one place every one of them agrees.
36
+ //
37
+ // A CONSUMER'S WORD ALWAYS WINS. `<Sheet cancel="Not now">` is a deliberate choice and this must
38
+ // not overwrite it — so only the DEFAULTS carry `data-chrome`, and only those are rewritten.
39
+ // That is also what keeps the runtime-translating surfaces working: they re-label by id from
40
+ // their own dictionary, which is the older contract and still the more specific one.
41
+ const say = () => {
42
+ const w = chromeCopy(document.documentElement.lang);
43
+ for (const el of document.querySelectorAll<HTMLElement>("[data-chrome]")) {
44
+ const word = w[el.dataset.chrome as "cancel" | "done"];
45
+ if (word) el.textContent = word;
46
+ }
47
+ };
48
+ say();
49
+ // The child personas set `<html lang>` from their own storage AFTER this module runs — a sheet
50
+ // translated only at load would be correct on a host and English inside a guest. Cheaper than a
51
+ // MutationObserver and exact: the attribute cannot change without the element being replaced.
52
+ new MutationObserver(say).observe(document.documentElement, { attributeFilter: ["lang"] });
53
+ const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
54
+ const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
55
+ const wired = new WeakSet<HTMLDialogElement>();
56
+
57
+ // queries: background scroll lock · html data-sheet-locked · nested sheets unlock too early ·
58
+ // how does the sheet know it opened
59
+ //
60
+ // COUNTED, NOT TOGGLED. Sheets nest — qr's share sheet opens the file door on top of itself —
61
+ // so unlocking on the first `close` would free the page while a second sheet is still up. The
62
+ // count goes 0→1 to lock and 1→0 to unlock, and nothing in between touches it.
63
+ //
64
+ // WATCHED WITH A MutationObserver, because `<dialog>` has no "opened" event and consumers call
65
+ // `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
66
+ // would lock for sheets opened one way and not the other — which is worse than not locking at
67
+ // all, because it would work in testing and fail in the persona.
68
+ const locked = new WeakSet<HTMLDialogElement>();
69
+ let openCount = 0;
70
+ const relock = (delta: number) => {
71
+ openCount = Math.max(0, openCount + delta);
72
+ if (openCount > 0) document.documentElement.setAttribute("data-sheet-locked", "");
73
+ else document.documentElement.removeAttribute("data-sheet-locked");
74
+ };
75
+
76
+ // Measure the tallest sheet ALREADY open and use it as this one's floor. Measured rather than
77
+ // derived from the detent, because a `medium` sheet whose content is short is shorter than its
78
+ // detent allows — the floor has to be what is actually on the screen, not what was permitted.
79
+ function floorOnto(sheet: HTMLDialogElement) {
80
+ if (!sheet.hasAttribute("data-floor")) return;
81
+ const panel = sheet.querySelector<HTMLElement>("[data-panel]");
82
+ if (!panel) return;
83
+ let floor = 0;
84
+ for (const other of document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]")) {
85
+ if (other === sheet) continue;
86
+ const p = other.querySelector<HTMLElement>("[data-panel]");
87
+ if (p) floor = Math.max(floor, p.getBoundingClientRect().height);
88
+ }
89
+ // Never taller than the screen, and never a floor of zero — this is the first sheet then.
90
+ if (floor > 0) panel.style.minHeight = Math.min(floor, window.innerHeight) + "px";
91
+ else panel.style.removeProperty("min-height");
92
+ }
93
+
94
+ // ── in-sheet navigation ────────────────────────────────────────────────────────────────────
95
+ // queries: sheet push pop · back button in a sheet · data-sheet-push · escape pops instead of
96
+ // closing · in-sheet navigation stack
97
+ //
98
+ // A STACK PER SHEET, held on the element rather than in a module variable, because two sheets
99
+ // can be open at once and each has its own history. Empty means "at the root", which is also the
100
+ // state every sheet that never declares a page is permanently in — that is what makes this
101
+ // addition free for the consumers who do not use it.
102
+ const stacks = new WeakMap<HTMLDialogElement, string[]>();
103
+
104
+ const pageOf = (sheet: HTMLDialogElement, key: string) =>
105
+ sheet.querySelector<HTMLElement>(`[data-sheet-page="${CSS.escape(key)}"]`);
106
+
107
+ // The name a person reads on the way back. The page's own `data-sheet-page-title` if it has one,
108
+ // otherwise the sheet's heading — which is the honest answer at the root, since that IS where
109
+ // back goes.
110
+ const titleAt = (sheet: HTMLDialogElement, key: string | undefined) =>
111
+ (key ? pageOf(sheet, key)?.dataset.sheetPageTitle : null)
112
+ ?? sheet.querySelector<HTMLElement>(".sheet-title")?.textContent?.trim()
113
+ ?? "";
114
+
115
+ function show(sheet: HTMLDialogElement, popping: boolean) {
116
+ const stack = stacks.get(sheet) ?? [];
117
+ const pages = sheet.querySelectorAll<HTMLElement>("[data-sheet-page]");
118
+ if (!pages.length) return;
119
+ const current = stack[stack.length - 1];
120
+ for (const el of pages) {
121
+ const on = current ? el.dataset.sheetPage === current : el.hasAttribute("data-sheet-root");
122
+ el.classList.toggle("is-current", on && !popping);
123
+ el.classList.toggle("is-popping", on && popping);
124
+ }
125
+ const back = sheet.querySelector<HTMLElement>("[data-sheet-back]");
126
+ const label = sheet.querySelector<HTMLElement>("[data-sheet-back-label]");
127
+ if (back) back.hidden = stack.length === 0;
128
+ // The label names WHERE BACK GOES — the level beneath the current one — which is the whole
129
+ // convention iOS set. Naming the current page instead is the mistake that makes a back button
130
+ // read as a title.
131
+ if (label) label.textContent = stack.length ? titleAt(sheet, stack[stack.length - 2]) : "";
132
+ }
133
+
134
+ function push(sheet: HTMLDialogElement, key: string) {
135
+ if (!pageOf(sheet, key)) return;
136
+ const stack = stacks.get(sheet) ?? [];
137
+ stacks.set(sheet, [...stack, key]);
138
+ show(sheet, false);
139
+ }
140
+
141
+ function pop(sheet: HTMLDialogElement) {
142
+ const stack = stacks.get(sheet) ?? [];
143
+ if (!stack.length) return false;
144
+ stacks.set(sheet, stack.slice(0, -1));
145
+ show(sheet, true);
146
+ return true;
147
+ }
148
+
149
+ document.addEventListener("click", (e) => {
150
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-push]");
151
+ if (t) {
152
+ const sheet = t.closest("dialog.sheet") as HTMLDialogElement | null;
153
+ if (sheet) { e.preventDefault(); push(sheet, t.getAttribute("data-sheet-push") || ""); }
154
+ return;
155
+ }
156
+ const b = (e.target as HTMLElement)?.closest?.("[data-sheet-back]");
157
+ if (b) { e.preventDefault(); pop(b.closest("dialog.sheet") as HTMLDialogElement); }
158
+ });
159
+
160
+ // ESCAPE POPS BEFORE IT CLOSES, and the backdrop does the same. Anywhere else in a browser, Back
161
+ // at depth means "up one level"; a sheet that jumps straight out from three levels down throws
162
+ // away everything the person navigated through, and they cannot tell the two gestures apart
163
+ // beforehand. `preventDefault` on `cancel` is what keeps <dialog> from closing underneath us.
164
+ document.addEventListener("cancel", (e) => {
165
+ const sheet = e.target as HTMLDialogElement;
166
+ if (sheet?.classList?.contains("sheet") && pop(sheet)) e.preventDefault();
167
+ }, true);
168
+
169
+ // A sheet always REOPENS AT ITS ROOT. Coming back to a surface and finding it three levels deep
170
+ // where you left it is the behaviour of a window, not of a sheet — and nobody expects a sheet to
171
+ // remember.
172
+ document.addEventListener("click", (e) => {
173
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
174
+ if (!t) return;
175
+ const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
176
+ if (sheet) { stacks.set(sheet, []); show(sheet, false); }
177
+ }, true);
178
+
179
+ function wire(sheet: HTMLDialogElement) {
180
+ if (wired.has(sheet) || !sheet.showModal) return;
181
+ wired.add(sheet);
182
+ let wasOpen = sheet.open;
183
+ if (wasOpen) relock(1);
184
+ new MutationObserver(() => {
185
+ if (sheet.open === wasOpen) return;
186
+ wasOpen = sheet.open;
187
+ relock(wasOpen ? 1 : -1);
188
+ if (wasOpen) floorOnto(sheet);
189
+ else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
190
+ }).observe(sheet, { attributes: true, attributeFilter: ["open"] });
191
+ const panel = sheet.querySelector<HTMLElement>("[data-panel]");
192
+ const grip = sheet.querySelector<HTMLElement>("[data-grip]");
193
+ if (!panel) return;
194
+
195
+ // A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
196
+ // A tap on the stage pops one level first, for the same reason Escape does.
197
+ sheet.addEventListener("click", (e) => { if (e.target === sheet && !pop(sheet)) sheet.close(); });
198
+ sheet.addEventListener("close", () => {
199
+ sheet.removeAttribute("data-expanded");
200
+ panel.style.transform = "";
201
+ // Back to the root on the way out, whichever way it was dismissed — see the reopen note.
202
+ if (stacks.get(sheet)?.length) { stacks.set(sheet, []); show(sheet, false); }
203
+ });
204
+ // A sheet that ships pages must show one from the very first frame, or its body is empty until
205
+ // somebody navigates. The root is whichever page carries `data-sheet-root`.
206
+ show(sheet, false);
207
+
208
+ // ── drag: the grabber follows the pointer, and the release decides ──
209
+ let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
210
+ grip?.addEventListener("pointerdown", (e: PointerEvent) => {
211
+ id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
212
+ grip.setPointerCapture(id);
213
+ sheet.setAttribute("data-dragging", "");
214
+ });
215
+ grip?.addEventListener("pointermove", (e: PointerEvent) => {
216
+ if (id !== e.pointerId) return;
217
+ dy = e.clientY - y0;
218
+ // Downward drag travels 1:1. Upward is RUBBER-BANDED rather than free: pulling up past the
219
+ // detent must feel like resistance, not like a second scroll surface.
220
+ panel.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy / 4}px)`;
221
+ });
222
+ const release = (e: PointerEvent) => {
223
+ if (id !== e.pointerId) return;
224
+ const dt = Math.max(1, e.timeStamp - t0);
225
+ const v = dy / dt;
226
+ sheet.removeAttribute("data-dragging");
227
+ panel.style.transform = "";
228
+ id = null;
229
+ if (dy > panel.offsetHeight * DRAG_CLOSE || v > FLICK) sheet.close();
230
+ // A decisive pull upwards promotes the medium detent to large — iOS's two-detent sheet.
231
+ else if (dy < -40) sheet.setAttribute("data-expanded", "");
232
+ };
233
+ grip?.addEventListener("pointerup", release);
234
+ grip?.addEventListener("pointercancel", release);
235
+ }
236
+
237
+ // WIRED ON LOAD, not on the first trigger click.
238
+ //
239
+ // It used to be lazy, and the cost of that surfaced the moment a sheet was first opened by
240
+ // something other than a `data-sheet-open` trigger — <FilePicker> calls showModal() itself,
241
+ // because its sheet opens when a persona ASKS for a file rather than when a person presses a
242
+ // button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
243
+ // of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
244
+ // queries: does the confirm dialog lock the page too · scroll behind ConfirmSheet
245
+ //
246
+ // ConfirmSheet is its own `<dialog class="confirm">` rather than a `<Sheet>` — deliberately, it
247
+ // is an alert and not a surface. But the page scrolling behind it is the same defect on the
248
+ // same person's thumb, so the LOCK covers it while the floor and the drag do not. A rule about
249
+ // how a modal feels belongs to every modal, not to one component.
250
+ function lockOnly(d: HTMLDialogElement) {
251
+ if (locked.has(d)) return;
252
+ locked.add(d);
253
+ let was = d.open;
254
+ if (was) relock(1);
255
+ new MutationObserver(() => {
256
+ if (d.open === was) return;
257
+ was = d.open;
258
+ relock(was ? 1 : -1);
259
+ }).observe(d, { attributes: true, attributeFilter: ["open"] });
260
+ }
261
+
262
+ const wireAll = () => {
263
+ document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
264
+ document.querySelectorAll<HTMLDialogElement>("dialog.confirm").forEach(lockOnly);
265
+ };
266
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
267
+ else wireAll();
268
+
269
+ // Done closes, and NOTHING ELSE. It is not a commit — a sheet whose changes are live has
270
+ // nothing to commit — so it must not become a hook consumers hang saving off, or the word
271
+ // starts meaning two things in two personas.
272
+ document.addEventListener("click", (e) => {
273
+ const d = (e.target as HTMLElement)?.closest?.("[data-sheet-done]");
274
+ if (!d) return;
275
+ (d.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
276
+ });
277
+
278
+ // Cancel closes too, and the difference is not in what it DOES — it is in what the caller may
279
+ // conclude. A sheet that resolves a promise reads its own `close` event and settles null there,
280
+ // so a cancel, a backdrop tap and a drag down are one path with one answer. Two words, one exit,
281
+ // and no second place for a caller to be told something different.
282
+ document.addEventListener("click", (e) => {
283
+ const c = (e.target as HTMLElement)?.closest?.("[data-sheet-cancel]");
284
+ if (!c) return;
285
+ (c.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
286
+ });
287
+
288
+ document.addEventListener("click", (e) => {
289
+ const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
290
+ if (!t) return;
291
+ const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
292
+ if (!sheet) return;
293
+ e.preventDefault();
294
+ wire(sheet);
295
+ if (!sheet.open) sheet.showModal();
296
+ // Land the focus on the PANEL, not on whatever control happens to be first inside it — the
297
+ // keyboard-over-the-list failure described on the tabindex attribute above.
298
+ sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
299
+ });
300
+ })();
@@ -0,0 +1,89 @@
1
+ // queries: toolbar behaviour · toolbar script · where did Toolbar.astro's script go ·
2
+ // testing a component's behaviour · component script is not type checked
3
+ //
4
+ // The toolbar — the persona's row, its mark and the scroll spy it hands off to core.
5
+ //
6
+ // Lifted out of `Toolbar.astro`, where it was TypeScript that nothing read: a hoisted Astro script
7
+ // goes to esbuild, which strips annotations WITHOUT checking them, and this package had no tsconfig.
8
+ // Out here a compiler can open it and a test can call it. What stays in the component is the markup
9
+ // and the CSS, which is what a component is.
10
+ // queries: where does the toolbar's scroll behaviour live · land on the kernel · scroll spy ·
11
+ // title strip · why is the scroll code not in this file any more
12
+ //
13
+ // The BEHAVIOUR moved to @justai/core (`personaScroll`); what stays here is the markup and the CSS.
14
+ //
15
+ // It moved because this cut can only serve pages that adopt its markup, and three surfaces cannot:
16
+ // `/me` and `/files` carry extra navigation of their own and the feed is a different build. All
17
+ // three hand-wrote the same eighty lines, and by the third copy they had drifted — `/files` opened
18
+ // on the profile with an empty title strip, and its hash writer clobbered the query string where
19
+ // this one preserved it. One implementation, four consumers.
20
+ //
21
+ // The defaults ARE this cut's contract — the three cut ids, `nav<Cut>`, `#tbTitle`/`#tbTitleText`,
22
+ // `.toolbar`, `#below` — so a persona using the shell passes nothing at all.
23
+ import { personaScroll, adoptMark } from "@justai/core";
24
+
25
+ // queries: the parent icon in a child toolbar has a background · navParent tile · the child's row
26
+ // does not match the persona's · who removes the ground for a child persona
27
+ //
28
+ // THE ROW TAKES CARE OF ITS OWN MARK. Every other icon here is an ink; `parent` renders the host's
29
+ // avatar file, which is a TILE, so a child's row is the one place in the fleet where a background
30
+ // still shows. The persona's own mark is derived at build time from a file the repo imports — a
31
+ // child has only a URL, so `adoptMark` fetches it and runs the same derivation in the browser,
32
+ // measuring the box from the drawing instead of being told it.
33
+ //
34
+ // Here rather than in the three children because this cut is what rendered `#navParent`: three
35
+ // consumers each remembering a line is O(N), and the third one always forgets.
36
+ if (document.getElementById("navParent")) adoptMark({ id: "navParent" });
37
+
38
+ // queries: the persona mark is an image · the golden rule does not apply · currentColor img ·
39
+ // the mark keeps its colours when I did not pass profileIcon
40
+ //
41
+ // THE FALLBACK HAS TO OBEY THE RULE TOO. A persona that passes `profileIcon` hands over a mark
42
+ // derived at build time — ink, ground-less, sized. A persona that passes nothing gets the `<img>`
43
+ // above, and an image obeys nothing: it cannot take `currentColor`, so it wears its own colours
44
+ // while another cut is current, which is exactly what the accent must never do.
45
+ //
46
+ // It is the child persona's problem in a different costume — a mark known only as a URL — so it
47
+ // takes the child's answer: fetch it and derive it here. Same function, same three edits, and the
48
+ // row's own `.tb-nav` / `.tb-nav.active` rules reach it afterwards like any glyph.
49
+ //
50
+ // The build-time path stays the better one and stays the default advice: it costs no request and
51
+ // no frame of the wrong picture. This is the floor under a persona that has not got there yet.
52
+ const ownMark = document.querySelector("#navProfile img");
53
+ if (ownMark) adoptMark({ id: "navProfile" });
54
+
55
+ // Both or neither — see the props above. When both are set the strip names the SECTIONS; core's
56
+ // default, which names the persona and its handle, is left entirely alone otherwise. Nothing here
57
+ // reproduces that default, which is the point: `.pname` and `.handle` stay core's business.
58
+ // ── the stage: the row stays LIVE, and using it pauses the game ───────────────────────────
59
+ // queries: toolbar during a game · tapping posts while playing · auto pause · data-stage-silent
60
+ //
61
+ // The row went inert for one version and the founder unlocked it: every control stays enabled,
62
+ // and touching ANY of them while a game is playing asks the game to pause first — so a player who
63
+ // reaches for the posts mid-flight gets a paused gull and the feed, in one tap. The event is not
64
+ // blocked: the action proceeds naturally while the pause lands beside it (a locked page still
65
+ // scrolls programmatically, so even the hash navigation works before the unlock).
66
+ //
67
+ // Declarative seam, as asked: an element that must NOT pause the game opts out by carrying
68
+ // `data-stage-silent` — on itself or any ancestor. Everything else pauses by default, because a
69
+ // rule with a default and an opt-out survives a fleet; a per-element opt-in is the third copy
70
+ // that forgets.
71
+ document.querySelector(".toolbar")?.addEventListener(
72
+ "click",
73
+ (e) => {
74
+ if (document.documentElement.dataset.stage !== "playing") return;
75
+ const hit = (e.target as Element | null)?.closest?.("a, button, select, [role=button]");
76
+ if (!hit || hit.closest("[data-stage-silent]")) return;
77
+ document.dispatchEvent(new CustomEvent("justai:pause-request"));
78
+ },
79
+ true,
80
+ );
81
+
82
+ const bar = document.querySelector(".toolbar") as HTMLElement | null;
83
+ const kt = bar?.dataset.kernelTitle;
84
+ const pt = bar?.dataset.postsTitle;
85
+ personaScroll(
86
+ kt && pt
87
+ ? { titleFor: (id: string) => (id === "kernel" ? kt : id === "posts" ? pt : "") }
88
+ : undefined,
89
+ );