@justai/cuts 0.31.0 → 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 +6 -4
- package/src/FilePicker.astro +171 -0
- package/src/Sheet.astro +11 -0
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.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>
|
package/src/Sheet.astro
CHANGED
|
@@ -264,6 +264,17 @@ const { id, title, detent = "medium" } = Astro.props;
|
|
|
264
264
|
grip?.addEventListener("pointercancel", release);
|
|
265
265
|
}
|
|
266
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
|
+
|
|
267
278
|
document.addEventListener("click", (e) => {
|
|
268
279
|
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
|
|
269
280
|
if (!t) return;
|