@justai/cuts 0.37.0 → 0.39.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/FilePicker.astro +184 -18
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.37.0",
4
- "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.39.0",
4
+ "description": "A persona's named parts the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
@@ -36,6 +36,21 @@ import Sheet from "./Sheet.astro";
36
36
  * const picked = await document.getElementById("logoPicker").pick({ accept: "image/*" });
37
37
  * // → { path, name, mime, bytes } | null
38
38
  *
39
+ * ── IT ALSO GOES THE OTHER WAY (2026-07-27) ──────────────────────────────────────────────────
40
+ *
41
+ * The same element hands something BACK, which is how a persona keeps what a person made instead
42
+ * of pushing it into the operating system's downloads and forgetting it:
43
+ *
44
+ * const saved = await document.getElementById("logoPicker").save({
45
+ * name: "café wifi.qr", bytes, mime: "application/vnd.justai.qr+json",
46
+ * });
47
+ * // → { path, name } | null
48
+ *
49
+ * One element rather than two cuts, because it is one room: two frames would be two documents,
50
+ * two atrium routers and two copies of the same kernel open at once, to ask the same file system
51
+ * a question in each direction. The name stays `FilePicker` for now and that is a small debt —
52
+ * what it actually is, is the persona's door to its files.
53
+ *
39
54
  * A method on the element rather than a global, so two pickers on one page are two pickers and
40
55
  * nothing has to be coordinated through a name only one of them can own.
41
56
  *
@@ -60,7 +75,13 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
60
75
  <div class="jfp" id={id} data-picker={id} data-src={src}>
61
76
  {/* Empty until the first open. `title` is the accessible name of the document inside — a frame
62
77
  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>
78
+ {/* NO `loading="lazy"`, and its removal is a fix rather than a tidy-up.
79
+ The laziness that matters is the SRC, which is empty until somebody asks — so the attribute
80
+ bought nothing, and it cost the one call that opens no sheet. A lazy frame inside a closed
81
+ <dialog> is display:none, which the browser reads as "not near the viewport", so it defers
82
+ the load forever: `read()` waited on a guest that was never going to arrive, and it waited
83
+ silently. Caught by this cut's own spec, which is the whole argument for law 2. */}
84
+ <iframe class="jfp-frame" title={title} data-frame allow=""></iframe>
64
85
  </div>
65
86
  </Sheet>
66
87
 
@@ -95,8 +116,21 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
95
116
  import atrium from "@justai/atrium";
96
117
  import { PRESENTATION_PARAM, PRESENTATION_EMBEDDED } from "@justai/core";
97
118
 
119
+ // queries: file id or path · which one should a persona remember · does renaming break a
120
+ // reference · the durable handle for a file
121
+ //
122
+ // A **path** is where a file is standing right now, and it is how a PERSON says where something
123
+ // is. Rename it and every path to it is a dangling string that still looks perfectly valid. An
124
+ // **id** is the file: the kernel has stamped a `crypto.randomUUID()` on every node since the
125
+ // first write, and `mv` never touches it.
126
+ //
127
+ // So a caller reacting to something a person just did may use a path. A caller REMEMBERING a
128
+ // file across time — a project pointing at its logo, a document being saved back — must use the
129
+ // id, and both are handed over here so that choice is available rather than argued for.
98
130
  interface Picked {
99
131
  path: string;
132
+ /** WHAT IT IS, not where it is standing. Survives a rename and a move — remember this one. */
133
+ id: string;
100
134
  name: string;
101
135
  mime: string;
102
136
  bytes: ArrayBuffer;
@@ -106,6 +140,23 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
106
140
  dataUrl(): Promise<string>;
107
141
  }
108
142
  interface PickRequest { accept?: string; title?: string; timeout?: number }
143
+ /**
144
+ * What a persona hands over to be kept. `name` is a PROPOSAL — the person may rewrite it and
145
+ * will choose the folder — so give it the best default you have, extension included.
146
+ */
147
+ interface SaveRequest {
148
+ /** A PROPOSAL. The person may rewrite it — the extension is shown beside the field, not in it. */
149
+ name?: string;
150
+ /** The persona's format. Kept out of the name box, the way a desktop save dialog keeps it out. */
151
+ ext?: string;
152
+ /** Save BACK to a file already saved once. NO QUESTION IS ASKED: it was answered the first time. */
153
+ id?: string;
154
+ bytes: ArrayBuffer | Uint8Array;
155
+ mime?: string;
156
+ title?: string;
157
+ timeout?: number;
158
+ }
159
+ interface Saved { path: string; id: string; name: string }
109
160
 
