@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.
@@ -124,368 +124,7 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
124
124
  }
125
125
  </style>
126
126
 
127
- <script>
128
- // queries: how do I ask for a file from a persona · FilePicker pick method · the file picker
129
- // returns null · picker sheet does not open · atrium router for the picker
130
- //
131
- // ONE ROUTER PER PICKER, created on first open.
132
- //
133
- // The kernel in the frame discovers UPWARD — that is atrium's own rule, and it is what makes the
134
- // embed the invitation. So this page has to be a router before the frame can connect, and it names
135
- // its guest through `mount()` rather than trusting what the guest calls itself: a persona composing
136
- // two kernels at once would otherwise have both arrive calling themselves "files", and the second
137
- // would silently take the first one's place.
138
- import atrium from "@justai/atrium";
139
- import { PRESENTATION_PARAM, PRESENTATION_EMBEDDED } from "@justai/core";
140
-
141
- // queries: file id or path · which one should a persona remember · does renaming break a
142
- // reference · the durable handle for a file
143
- //
144
- // A **path** is where a file is standing right now, and it is how a PERSON says where something
145
- // is. Rename it and every path to it is a dangling string that still looks perfectly valid. An
146
- // **id** is the file: the kernel has stamped a `crypto.randomUUID()` on every node since the
147
- // first write, and `mv` never touches it.
148
- //
149
- // So a caller reacting to something a person just did may use a path. A caller REMEMBERING a
150
- // file across time — a project pointing at its logo, a document being saved back — must use the
151
- // id, and both are handed over here so that choice is available rather than argued for.
152
- interface Picked {
153
- path: string;
154
- /** WHAT IT IS, not where it is standing. Survives a rename and a move — remember this one. */
155
- id: string;
156
- name: string;
157
- mime: string;
158
- bytes: ArrayBuffer;
159
- /** The same file as a Blob — for URL.createObjectURL, an upload body, an <img> src. */
160
- blob(): Blob;
161
- /** The same file as a data: URL — for anything that must EMBED it (an SVG, a saved document). */
162
- dataUrl(): Promise<string>;
163
- }
164
- interface PickRequest { accept?: string; title?: string; timeout?: number }
165
- /**
166
- * What a persona hands over to be kept. `name` is a PROPOSAL — the person may rewrite it and
167
- * will choose the folder — so give it the best default you have, extension included.
168
- */
169
- interface SaveRequest {
170
- /** A PROPOSAL. The person may rewrite it — the extension is shown beside the field, not in it. */
171
- name?: string;
172
- /** The persona's format. Kept out of the name box, the way a desktop save dialog keeps it out. */
173
- ext?: string;
174
- /** Save BACK to a file already saved once. NO QUESTION IS ASKED: it was answered the first time. */
175
- id?: string;
176
- bytes: ArrayBuffer | Uint8Array;
177
- /**
178
- * THE FILE'S FACE — the ecosystem's optional standard, and worth taking.
179
- *
180
- * Most files can show themselves; a persona's OWN format cannot, and the persona is the only
181
- * thing in the system that knows what it looks like. Hand over a small render (a 256px PNG is
182
- * plenty) and the file has a face in the list and in its detail sheet instead of a large empty
183
- * box saying there is no preview for this kind. Say nothing and nothing is lost.
184
- *
185
- * Kept small on purpose: the kernel drops a face over 64 KB rather than let a thumbnail become
186
- * a second copy of the work in an evictable quota. Over the ceiling the FILE is still written —
187
- * a cosmetic loss must never become a refused save.
188
- */
189
- thumb?: ArrayBuffer | Uint8Array | Blob;
190
- mime?: string;
191
- title?: string;
192
- timeout?: number;
193
- }
194
- interface Saved { path: string; id: string; name: string }
195
-
196
- for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
197
- const id = root.dataset.picker!;
198
- const host = document.getElementById(id) ?? root.closest("dialog");
199
- const found = document.getElementById(`${id}-sheet`) as HTMLDialogElement | null;
200
- const frame = root.querySelector<HTMLIFrameElement>("[data-frame]")!;
201
- if (!found) continue;
202
- const sheet = found;
203
-
204
- let svc: any = null;
205
- // THE GUEST HAS TO ARRIVE BEFORE ANYONE CAN BE ASKED ANYTHING.
206
- //
207
- // Caught by this cut's own spec on its first run, and it failed in the shape this ecosystem
208
- // keeps naming: nothing threw where a person could see it. `proxy()` to a service that has not
209
- // connected yet rejects immediately, the rejection was swallowed into `null`, and the sheet
210
- // opened and closed again inside one frame — a picker that "just doesn't work", with a clean
211
- // console. The frame is created on the first open, so the very first pick is ALWAYS the race.
212
- //
213
- // atrium already announces the arrival; this simply waits for it. Resolved once and kept: the
214
- // guest connects once and stays.
215
- let arrived: Promise<void> | null = null;
216
- async function router() {
217
- if (svc) return svc;
218
- // A name of our own, so a persona already running atrium for something else is not disturbed.
219
- //
220
- // DERIVED FROM THE DOM id, AND SANITISED. atrium's ids are a grammar, not free text — "/"
221
- // separates hops in a service path, so it rejects anything outside [a-z0-9._-]. A DOM id is
222
- // free text, and `picker:logoPicker` threw on the very first run of this cut's own spec. The
223
- // consumer must not have to know atrium's grammar to name a picker; that is the homework law.
224
- svc = await atrium({
225
- id: "picker-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x"),
226
- router: true,
227
- mount: ({ source }: any) => (source === frame.contentWindow ? "files" : null),
228
- });
229
- arrived = new Promise<void>((resolve) => {
230
- svc.on("connected", (e: any) => { if (e?.name === "files") resolve(); });
231
- });
232
- return svc;
233
- }
234
-
235
- // queries: the picker and the saver share one frame · why is save a method on the picker ·
236
- // asking the kernel over atrium · one sheet two questions
237
- //
238
- // ONE FRAME, ONE BUS, TWO QUESTIONS. Everything below the proxy call is identical for asking
239
- // for a file and for handing one over — the lazy frame, the presentation declaration, the
240
- // heading, the two ways a sheet can end. Extracted so the second question inherits the first
241
- // one's scars instead of being written again and re-earning them.
242
- // Set the frame's src on first use. THREE callers need it now, and the presentation declaration
243
- // must be identical in all three — a fourth that copied it slightly wrong is how a page ends up
244
- // announcing itself as standalone on the one path that forgot to say otherwise.
245
- function ensureFrame() {
246
- if (frame.getAttribute("src")) return;
247
- // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
248
- // are two different situations, and the same controls do not belong in both — standalone
249
- // there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
250
- // the declaration rides in the URL and is available in the first paint. It is presentation
251
- // only: nothing that must not be forged is decided by it.
252
- const src = root.dataset.src!;
253
- frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
254
- }
255
-
256
- async function ask<T>(
257
- req: { title?: string },
258
- invoke: (bus: any) => Promise<T>,
259
- ): Promise<T | null> {
260
- const bus = await router();
261
- ensureFrame();
262
- // ONE HEADING, and it is the sheet's. A per-call title retitles the sheet rather than being
263
- // forwarded into the frame: the kernel renders a heading of its own for callers that have no
264
- // sheet around them, and sending it one here printed "Upload logo" twice, one under the other.
265
- const heading = sheet.querySelector<HTMLElement>(".sheet-title");
266
- if (heading && req.title) heading.textContent = req.title;
267
- sheet.showModal();
268
-
269
- // TWO WAYS THIS ENDS, and only one of them is the kernel's.
270
- //
271
- // The person may answer inside the frame — that is the real reply. Or they may dismiss the
272
- // sheet from OUT here, with Escape or the backdrop, which the kernel cannot see: it is a
273
- // different document and nobody told it. So the dismissal resolves this promise with `null`
274
- // and the kernel's later answer is dropped. Racing them is deliberate; the alternative is a
275
- // caller left waiting on a sheet that is no longer on the screen.
276
- const dismissed = new Promise<null>((resolve) => {
277
- sheet.addEventListener("close", () => resolve(null), { once: true });
278
- });
279
- const answered = arrived!.then(() => invoke(bus)).catch(() => null);
280
-
281
- const result = await Promise.race([answered, dismissed]);
282
- if (sheet.open) sheet.close();
283
- return (result as T) ?? null;
284
- }
285
-
286
- async function pick(req: PickRequest = {}): Promise<Picked | null> {
287
- const result = await ask(req, (bus) =>
288
- bus.proxy({
289
- service: "files",
290
- method: "pick",
291
- // `hostExit`: this cut draws the way out in its own sheet's bar, so the kernel must not
292
- // draw a second one in its footer beside the three sources. TOLD, never inferred — the
293
- // kernel is also embedded by hosts that frame it bare and give it no chrome at all, and
294
- // one that hid its own exit on a guess would leave that person unable to answer "nowhere".
295
- args: { accept: req.accept, hostExit: true },
296
- options: { timeout: req.timeout ?? 300000 },
297
- }));
298
- if (!result) return null;
299
-
300
- // THE CUT BRINGS ITS OWN GROUND, and bytes are not ground. Every consumer of this needs the
301
- // file in some other shape, and the obvious hand-written conversion is a trap:
302
- //
303
- // btoa(String.fromCharCode(...new Uint8Array(bytes)))
304
- //
305
- // makes one argument per BYTE, so a 7.5 MB photo is a 7.5-million-argument call and the stack
306
- // gives out — "Maximum call stack size exceeded", thrown far from anything that mentions size,
307
- // and invisible to any test whose fixture image is small. qr wrote exactly that line and a
308
- // person found it with a real photograph. It belongs here once, not in every persona.
309
- const r = result as any;
310
- const blob = () => new Blob([r.bytes], { type: r.mime });
311
- return {
312
- ...r,
313
- blob,
314
- dataUrl: () => new Promise<string>((resolve, reject) => {
315
- const rd = new FileReader();
316
- rd.onload = () => resolve(rd.result as string);
317
- rd.onerror = () => reject(rd.error);
318
- rd.readAsDataURL(blob()); // no ceiling, unlike the spread above
319
- }),
320
- } as Picked;
321
- }
322
-
323
- // queries: FilePicker save method · how does a persona keep what it made · save a project file ·
324
- // the persona project file · save returns null
325
- //
326
- // THE OTHER DIRECTION. A persona that makes something has, until now, had exactly two ways to
327
- // give it to the person who made it: the operating system's download folder, or a link they
328
- // must keep themselves. Both mean "you take it from here" — the persona forgets immediately,
329
- // and coming back tomorrow starts from nothing.
330
- //
331
- // This is the third: hand it to the person's own files, in this persona's origin, under a name
332
- // they chose. It is not `write` — the caller does not decide the name or the folder, because
333
- // those are what make a saved thing findable later and they are the person's to answer.
334
- //
335
- // await el.save({ name: "café wifi.qr", bytes, mime: "application/vnd.justai.qr+json" })
336
- // // → { path, name } | null
337
- //
338
- // `null` is a person declining, and it is not an error — the same posture a cancelled pick has.
339
- async function save(req: SaveRequest): Promise<Saved | null> {
340
- // THE CUT BRINGS ITS OWN GROUND. A caller holding a Uint8Array (which anything that encoded
341
- // text does) would otherwise have to know that the kernel wants the buffer, and that a typed
342
- // array's `.buffer` may be a larger pool it is only a window onto — a subtle way to save a
343
- // file with somebody else's bytes stuck to the end of it.
344
- // THE CUT BRINGS ITS OWN GROUND for both payloads. A caller holds whatever its work produced
345
- // — a typed array from an encoder, a Blob from a canvas — and must not have to know that a
346
- // typed array's `.buffer` may be a larger pool it is only a window onto, which is a subtle
347
- // way to save a file with somebody else's bytes stuck to the end of it.
348
- const buffer = async (v: any): Promise<ArrayBuffer | undefined> => {
349
- if (!v) return undefined;
350
- if (v instanceof ArrayBuffer) return v;
351
- if (typeof Blob !== "undefined" && v instanceof Blob) return v.arrayBuffer();
352
- return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
353
- };
354
- const bytes = (await buffer(req.bytes))!;
355
- const thumb = await buffer(req.thumb);
356
- const args = { name: req.name, ext: req.ext, id: req.id, bytes, thumb, mime: req.mime };
357
- // SAVING BACK OPENS NO SHEET, and that is the point rather than an optimisation. The person
358
- // named this file the first time; asking again on every save is not care, it is a persona
359
- // that cannot remember what it is working on. So this path never touches `ask`.
360
- //
361
- // BY ID AND NOT BY PATH: between opening a document and saving it, the person may have
362
- // renamed or moved it in the very list they are looking at. A path captured at open time
363
- // would then either miss — a second copy appearing under the old name — or land on whatever
364
- // has since taken that name, which is worse and completely silent.
365
- if (req.id) {
366
- const bus = await router();
367
- ensureFrame();
368
- return arrived!
369
- .then(() => bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 60000 } }))
370
- .catch(() => null) as Promise<Saved | null>;
371
- }
372
- return ask(req, (bus) =>
373
- bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 300000 } }));
374
- }
375
-
376
- // queries: read a file the persona already has a path to · FilePicker read · restore an asset a
377
- // project points at · read without asking the person · does read open the sheet
378
- //
379
- // THE ONE VERB THAT ASKS NOBODY, AND WHY THAT IS NOT A HOLE.
380
- //
381
- // `pick` and `save` are interactions: the person answers. `read` is not — it takes a path the
382
- // person ALREADY gave, on an earlier pick, and it exists because of what the alternative costs.
383
- //
384
- // A project file that references an asset ("this code wears that logo") is small. One that
385
- // swallows a copy is not: a 7.5 MB photograph is ~10 MB as base64, saved again on every edit,
386
- // and the kernel keeps five versions — 50 MB of an evictable ~1 GB quota, for one logo. The
387
- // kernel's own corpus warns that a full origin does not merely lose history, it loses the
388
- // CURRENT file. So references are the correct shape, and a reference nobody can follow is not
389
- // a reference.
390
- //
391
- // It opens no sheet, because there is no question. The policy still answers "who is asking" —
392
- // same-origin only, exactly as every other capability — so this widens no door: a caller that
393
- // could not read here a moment ago still cannot.
394
- async function read(ref: string | { path?: string; id?: string }): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
395
- // A bare string is a PATH, because that is what a string reads as. An id must be said out
396
- // loud — `read({ id })` — so a caller holding one kind can never pass it as the other.
397
- const where = typeof ref === "string" ? { path: ref } : ref;
398
- const bus = await router();
399
- ensureFrame();
400
- const [bytes, stat] = await arrived!
401
- .then(() => Promise.all([
402
- bus.proxy({ service: "files", method: "read", args: where, options: { timeout: 30000 } }),
403
- bus.proxy({ service: "files", method: "stat", args: where, options: { timeout: 30000 } }),
404
- ]))
405
- .catch(() => [null, null]);
406
- // A file the person deleted since is simply gone. `null` rather than a throw: a caller
407
- // restoring a project must be able to carry on without the asset, and an exception here
408
- // would take the whole project down with the logo.
409
- if (!bytes) return null;
410
- const blob = () => new Blob([bytes as ArrayBuffer], { type: (stat as any)?.mime || "application/octet-stream" });
411
- return {
412
- bytes: bytes as ArrayBuffer,
413
- blob,
414
- dataUrl: () => new Promise<string>((resolve, reject) => {
415
- const rd = new FileReader();
416
- rd.onload = () => resolve(rd.result as string);
417
- rd.onerror = () => reject(rd.error);
418
- rd.readAsDataURL(blob());
419
- }),
420
- };
421
- }
422
-
423
- // queries: write a file without asking · FilePicker write · a persona's own cached asset ·
424
- // keep a dependency a project points at · write vs save · save asks and I do not want
425
- // to ask · store a file the person did not make · project dependency from outside files
426
- //
427
- // THE SECOND VERB THAT ASKS NOBODY, AND THE ONE THAT WAS MISSING.
428
- //
429
- // `save` is deliberately not a friendlier `write`. The kernel's own corpus states the split and
430
- // it is not about permission — both write the same origin under the same policy:
431
- //
432
- // `write` is arithmetic and keeps the PERSONA's things — a cached asset, a draft it will
433
- // reopen itself. `save` is an interaction and hands a person THEIRS, because the two questions
434
- // that make a saved document a document — what is it called, where does it go — are the
435
- // person's to answer.
436
- //
437
- // The service has had `write` since the beginning. This door did not expose it, and for fifteen
438
- // personas that was invisible, because the only project dependency anybody had was `qr`'s logo —
439
- // which arrives through `pick`, so it is already in files and already has an id.
440
- //
441
- // `3dviewer` is the first persona whose dependency comes from OUTSIDE: a model dragged off a
442
- // desktop. Its project file must point at that model, so the model needs a home — and it is not
443
- // a document the person made here, it is the asset this persona will reopen itself. Exactly the
444
- // case the paragraph above describes, with no verb to express it. The persona was left choosing
445
- // between a doctrine it could not follow and a second Save sheet asking somebody to name a file
446
- // they did not create.
447
- //
448
- // ADDITIVE, and that matters: nothing that exists behaves differently. The policy still answers
449
- // "who is asking" — same-origin only, exactly as `read` does — so this widens no door.
450
- //
451
- // It returns the NODE, because a caller writing a project dependency needs the `id`: a path is
452
- // what a person renames and an id is what a thing IS.
453
- async function write(
454
- path: string,
455
- bytes: ArrayBuffer | Uint8Array,
456
- opts: { mime?: string; keepVersions?: number } = {},
457
- ): Promise<{ id: string; name: string; path: string } | null> {
458
- const bus = await router();
459
- ensureFrame();
460
- const body = bytes instanceof Uint8Array
461
- ? (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
462
- ? bytes.buffer
463
- : bytes.slice().buffer)
464
- : bytes;
465
- const node = await arrived!
466
- .then(() => bus.proxy({
467
- service: "files",
468
- method: "write",
469
- args: { path, bytes: body, mime: opts.mime, keepVersions: opts.keepVersions },
470
- // The same 30s ceiling `read` uses. A model is tens of megabytes and a phone's storage is
471
- // not fast; the default would time out on exactly the files this verb exists for.
472
- options: { timeout: 30000 },
473
- }))
474
- .catch(() => null);
475
- // A quota that is full, or a path the kernel refused. `null` rather than a throw, for the same
476
- // reason `read` returns null: a caller must be able to carry on and say so.
477
- if (!node) return null;
478
- const n = node as { id: string; name: string };
479
- return { id: n.id, name: n.name, path };
480
- }
481
-
482
- (host as any).pick = pick;
483
- (sheet as any).pick = pick;
484
- (host as any).save = save;
485
- (sheet as any).save = save;
486
- (host as any).read = read;
487
- (sheet as any).read = read;
488
- (host as any).write = write;
489
- (sheet as any).write = write;
490
- }
491
- </script>
127
+ <script>
128
+ // The behaviour lives in ./behaviour/filePicker.ts — see that file for why it is not in here.
129
+ import "./behaviour/filePicker";
130
+ </script>
@@ -124,66 +124,7 @@ const ordered = orderLocales(locales);
124
124
  .item svg { width: 17px; height: 17px; flex: none; }
