@justai/cuts 0.37.0 → 0.38.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.37.0",
4
- "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
3
+ "version": "0.38.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
 
@@ -106,6 +127,12 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
106
127
  dataUrl(): Promise<string>;
107
128
  }
108
129
  interface PickRequest { accept?: string; title?: string; timeout?: number }
130
+ /**
131
+ * What a persona hands over to be kept. `name` is a PROPOSAL — the person may rewrite it and
132
+ * will choose the folder — so give it the best default you have, extension included.
133
+ */
134
+ interface SaveRequest { name: string; bytes: ArrayBuffer | Uint8Array; mime?: string; title?: string; timeout?: number }
135
+ interface Saved { path: string; name: string }
109
136
 
110
137
  for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
111
138
  const id = root.dataset.picker!;
@@ -146,7 +173,17 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
146
173
  return svc;
147
174
  }
148
175
 
149
- async function pick(req: PickRequest = {}): Promise<Picked | null> {
176
+ // queries: the picker and the saver share one frame · why is save a method on the picker ·
177
+ // asking the kernel over atrium · one sheet two questions
178
+ //
179
+ // ONE FRAME, ONE BUS, TWO QUESTIONS. Everything below the proxy call is identical for asking
180
+ // for a file and for handing one over — the lazy frame, the presentation declaration, the
181
+ // heading, the two ways a sheet can end. Extracted so the second question inherits the first
182
+ // one's scars instead of being written again and re-earning them.
183
+ async function ask<T>(
184
+ req: { title?: string },
185
+ invoke: (bus: any) => Promise<T>,
186
+ ): Promise<T | null> {
150
187
  const bus = await router();
151
188
  if (!frame.getAttribute("src")) {
152
189
  // DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
@@ -174,17 +211,21 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
174
211
  const dismissed = new Promise<null>((resolve) => {
175
212
  sheet.addEventListener("close", () => resolve(null), { once: true });
176
213
  });
177
- const answered = arrived!.then(() => bus
178
- .proxy({
214
+ const answered = arrived!.then(() => invoke(bus)).catch(() => null);
215
+
216
+ const result = await Promise.race([answered, dismissed]);
217
+ if (sheet.open) sheet.close();
218
+ return (result as T) ?? null;
219
+ }
220
+
221
+ async function pick(req: PickRequest = {}): Promise<Picked | null> {
222
+ const result = await ask(req, (bus) =>
223
+ bus.proxy({
179
224
  service: "files",
180
225
  method: "pick",
181
226
  args: { accept: req.accept },
182
227
  options: { timeout: req.timeout ?? 300000 },
183
- }))
184
- .catch(() => null);
185
-
186
- const result = await Promise.race([answered, dismissed]);
187
- if (sheet.open) sheet.close();
228
+ }));
188
229
  if (!result) return null;
189
230
 
190
231
  // THE CUT BRINGS ITS OWN GROUND, and bytes are not ground. Every consumer of this needs the
@@ -210,7 +251,91 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
210
251
  } as Picked;
211
252
  }
212
253
 
254
+ // queries: FilePicker save method · how does a persona keep what it made · save a project file ·
255
+ // the persona project file · save returns null
256
+ //
257
+ // THE OTHER DIRECTION. A persona that makes something has, until now, had exactly two ways to
258
+ // give it to the person who made it: the operating system's download folder, or a link they
259
+ // must keep themselves. Both mean "you take it from here" — the persona forgets immediately,
260
+ // and coming back tomorrow starts from nothing.
261
+ //
262
+ // This is the third: hand it to the person's own files, in this persona's origin, under a name
263
+ // they chose. It is not `write` — the caller does not decide the name or the folder, because
264
+ // those are what make a saved thing findable later and they are the person's to answer.
265
+ //
266
+ // await el.save({ name: "café wifi.qr", bytes, mime: "application/vnd.justai.qr+json" })
267
+ // // → { path, name } | null
268
+ //
269
+ // `null` is a person declining, and it is not an error — the same posture a cancelled pick has.
270
+ async function save(req: SaveRequest): Promise<Saved | null> {
271
+ // THE CUT BRINGS ITS OWN GROUND. A caller holding a Uint8Array (which anything that encoded
272
+ // text does) would otherwise have to know that the kernel wants the buffer, and that a typed
273
+ // array's `.buffer` may be a larger pool it is only a window onto — a subtle way to save a
274
+ // file with somebody else's bytes stuck to the end of it.
275
+ const b: any = req.bytes;
276
+ const bytes: ArrayBuffer =
277
+ b instanceof ArrayBuffer ? b : b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
278
+ return ask(req, (bus) =>
279
+ bus.proxy({
280
+ service: "files",
281
+ method: "save",
282
+ args: { name: req.name, bytes, mime: req.mime },
283
+ options: { timeout: req.timeout ?? 300000 },
284
+ }));
285
+ }
286
+
287
+ // queries: read a file the persona already has a path to · FilePicker read · restore an asset a
288
+ // project points at · read without asking the person · does read open the sheet
289
+ //
290
+ // THE ONE VERB THAT ASKS NOBODY, AND WHY THAT IS NOT A HOLE.
291
+ //
292
+ // `pick` and `save` are interactions: the person answers. `read` is not — it takes a path the
293
+ // person ALREADY gave, on an earlier pick, and it exists because of what the alternative costs.
294
+ //
295
+ // A project file that references an asset ("this code wears that logo") is small. One that
296
+ // swallows a copy is not: a 7.5 MB photograph is ~10 MB as base64, saved again on every edit,
297
+ // and the kernel keeps five versions — 50 MB of an evictable ~1 GB quota, for one logo. The
298
+ // kernel's own corpus warns that a full origin does not merely lose history, it loses the
299
+ // CURRENT file. So references are the correct shape, and a reference nobody can follow is not
300
+ // a reference.
301
+ //
302
+ // It opens no sheet, because there is no question. The policy still answers "who is asking" —
303
+ // same-origin only, exactly as every other capability — so this widens no door: a caller that
304
+ // could not read here a moment ago still cannot.
305
+ async function read(path: string): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
306
+ const bus = await router();
307
+ if (!frame.getAttribute("src")) {
308
+ const src = root.dataset.src!;
309
+ frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
310
+ }
311
+ const [bytes, stat] = await arrived!
312
+ .then(() => Promise.all([
313
+ bus.proxy({ service: "files", method: "read", args: { path }, options: { timeout: 30000 } }),
314
+ bus.proxy({ service: "files", method: "stat", args: { path }, options: { timeout: 30000 } }),
315
+ ]))
316
+ .catch(() => [null, null]);
317
+ // A file the person deleted since is simply gone. `null` rather than a throw: a caller
318
+ // restoring a project must be able to carry on without the asset, and an exception here
319
+ // would take the whole project down with the logo.
320
+ if (!bytes) return null;
321
+ const blob = () => new Blob([bytes as ArrayBuffer], { type: (stat as any)?.mime || "application/octet-stream" });
322
+ return {
323
+ bytes: bytes as ArrayBuffer,
324
+ blob,
325
+ dataUrl: () => new Promise<string>((resolve, reject) => {
326
+ const rd = new FileReader();
327
+ rd.onload = () => resolve(rd.result as string);
328
+ rd.onerror = () => reject(rd.error);
329
+ rd.readAsDataURL(blob());
330
+ }),
331
+ };
332
+ }
333
+
213
334
  (host as any).pick = pick;
214
335
  (sheet as any).pick = pick;
336
+ (host as any).save = save;
337
+ (sheet as any).save = save;
338
+ (host as any).read = read;
339
+ (sheet as any).read = read;
215
340
  }
216
341
  </script>