110
161
  for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
111
162
  const id = root.dataset.picker!;
@@ -146,17 +197,33 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
146
197
  return svc;
147
198
  }
148
199
 
149
- async function pick(req: PickRequest = {}): Promise<Picked | null> {
200
+ // queries: the picker and the saver share one frame · why is save a method on the picker ·
201
+ // asking the kernel over atrium · one sheet two questions
202
+ //
203
+ // ONE FRAME, ONE BUS, TWO QUESTIONS. Everything below the proxy call is identical for asking
204
+ // for a file and for handing one over — the lazy frame, the presentation declaration, the
205
+ // heading, the two ways a sheet can end. Extracted so the second question inherits the first
206
+ // one's scars instead of being written again and re-earning them.
207
+ // Set the frame's src on first use. THREE callers need it now, and the presentation declaration
208
+ // must be identical in all three — a fourth that copied it slightly wrong is how a page ends up
209
+ // announcing itself as standalone on the one path that forgot to say otherwise.
210
+ function ensureFrame() {
211
+ if (frame.getAttribute("src")) return;
212
+ // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
213
+ // are two different situations, and the same controls do not belong in both — standalone
214
+ // there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
215
+ // the declaration rides in the URL and is available in the first paint. It is presentation
216
+ // only: nothing that must not be forged is decided by it.
217
+ const src = root.dataset.src!;
218
+ frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
219
+ }
220
+
221
+ async function ask<T>(
222
+ req: { title?: string },
223
+ invoke: (bus: any) => Promise<T>,
224
+ ): Promise<T | null> {
150
225
  const bus = await router();
151
- if (!frame.getAttribute("src")) {
152
- // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
153
- // are two different situations, and the same controls do not belong in both — standalone
154
- // there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
155
- // the declaration rides in the URL and is available in the first paint. It is presentation
156
- // only: nothing that must not be forged is decided by it.
157
- const src = root.dataset.src!;
158
- frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
159
- }
226
+ ensureFrame();
160
227
  // ONE HEADING, and it is the sheet's. A per-call title retitles the sheet rather than being
161
228
  // forwarded into the frame: the kernel renders a heading of its own for callers that have no
162
229
  // sheet around them, and sending it one here printed "Upload logo" twice, one under the other.
@@ -174,17 +241,21 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
174
241
  const dismissed = new Promise<null>((resolve) => {
175
242
  sheet.addEventListener("close", () => resolve(null), { once: true });
176
243
  });
177
- const answered = arrived!.then(() => bus
178
- .proxy({
244
+ const answered = arrived!.then(() => invoke(bus)).catch(() => null);
245
+
246
+ const result = await Promise.race([answered, dismissed]);
247
+ if (sheet.open) sheet.close();
248
+ return (result as T) ?? null;
249
+ }
250
+
251
+ async function pick(req: PickRequest = {}): Promise<Picked | null> {
252
+ const result = await ask(req, (bus) =>
253
+ bus.proxy({
179
254
  service: "files",
180
255
  method: "pick",
181
256
  args: { accept: req.accept },
182
257
  options: { timeout: req.timeout ?? 300000 },
183
- }))
184
- .catch(() => null);
185
-
186
- const result = await Promise.race([answered, dismissed]);
187
- if (sheet.open) sheet.close();
258
+ }));
188
259
  if (!result) return null;
189
260
 
190
261
  // THE CUT BRINGS ITS OWN GROUND, and bytes are not ground. Every consumer of this needs the
@@ -210,7 +281,102 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
210
281
  } as Picked;
211
282
  }
212
283
 
