@justai/cuts 0.30.1 → 0.32.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.30.1",
4
- "description": "A persona's named parts the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.32.0",
4
+ "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
@@ -21,7 +21,8 @@
21
21
  "./Profile.astro": "./src/Profile.astro",
22
22
  "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
23
23
  "./Sheet.astro": "./src/Sheet.astro",
24
- "./ground.css": "./src/ground.css"
24
+ "./ground.css": "./src/ground.css",
25
+ "./FilePicker.astro": "./src/FilePicker.astro"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "astro": ">=5"
@@ -31,6 +32,7 @@
31
32
  },
32
33
  "dependencies": {
33
34
  "@justai/core": "^0.5.0",
34
- "@justai/ui": "^0.3.0"
35
+ "@justai/ui": "^0.3.0",
36
+ "@justai/atrium": "^0.1.9"
35
37
  }
36
38
  }
@@ -0,0 +1,171 @@
1
+ ---
2
+ // THE GROUND rides with the picker, same as every cut — law 1 is a file it imports, not a promise.
3
+ import "./ground.css";
4
+ import Sheet from "./Sheet.astro";
5
+ /**
6
+ * <FilePicker> — ask the PERSON for a file, from the persona's own files or from the device.
7
+ *
8
+ * WHY THIS EXISTS (founder, 2026-07-26). Every persona that needs a file has been reaching for a
9
+ * bare `<input type="file">`, which is one source, no memory, and the operating system's dialog
10
+ * rather than the persona's. His framing was the phone's:
11
+ *
12
+ * "bazen whatsapp dan bir resim paylasirken dogrudan ya camera ya da image listesini secmen
13
+ * gibi birsey"
14
+ *
15
+ * Two sources offered together — what you already have, and where you can get more. That is the
16
+ * shape, and it is the shape because the alternative asks a person to remember where they put
17
+ * something before they are allowed to look for it.
18
+ *
19
+ * ── WHAT IT ACTUALLY IS ──────────────────────────────────────────────────────────────────────
20
+ *
21
+ * A sheet containing `<persona>/files` — the file kernel, mounted by the edge on every persona host,
22
+ * running in THIS persona's origin. The picker does not implement picking: it asks the kernel, over
23
+ * atrium, and the kernel asks the person. So the rows offered here are the same rows a person sees
24
+ * when they open `/files` themselves, and anything taken from the device is kept, which means the
25
+ * second time there is nothing to upload.
26
+ *
27
+ * The call is an ordinary request whose RESPONSE happens to be a human tapping a row. Nothing polls,
28
+ * nothing reaches back into this page, and a cancel resolves `null` rather than throwing — choosing
29
+ * nothing is a choice.
30
+ *
31
+ * ── NO HOMEWORK (law 1) ──────────────────────────────────────────────────────────────────────
32
+ *
33
+ * The consumer writes no atrium, no iframe, no router, no protocol:
34
+ *
35
+ * <FilePicker id="logoPicker" />
36
+ * const picked = await document.getElementById("logoPicker").pick({ accept: "image/*" });
37
+ * // → { path, name, mime, bytes } | null
38
+ *
39
+ * A method on the element rather than a global, so two pickers on one page are two pickers and
40
+ * nothing has to be coordinated through a name only one of them can own.
41
+ *
42
+ * ── THE FRAME IS LAZY, AND THAT IS NOT ONLY ABOUT BYTES ──────────────────────────────────────
43
+ *
44
+ * `src` is set on first open. A persona that ships this cut and is never asked for a file therefore
45
+ * loads no second document — which matters for LCP (the fleet's red line), and matters more for the
46
+ * promise: a page that has not been asked for a file has not opened the room where the files are.
47
+ */
48
+ interface Props {
49
+ /** DOM id — what `document.getElementById(...).pick()` is called on. */
50
+ id: string;
51
+ /** The sheet's heading. The per-call `title` overrides it, for a persona that asks for two things. */
52
+ title?: string;
53
+ /** Where the kernel is mounted. Only ever changed by a test. */
54
+ src?: string;
55
+ }
56
+ const { id, title = "Choose a file", src = "/files" } = Astro.props;
57
+ ---
58
+
59
+ <Sheet id={`${id}-sheet`} title={title} detent="large">
60
+ <div class="jfp" id={id} data-picker={id} data-src={src}>
61
+ {/* Empty until the first open. `title` is the accessible name of the document inside — a frame
62
+ announced as "frame" is the one control on the page that says nothing about itself. */}
63
+ <iframe class="jfp-frame" title={title} data-frame loading="lazy" allow=""></iframe>
64
+ </div>
65
+ </Sheet>
66
+
67
+ <style>
68
+ /* The frame is given the sheet's height and takes it — the kernel inside drops its own full-screen
69
+ minimum when it is picking, precisely so it fits somebody else's sheet instead of scrolling
70
+ inside it. */
71
+ .jfp {
72
+ display: flex;
73
+ min-height: 62dvh;
74
+ }
75
+ .jfp-frame {
76
+ flex: 1;
77
+ width: 100%;
78
+ border: 0;
79
+ background: none;
80
+ color-scheme: light dark;
81
+ }
82
+ </style>
83
+
84
+ <script>
85
+ // queries: how do I ask for a file from a persona · FilePicker pick method · the file picker
86
+ // returns null · picker sheet does not open · atrium router for the picker
87
+ //
88
+ // ONE ROUTER PER PICKER, created on first open.
89
+ //
90
+ // The kernel in the frame discovers UPWARD — that is atrium's own rule, and it is what makes the
91
+ // embed the invitation. So this page has to be a router before the frame can connect, and it names
92
+ // its guest through `mount()` rather than trusting what the guest calls itself: a persona composing
93
+ // two kernels at once would otherwise have both arrive calling themselves "files", and the second
94
+ // would silently take the first one's place.
95
+ import atrium from "@justai/atrium";
96
+
97
+ interface Picked { path: string; name: string; mime: string; bytes: ArrayBuffer }
98
+ interface PickRequest { accept?: string; title?: string; timeout?: number }
99
+
100
+ for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
101
+ const id = root.dataset.picker!;
102
+ const host = document.getElementById(id) ?? root.closest("dialog");
103
+ const found = document.getElementById(`${id}-sheet`) as HTMLDialogElement | null;
104
+ const frame = root.querySelector<HTMLIFrameElement>("[data-frame]")!;
105
+ if (!found) continue;
106
+ const sheet = found;
107
+
108
+ let svc: any = null;
109
+ // THE GUEST HAS TO ARRIVE BEFORE ANYONE CAN BE ASKED ANYTHING.
110
+ //
111
+ // Caught by this cut's own spec on its first run, and it failed in the shape this ecosystem
112
+ // keeps naming: nothing threw where a person could see it. `proxy()` to a service that has not
113
+ // connected yet rejects immediately, the rejection was swallowed into `null`, and the sheet
114
+ // opened and closed again inside one frame — a picker that "just doesn't work", with a clean
115
+ // console. The frame is created on the first open, so the very first pick is ALWAYS the race.
116
+ //
117
+ // atrium already announces the arrival; this simply waits for it. Resolved once and kept: the
118
+ // guest connects once and stays.
119
+ let arrived: Promise<void> | null = null;
120
+ async function router() {
121
+ if (svc) return svc;
122
+ // A name of our own, so a persona already running atrium for something else is not disturbed.
123
+ //
124
+ // DERIVED FROM THE DOM id, AND SANITISED. atrium's ids are a grammar, not free text — "/"
125
+ // separates hops in a service path, so it rejects anything outside [a-z0-9._-]. A DOM id is
126
+ // free text, and `picker:logoPicker` threw on the very first run of this cut's own spec. The
127
+ // consumer must not have to know atrium's grammar to name a picker; that is the homework law.
128
+ svc = await atrium({
129
+ id: "picker-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x"),
130
+ router: true,
131
+ mount: ({ source }: any) => (source === frame.contentWindow ? "files" : null),
132
+ });
133
+ arrived = new Promise<void>((resolve) => {
134
+ svc.on("connected", (e: any) => { if (e?.name === "files") resolve(); });
135
+ });
136
+ return svc;
137
+ }
138
+
139
+ async function pick(req: PickRequest = {}): Promise<Picked | null> {
140
+ const bus = await router();
141
+ if (!frame.getAttribute("src")) frame.setAttribute("src", root.dataset.src!);
142
+ sheet.showModal();
143
+
144
+ // TWO WAYS THIS ENDS, and only one of them is the kernel's.
145
+ //
146
+ // The person may answer inside the frame — that is the real reply. Or they may dismiss the
147
+ // sheet from OUT here, with Escape or the backdrop, which the kernel cannot see: it is a
148
+ // different document and nobody told it. So the dismissal resolves this promise with `null`
149
+ // and the kernel's later answer is dropped. Racing them is deliberate; the alternative is a
150
+ // caller left waiting on a sheet that is no longer on the screen.
151
+ const dismissed = new Promise<null>((resolve) => {
152
+ sheet.addEventListener("close", () => resolve(null), { once: true });
153
+ });
154
+ const answered = arrived!.then(() => bus
155
+ .proxy({
156
+ service: "files",
157
+ method: "pick",
158
+ args: { accept: req.accept, title: req.title },
159
+ options: { timeout: req.timeout ?? 300000 },
160
+ }))
161
+ .catch(() => null);
162
+
163
+ const result = await Promise.race([answered, dismissed]);
164
+ if (sheet.open) sheet.close();
165
+ return (result as Picked) ?? null;
166
+ }
167
+
168
+ (host as any).pick = pick;
169
+ (sheet as any).pick = pick;
170
+ }
171
+ </script>
@@ -50,7 +50,11 @@ const ordered = orderLocales(locales);
50
50
  every page). `name` answers it without the one risk an `id` would carry — an id must be