125
125
  </style>
126
126
 
127
- <script>
128
- // What is left here is only what the LIST does: filter, and remember the choice. Opening,
129
- // dismissing and dragging all belong to <Sheet>.
130
- const picker = document.querySelector<HTMLElement>("[data-picker]");
131
- if (picker) {
132
- const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
133
- const sheet = document.getElementById("lang-sheet") as HTMLDialogElement | null;
134
- const search = picker.querySelector<HTMLInputElement>("[data-search]")!;
135
- const items = Array.from(picker.querySelectorAll<HTMLAnchorElement>(".item"));
136
-
137
- function filter() {
138
- const q = search.value.trim().toLowerCase();
139
- for (const it of items) {
140
- it.style.display = (it.dataset.name ?? "").includes(q) ? "" : "none";
141
- }
142
- }
143
- search.addEventListener("input", filter);
144
-
145
- trigger.addEventListener("click", () => {
146
- trigger.setAttribute("aria-expanded", "true");
147
- search.value = "";
148
- filter();
149
- // Focus the search ONLY where a hardware keyboard is already present. On a touch device,
150
- // focusing would throw the software keyboard over the list you opened the sheet to read — iOS
151
- // shows the list first and lets you reach for search. This is NOT a desktop/mobile branch of
152
- // the EXPERIENCE (the sheet is identical everywhere); it is a fact about input hardware, which
153
- // is why it asks about the pointer rather than about the viewport.
154
- if (matchMedia("(pointer: fine)").matches) setTimeout(() => search.focus(), 60);
155
- });
156
- sheet?.addEventListener("close", () => trigger.setAttribute("aria-expanded", "false"));
157
-
158
- // queries: language picker does not close · choosing a language does nothing · route-less
159
- // locale switch · the sheet stays open after picking a language · /me /files language
160
- //
161
- // Remember the choice, then GO — and "go" has two shapes, because two kinds of consumer exist.
162
- //
163
- // A persona with per-locale routes navigates to /<lang>/, carrying the section it was on. A
164
- // ROUTE-LESS surface — /me, /files: one bundle mounted at one path on thirteen hosts, translated
165
- // in the browser — hands back the page it is already on, because there is nowhere else to go.
166
- //
167
- // The second case was broken from the day those surfaces adopted this cut, and it broke quietly:
168
- // `location.assign("/me" + "#kernel")` on a page already at `/me#kernel` is the SAME URL, so the
169
- // browser correctly does nothing. The choice was written to localStorage, no navigation
170
- // happened, the sheet stayed open and the words stayed English. It looked like a picker that
171
- // ignores you.
172
- for (const it of items) {
173
- it.addEventListener("click", (e) => {
174
- try { localStorage.setItem("lang", it.dataset.lang ?? "en"); } catch { /* a locked jar is not a reason to refuse the click */ }
175
- const href = it.getAttribute("href");
176
- if (!href) return;
177
- const to = new URL(href, location.href);
178
- if (to.pathname === location.pathname && to.search === location.search) {
179
- // Same document: the only way to re-render is to reload it.
180
- e.preventDefault();
181
- location.reload();
182
- return;
183
- }
184
- // preserve the section you're viewing (Profile/Kernel/Posts) across the language switch
185
- if (location.hash) { e.preventDefault(); location.assign(href + location.hash); }
186
- });
187
- }
188
- }
189
- </script>
127
+ <script>
128
+ // The behaviour lives in ./behaviour/langPicker.ts — see that file for why it is not in here.
129
+ import "./behaviour/langPicker";
130
+ </script>