284
+ // queries: FilePicker save method · how does a persona keep what it made · save a project file ·
285
+ // the persona project file · save returns null
286
+ //
287
+ // THE OTHER DIRECTION. A persona that makes something has, until now, had exactly two ways to
288
+ // give it to the person who made it: the operating system's download folder, or a link they
289
+ // must keep themselves. Both mean "you take it from here" — the persona forgets immediately,
290
+ // and coming back tomorrow starts from nothing.
291
+ //
292
+ // This is the third: hand it to the person's own files, in this persona's origin, under a name
293
+ // they chose. It is not `write` — the caller does not decide the name or the folder, because
294
+ // those are what make a saved thing findable later and they are the person's to answer.
295
+ //
296
+ // await el.save({ name: "café wifi.qr", bytes, mime: "application/vnd.justai.qr+json" })
297
+ // // → { path, name } | null
298
+ //
299
+ // `null` is a person declining, and it is not an error — the same posture a cancelled pick has.
300
+ async function save(req: SaveRequest): Promise<Saved | null> {
301
+ // THE CUT BRINGS ITS OWN GROUND. A caller holding a Uint8Array (which anything that encoded
302
+ // text does) would otherwise have to know that the kernel wants the buffer, and that a typed
303
+ // array's `.buffer` may be a larger pool it is only a window onto — a subtle way to save a
304
+ // file with somebody else's bytes stuck to the end of it.
305
+ const b: any = req.bytes;
306
+ const bytes: ArrayBuffer =
307
+ b instanceof ArrayBuffer ? b : b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
308
+ const args = { name: req.name, ext: req.ext, id: req.id, bytes, mime: req.mime };
309
+ // SAVING BACK OPENS NO SHEET, and that is the point rather than an optimisation. The person
310
+ // named this file the first time; asking again on every save is not care, it is a persona
311
+ // that cannot remember what it is working on. So this path never touches `ask`.
312
+ //
313
+ // BY ID AND NOT BY PATH: between opening a document and saving it, the person may have
314
+ // renamed or moved it in the very list they are looking at. A path captured at open time
315
+ // would then either miss — a second copy appearing under the old name — or land on whatever
316
+ // has since taken that name, which is worse and completely silent.
317
+ if (req.id) {
318
+ const bus = await router();
319
+ ensureFrame();
320
+ return arrived!
321
+ .then(() => bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 60000 } }))
322
+ .catch(() => null) as Promise<Saved | null>;
323
+ }
324
+ return ask(req, (bus) =>
325
+ bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 300000 } }));
326
+ }
327
+
328
+ // queries: read a file the persona already has a path to · FilePicker read · restore an asset a
329
+ // project points at · read without asking the person · does read open the sheet
330
+ //
331
+ // THE ONE VERB THAT ASKS NOBODY, AND WHY THAT IS NOT A HOLE.
332
+ //
333
+ // `pick` and `save` are interactions: the person answers. `read` is not — it takes a path the
334
+ // person ALREADY gave, on an earlier pick, and it exists because of what the alternative costs.
335
+ //
336
+ // A project file that references an asset ("this code wears that logo") is small. One that
337
+ // swallows a copy is not: a 7.5 MB photograph is ~10 MB as base64, saved again on every edit,
338
+ // and the kernel keeps five versions — 50 MB of an evictable ~1 GB quota, for one logo. The
339
+ // kernel's own corpus warns that a full origin does not merely lose history, it loses the
340
+ // CURRENT file. So references are the correct shape, and a reference nobody can follow is not
341
+ // a reference.
342
+ //
343
+ // It opens no sheet, because there is no question. The policy still answers "who is asking" —
344
+ // same-origin only, exactly as every other capability — so this widens no door: a caller that
345
+ // could not read here a moment ago still cannot.
346
+ async function read(ref: string | { path?: string; id?: string }): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
347
+ // A bare string is a PATH, because that is what a string reads as. An id must be said out
348
+ // loud — `read({ id })` — so a caller holding one kind can never pass it as the other.
349
+ const where = typeof ref === "string" ? { path: ref } : ref;
350
+ const bus = await router();
351
+ ensureFrame();
352
+ const [bytes, stat] = await arrived!
353
+ .then(() => Promise.all([
354
+ bus.proxy({ service: "files", method: "read", args: where, options: { timeout: 30000 } }),
355
+ bus.proxy({ service: "files", method: "stat", args: where, options: { timeout: 30000 } }),
356
+ ]))
357
+ .catch(() => [null, null]);
358
+ // A file the person deleted since is simply gone. `null` rather than a throw: a caller
359
+ // restoring a project must be able to carry on without the asset, and an exception here
360
+ // would take the whole project down with the logo.
361
+ if (!bytes) return null;
362
+ const blob = () => new Blob([bytes as ArrayBuffer], { type: (stat as any)?.mime || "application/octet-stream" });
363
+ return {
364
+ bytes: bytes as ArrayBuffer,
365
+ blob,
366
+ dataUrl: () => new Promise<string>((resolve, reject) => {
367
+ const rd = new FileReader();
368
+ rd.onload = () => resolve(rd.result as string);
369
+ rd.onerror = () => reject(rd.error);
370
+ rd.readAsDataURL(blob());
371
+ }),
372
+ };
373
+ }
374
+
213
375
  (host as any).pick = pick;
214
376
  (sheet as any).pick = pick;
377
+ (host as any).save = save;
378
+ (sheet as any).save = save;
379
+ (host as any).read = read;
380
+ (sheet as any).read = read;
215
381
  }
216
382
  </script>