51
51
  unique in a document. The value is never submitted: this input lives in no form and filters
52
52
  the list client-side. */}
53
- <input type="search" class="search" name="lang-search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
53
+ {/* `id` as well as `name` now. The id is the runtime-i18n hook (see <Sheet>'s title): a
54
+ consumer that translates in the browser re-writes this placeholder from its own dictionary.
55
+ Safe because the picker already assumes one instance per page — <Sheet> requires a unique
56
+ id and this file hardcodes `lang-sheet`. */}
57
+ <input type="search" class="search" id="lang-search" name="lang-search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
54
58
  <div class="list" role="listbox" aria-label={label} data-list>
55
59
  {ordered.map((l) => (
56
60
  <a
package/src/Sheet.astro CHANGED
@@ -61,7 +61,12 @@ interface Props {
61
61
  const { id, title, detent = "medium" } = Astro.props;
62
62
  ---
63
63
 
64
- <dialog class="sheet" id={id} data-detent={detent} aria-label={title}>
64
+ {/* aria-labelledby over aria-label when there IS a title, so the accessible name and the visible
65
+ one are the same string — and `${id}-title` is a STABLE HOOK, the same contract ConfirmSheet
66
+ exposes. A surface that translates at RUNTIME (the vault, the feed — one bundle, thirteen
67
+ languages, no per-locale routes) renders this cut in English and re-labels it from its own
68
+ dictionary. Without the hook such a consumer cannot adopt the cut at all, and writes its own. */}
69
+ <dialog class="sheet" id={id} data-detent={detent} aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Sheet"}>
65
70
  {/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
66
71
  hands focus to the first focusable descendant — which for a picker is the search field, and on
67
72
  a phone that throws the software keyboard over the list the visitor opened the sheet to read.
@@ -72,7 +77,7 @@ const { id, title, detent = "medium" } = Astro.props;
72
77
  the thing you see is the thing you can grab. Hidden from AX: Escape and the backdrop are the
73
78
  keyboard/assistive paths, and a decorative pill announced as a control is noise. */}
74
79
  <div class="sheet-grip" data-grip aria-hidden="true"><span></span></div>
75
- {title && <h2 class="sheet-title">{title}</h2>}
80
+ {title && <h2 class="sheet-title" id={`${id}-title`}>{title}</h2>}
76
81
  <div class="sheet-body"><slot /></div>
77
82
  </div>
78
83
  </dialog>
@@ -259,6 +264,17 @@ const { id, title, detent = "medium" } = Astro.props;
259
264
  grip?.addEventListener("pointercancel", release);
260
265
  }
261
266
 
267
+ // WIRED ON LOAD, not on the first trigger click.
268
+ //
269
+ // It used to be lazy, and the cost of that surfaced the moment a sheet was first opened by
270
+ // something other than a `data-sheet-open` trigger — <FilePicker> calls showModal() itself,
271
+ // because its sheet opens when a persona ASKS for a file rather than when a person presses a
272
+ // button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
273
+ // of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
274
+ const wireAll = () => document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
275
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
276
+ else wireAll();
277
+
262
278
  document.addEventListener("click", (e) => {
263
279
  const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
264
280
  if (!t) return;