@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.
- package/package.json +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.
|
|
4
|
-
"description": "A persona's named parts
|
|
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",
|
package/src/FilePicker.astro
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|