@justai/cuts 0.133.0 → 0.138.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 -3
- package/src/ActionList.astro +31 -27
- package/src/ActionRow.astro +68 -64
- package/src/BackToZone.astro +39 -35
- package/src/Base.astro +21 -3
- package/src/Catalogue.astro +95 -319
- package/src/CatalogueCard.astro +72 -68
- package/src/ChildPersona.astro +55 -51
- package/src/ConfirmSheet.astro +69 -65
- package/src/FilePicker.astro +21 -378
- package/src/LangPicker.astro +49 -104
- package/src/Posts.astro +177 -396
- package/src/Preroll.astro +204 -487
- package/src/Profile.astro +39 -35
- package/src/Segmented.astro +62 -111
- package/src/Sheet.astro +247 -507
- package/src/Toolbar.astro +288 -362
- package/src/behaviour/catalogue.ts +239 -0
- package/src/behaviour/filePicker.ts +372 -0
- package/src/behaviour/langPicker.ts +70 -0
- package/src/behaviour/posts.ts +234 -0
- package/src/behaviour/preroll.ts +298 -0
- package/src/behaviour/segmented.ts +64 -0
- package/src/behaviour/sheet.ts +300 -0
- package/src/behaviour/toolbar.ts +89 -0
- package/src/ground.css +124 -120
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// queries: catalogue behaviour · catalogue script · where did Catalogue.astro's script go ·
|
|
2
|
+
// testing a component's behaviour · component script is not type checked
|
|
3
|
+
//
|
|
4
|
+
// The catalogue — live search, the URL filter, and the deep links a campaign lands on.
|
|
5
|
+
//
|
|
6
|
+
// Lifted out of `Catalogue.astro`, where it was TypeScript that nothing read: a hoisted Astro script
|
|
7
|
+
// goes to esbuild, which strips annotations WITHOUT checking them, and this package had no tsconfig.
|
|
8
|
+
// Out here a compiler can open it and a test can call it. What stays in the component is the markup
|
|
9
|
+
// and the CSS, which is what a component is.
|
|
10
|
+
// queries: how does a new app appear on app.zone · my app is not on app.zone · new app missing
|
|
11
|
+
// from app.zone · runtime append registry · app.zone card unstyled · data-astro-cid
|
|
12
|
+
// insertAdjacentHTML · app.zone grid not sorted · where does a runtime card's category
|
|
13
|
+
// come from · why is an app's icon fetched from its own host
|
|
14
|
+
//
|
|
15
|
+
// app.zone's catalog: SSR renders the curated apps (for SEO + design); at RUNTIME we fetch the live
|
|
16
|
+
// registry (api.justai.pro/api/catalog) and APPEND any persona registered since the build — a
|
|
17
|
+
// *.app.zone citizen not already on the page — so a new app appears with NO app.zone redeploy.
|
|
18
|
+
//
|
|
19
|
+
// Two traps live in here. (1) Nodes built with insertAdjacentHTML sit OUTSIDE Astro's scoped CSS,
|
|
20
|
+
// so the scope attribute has to be copied off a real card onto every injected node — forget it and
|
|
21
|
+
// nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
|
|
22
|
+
// the append, seeds and newcomers together, so ordering is a property of the merged list, not of
|
|
23
|
+
// either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
|
|
24
|
+
(function () {
|
|
25
|
+
const grid = document.getElementById("grid");
|
|
26
|
+
if (!grid) return;
|
|
27
|
+
const locale = document.documentElement.lang || "en";
|
|
28
|
+
const NEW_MS = 7 * 864e5; // a persona wears NEW only for its first week (honest — from its createdAt)
|
|
29
|
+
const newLabel = grid.dataset.newLabel || "New";
|
|
30
|
+
// queries: which personas appear on this apex · runtime append kind · why is my game not listed
|
|
31
|
+
//
|
|
32
|
+
// WHICH CITIZENS THIS APEX LISTS — where the hostname test used to be. A bundled script cannot
|
|
33
|
+
// reach this component's props, so the two facts it needs ride the same data-attribute channel as
|
|
34
|
+
// newLabel and catMap. And `kind` is asked of the registry rather than inferred from a suffix: a
|
|
35
|
+
// persona's apex is a property of the persona, not a shape of its address.
|
|
36
|
+
const apexKind = grid.dataset.kind || "app";
|
|
37
|
+
const apexHost = grid.dataset.apex || "app.zone";
|
|
38
|
+
// The category label must be localized like the SSR path (catLabel), but the API returns the
|
|
39
|
+
// category in English regardless of ?locale=. So carry this page's cat dictionary through a data
|
|
40
|
+
// attribute (same channel as newLabel) and map through it — the English key stays the stable
|
|
41
|
+
// filter value in data-category, only the visible label is translated.
|
|
42
|
+
let catMap: Record<string, string> = {};
|
|
43
|
+
try { catMap = JSON.parse(grid.dataset.catMap || "{}"); } catch (e) {}
|
|
44
|
+
const catLabel = (c: string): string => catMap[c] ?? c;
|
|
45
|
+
// The card's line, in the storefront's voice rather than the persona's — same map the SSR path
|
|
46
|
+
// reads, arriving through the same channel as the two above. Keyed by host, which the API calls
|
|
47
|
+
// `home`. Missing entry falls through to the bio, so a locale with no taglines yet, or a persona
|
|
48
|
+
// that registered after they were written, simply keeps what it had.
|
|
49
|
+
let tagMap: Record<string, string> = {};
|
|
50
|
+
try { tagMap = JSON.parse(grid.dataset.taglines || "{}"); } catch (e) {}
|
|
51
|
+
|
|
52
|
+
// hosts already on the page (don't duplicate the SSR cards)
|
|
53
|
+
const have = new Set(
|
|
54
|
+
Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card")).map((a) => {
|
|
55
|
+
try { return new URL(a.href).host; } catch { return ""; }
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
// The appended cards must inherit CatalogueCard's scoped styles → copy the cid off a real card, and
|
|
59
|
+
// fall back to the inert one in #cardscope when this apex ships no seed. Without that fallback an
|
|
60
|
+
// empty-seed storefront renders every runtime card as unstyled markup — measured on pin.games' first
|
|
61
|
+
// night, where the shelf's only game arrived as blue underlined links.
|
|
62
|
+
const scopeTpl = document.getElementById("cardscope") as HTMLTemplateElement | null;
|
|
63
|
+
const sample = grid.querySelector<HTMLElement>("a.card")
|
|
64
|
+
?? scopeTpl?.content.querySelector<HTMLElement>("a.card")
|
|
65
|
+
?? null;
|
|
66
|
+
const scopeAttr = sample ? sample.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null : null;
|
|
67
|
+
|
|
68
|
+
const esc = (s: unknown): string =>
|
|
69
|
+
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
70
|
+
|
|
71
|
+
type Entry = { handle: string; home: string; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
|
|
72
|
+
const cardHTML = (a: Entry): string => {
|
|
73
|
+
const url = `https://${a.home}`;
|
|
74
|
+
// the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
|
|
75
|
+
// NO inline style. It used to carry the whole .icon rule inline, on the grounds that a node inserted with
|
|
76
|
+
// insertAdjacentHTML sits outside Astro's scoped CSS — but the append copies the cid onto every injected
|
|
77
|
+
// node, including this img, so the class does apply. What the inline copy actually did was OVERRIDE it:
|
|
78
|
+
// when the icon grew from 60px to 72px in AppCard.astro, this stayed at 60 and won on specificity, so the
|
|
79
|
+
// three seeded cards rendered a fifth larger than the seven that arrive from the registry. Nobody saw it
|
|
80
|
+
// until a reorder put them next to each other. (72 did not last — the size is back to 60, see
|
|
81
|
+
// AppCard.astro — but the trap is the same at any value: these attributes and that rule move together.)
|
|
82
|
+
// It protected nothing either: if the cid copy ever failed, the whole card would be unstyled, not just
|
|
83
|
+
// this image. One rule, in AppCard.astro.
|
|
84
|
+
const icon = `<img class="icon" src="${esc(a.avatar || `${url}/icon.svg`)}" alt="" width="60" height="60" loading="lazy" decoding="async">`;
|
|
85
|
+
// NEW only for the first week from the persona's own createdAt — never the hardcoded a.badge / a.status.
|
|
86
|
+
const created = a.createdAt ? Date.parse(a.createdAt) : NaN;
|
|
87
|
+
const isNew = Number.isFinite(created) && created <= Date.now() && Date.now() - created < NEW_MS;
|
|
88
|
+
const badge = isNew ? `<span class="badge new">${esc(newLabel)}</span>` : "";
|
|
89
|
+
const name = a.name || a.handle;
|
|
90
|
+
const cat = a.category || "";
|
|
91
|
+
// Must stay in step with AppCard.astro — this is the same card, built twice. The category span is
|
|
92
|
+
// gone from both: it lives in the filter row above the grid now.
|
|
93
|
+
return `<a class="card" href="${esc(url)}" data-name="${esc(name.toLowerCase())}" data-category="${esc(cat)}" data-id="${esc((a.handle || "").toLowerCase())}" data-added="${esc(a.createdAt || "")}">${icon}<div class="meta"><div class="top"><h2>${esc(name)}</h2>${badge}</div><p>${esc(tagMap[a.home] ?? a.bio ?? "")}</p></div><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></a>`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
fetch(`https://api.justai.pro/api/catalog?locale=${encodeURIComponent(locale)}&kind=${encodeURIComponent(apexKind)}`)
|
|
97
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
98
|
+
.then((data) => {
|
|
99
|
+
if (!data || !data.personas) return;
|
|
100
|
+
const newcomers = (data.personas as Entry[]).filter(
|
|
101
|
+
// The registry already returned only this apex's kind, so all that is left to exclude is the
|
|
102
|
+
// apex itself — a household head does not stand on its own shelf.
|
|
103
|
+
//
|
|
104
|
+
// queries: auth appeared on app.zone · a persona from another apex is listed · why both
|
|
105
|
+
// kind and home · residency
|
|
106
|
+
//
|
|
107
|
+
// TWO QUESTIONS, AND THE OLD LINE ANSWERED BOTH AT ONCE. `home.endsWith(".app.zone")` read
|
|
108
|
+
// as "is it an app" and was also, silently, "does it live here" — so replacing it with
|
|
109
|
+
// `kind` alone put `auth.justai.pro` and `justai.pro` on app.zone's shelf, both being kind
|
|
110
|
+
// `app`. Caught by comparing against the live page before deploying: fifteen cards where
|
|
111
|
+
// there had been thirteen, and a category nobody had ever seen.
|
|
112
|
+
//
|
|
113
|
+
// Both are asked now. KIND is what a persona IS — the registry's own property, and the
|
|
114
|
+
// reason a second apex can exist. RESIDENCY is where it LIVES, which is still its address.
|
|
115
|
+
(p) => p.home
|
|
116
|
+
&& p.home.endsWith("." + apexHost)
|
|
117
|
+
&& p.home !== apexHost && p.handle !== apexHost
|
|
118
|
+
&& !have.has(p.home),
|
|
119
|
+
);
|
|
120
|
+
for (const a of newcomers) {
|
|
121
|
+
grid.insertAdjacentHTML("beforeend", cardHTML(a));
|
|
122
|
+
const el = grid.lastElementChild as HTMLAnchorElement | null;
|
|
123
|
+
if (!el) continue;
|
|
124
|
+
if (scopeAttr) {
|
|
125
|
+
el.setAttribute(scopeAttr, "");
|
|
126
|
+
el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
|
|
127
|
+
}
|
|
128
|
+
try { const u = new URL(el.href); u.searchParams.set("ref", apexHost); u.searchParams.set("lang", locale); el.href = u.toString(); } catch {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The filter panel is rendered from the build-time seed, so a persona that registered afterwards
|
|
132
|
+
// can arrive carrying a category no row exists for — and it would then be unreachable by filter
|
|
133
|
+
// and invisible in the counts. Reconcile the panel against what is actually in the grid: every
|
|
134
|
+
// category present gets a row, and every count is recomputed rather than incremented, so this is
|
|
135
|
+
// correct however many times it runs.
|
|
136
|
+
const fpanel = document.getElementById("fpanel");
|
|
137
|
+
if (fpanel) {
|
|
138
|
+
// CLONE a row the server rendered instead of building HTML. Astro scopes per COMPONENT, so a
|
|
139
|
+
// hand-built node needs Zone.astro's own data-astro-cid-* copied onto it AND every child —
|
|
140
|
+
// forget it and nothing errors, the row just renders naked. A clone carries the cid, the tick
|
|
141
|
+
// and the inner spans with it, so that whole class of bug is gone rather than commented.
|
|
142
|
+
const proto = fpanel.querySelector<HTMLElement>('.opt[data-cat=""]');
|
|
143
|
+
const counts = new Map<string, number>();
|
|
144
|
+
for (const c of grid.querySelectorAll<HTMLAnchorElement>("a.card")) {
|
|
145
|
+
const k = c.dataset.category || "";
|
|
146
|
+
if (k) counts.set(k, (counts.get(k) || 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const allN = fpanel.querySelector<HTMLElement>('.opt[data-cat=""] .n');
|
|
149
|
+
if (allN) allN.textContent = String(grid.querySelectorAll("a.card").length);
|
|
150
|
+
for (const [cat, n] of counts) {
|
|
151
|
+
let opt = fpanel.querySelector<HTMLElement>(`.opt[data-cat="${CSS.escape(cat)}"]`);
|
|
152
|
+
if (!opt && proto) {
|
|
153
|
+
opt = proto.cloneNode(true) as HTMLElement;
|
|
154
|
+
// the prototype is the ticked "All" row — a clone must arrive unticked
|
|
155
|
+
opt.classList.remove("active");
|
|
156
|
+
opt.setAttribute("aria-pressed", "false");
|
|
157
|
+
opt.dataset.cat = cat;
|
|
158
|
+
const lb = opt.querySelector<HTMLElement>(".lb");
|
|
159
|
+
if (lb) lb.textContent = catLabel(cat);
|
|
160
|
+
fpanel.appendChild(opt);
|
|
161
|
+
}
|
|
162
|
+
if (!opt) continue;
|
|
163
|
+
const num = opt.querySelector<HTMLElement>(".n");
|
|
164
|
+
if (num) num.textContent = String(n);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Re-sort now, because the SSR order was decided from a seed that holds a fraction of the
|
|
168
|
+
// catalog — most personas arrive here, at runtime. "All" is pinned first; the rest go
|
|
169
|
+
// biggest-first, then alphabetical, so the fullest shelf is the first one read.
|
|
170
|
+
const ordered = Array.from(fpanel.querySelectorAll<HTMLElement>(".opt")).sort((x, y) => {
|
|
171
|
+
const cx = x.dataset.cat || "", cy = y.dataset.cat || "";
|
|
172
|
+
if (!cx) return -1;
|
|
173
|
+
if (!cy) return 1;
|
|
174
|
+
const nx = counts.get(cx) || 0, ny = counts.get(cy) || 0;
|
|
175
|
+
return ny - nx || cx.localeCompare(cy);
|
|
176
|
+
});
|
|
177
|
+
for (const c of ordered) fpanel.appendChild(c);
|
|
178
|
+
}
|
|
179
|
+
// One order for the whole grid, and it is CURATED now rather than chronological: src/data/order.ts
|
|
180
|
+
// lists hosts top to bottom, and that list is the grid. Anything NOT on the list goes ABOVE it, so
|
|
181
|
+
// a persona that registers with the platform announces itself at the top with no change to this
|
|
182
|
+
// repo — the list curates what is known, it does not gate what arrives. Several unlisted arrivals
|
|
183
|
+
// sort newest-first among themselves, which is the old rule, still doing the one job it is good at.
|
|
184
|
+
//
|
|
185
|
+
// Keyed by host on purpose. The same app reaches this grid by two routes with two different
|
|
186
|
+
// identifiers — Password Generator is id "password" in the seed and handle "password-generator" in
|
|
187
|
+
// the registry — and an identifier that only matches one route fails silently on the other. A card
|
|
188
|
+
// that fails to match reads as unlisted and jumps to the top, which is the worst possible failure
|
|
189
|
+
// for an ordering bug: it looks deliberate. The host is what both routes agree on.
|
|
190
|
+
// Anything that is not a list means "no curated order" — never an exception. This parsed a
|
|
191
|
+
// `{}` once and `{}.map is not a function` killed everything after it, including the event
|
|
192
|
+
// that tells the filter new cards exist: the shelf showed a card AND "the first games are on
|
|
193
|
+
// their way" underneath it.
|
|
194
|
+
const orderList: string[] = (() => {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(grid.dataset.order || "[]");
|
|
197
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
198
|
+
} catch { return []; }
|
|
199
|
+
})();
|
|
200
|
+
const rank = new Map(orderList.map((h, i) => [h, i]));
|
|
201
|
+
const UNLISTED = -1;
|
|
202
|
+
Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card"))
|
|
203
|
+
.map((el) => {
|
|
204
|
+
let host = "";
|
|
205
|
+
try { host = new URL(el.href).host; } catch {}
|
|
206
|
+
return { el, r: rank.has(host) ? (rank.get(host) as number) : UNLISTED, added: el.getAttribute("data-added") || "" };
|
|
207
|
+
})
|
|
208
|
+
.sort((x, y) => {
|
|
209
|
+
if (x.r !== y.r) return x.r - y.r; // listed follow the list; unlisted (-1) sort above all
|
|
210
|
+
if (x.r !== UNLISTED) return 0; // two listed cards cannot share a rank
|
|
211
|
+
if (x.added === y.added) return 0; // both unlisted → newest first, undated last
|
|
212
|
+
if (!x.added) return 1;
|
|
213
|
+
if (!y.added) return -1;
|
|
214
|
+
return x.added < y.added ? 1 : -1;
|
|
215
|
+
})
|
|
216
|
+
.forEach(({ el }) => grid.appendChild(el));
|
|
217
|
+
// queries: the empty message shows under a card · zone:cards never fired · the filter did not
|
|
218
|
+
// see the new cards · silent catch
|
|
219
|
+
//
|
|
220
|
+
// IN A `finally`, BECAUSE EVERYTHING ABOVE IT IS ORNAMENT AND THIS IS NOT. Once cards are in
|
|
221
|
+
// the DOM, the filter has to be told — it owns which of them are visible and whether the
|
|
222
|
+
// "nothing here" line is showing. Sorting them is a nicety; leaving the page claiming to be
|
|
223
|
+
// empty while a card sits above the claim is a defect a visitor sees.
|
|
224
|
+
//
|
|
225
|
+
// It sat at the end of a promise chain ending in `.catch(() => {})`, so one TypeError in the
|
|
226
|
+
// sort silenced it with nothing logged anywhere. That combination — a throw between the work
|
|
227
|
+
// and its announcement, and a catch that says nothing — is how a page ends up confidently
|
|
228
|
+
// wrong. The `finally` fixes the ordering; the catch below now says what it swallowed.
|
|
229
|
+
})
|
|
230
|
+
.catch((err) => {
|
|
231
|
+
// Never fatal — a storefront must render without the registry. But it must not be SILENT:
|
|
232
|
+
// the seed and the build-time layer are already on the page, and a swallowed error here is
|
|
233
|
+
// the difference between "no new personas today" and "the append is broken".
|
|
234
|
+
console.warn("[catalogue] runtime append failed", err);
|
|
235
|
+
})
|
|
236
|
+
.finally(() => {
|
|
237
|
+
document.dispatchEvent(new Event("zone:cards")); // cards are in — let the filter catch them
|
|
238
|
+
});
|
|
239
|
+
})();
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// queries: filePicker behaviour · filepicker script · where did FilePicker.astro's script go ·
|
|
2
|
+
// testing a component's behaviour · component script is not type checked
|
|
3
|
+
//
|
|
4
|
+
// The file picker — the door to a persona's own file system.
|
|
5
|
+
//
|
|
6
|
+
// Lifted out of `FilePicker.astro`, where it was TypeScript that nothing read: a hoisted Astro script
|
|
7
|
+
// goes to esbuild, which strips annotations WITHOUT checking them, and this package had no tsconfig.
|
|
8
|
+
// Out here a compiler can open it and a test can call it. What stays in the component is the markup
|
|
9
|
+
// and the CSS, which is what a component is.
|
|
10
|
+
// queries: how do I ask for a file from a persona · FilePicker pick method · the file picker
|
|
11
|
+
// returns null · picker sheet does not open · atrium router for the picker
|
|
12
|
+
//
|
|
13
|
+
// ONE ROUTER PER PICKER, created on first open.
|
|
14
|
+
//
|
|
15
|
+
// The kernel in the frame discovers UPWARD — that is atrium's own rule, and it is what makes the
|
|
16
|
+
// embed the invitation. So this page has to be a router before the frame can connect, and it names
|
|
17
|
+
// its guest through `mount()` rather than trusting what the guest calls itself: a persona composing
|
|
18
|
+
// two kernels at once would otherwise have both arrive calling themselves "files", and the second
|
|
19
|
+
// would silently take the first one's place.
|
|
20
|
+
import atrium from "@justai/atrium";
|
|
21
|
+
import { PRESENTATION_PARAM, PRESENTATION_EMBEDDED } from "@justai/core";
|
|
22
|
+
|
|
23
|
+
// queries: file id or path · which one should a persona remember · does renaming break a
|
|
24
|
+
// reference · the durable handle for a file
|
|
25
|
+
//
|
|
26
|
+
// A **path** is where a file is standing right now, and it is how a PERSON says where something
|
|
27
|
+
// is. Rename it and every path to it is a dangling string that still looks perfectly valid. An
|
|
28
|
+
// **id** is the file: the kernel has stamped a `crypto.randomUUID()` on every node since the
|
|
29
|
+
// first write, and `mv` never touches it.
|
|
30
|
+
//
|
|
31
|
+
// So a caller reacting to something a person just did may use a path. A caller REMEMBERING a
|
|
32
|
+
// file across time — a project pointing at its logo, a document being saved back — must use the
|
|
33
|
+
// id, and both are handed over here so that choice is available rather than argued for.
|
|
34
|
+
interface Picked {
|
|
35
|
+
path: string;
|
|
36
|
+
/** WHAT IT IS, not where it is standing. Survives a rename and a move — remember this one. */
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
mime: string;
|
|
40
|
+
bytes: ArrayBuffer;
|
|
41
|
+
/** The same file as a Blob — for URL.createObjectURL, an upload body, an <img> src. */
|
|
42
|
+
blob(): Blob;
|
|
43
|
+
/** The same file as a data: URL — for anything that must EMBED it (an SVG, a saved document). */
|
|
44
|
+
dataUrl(): Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
interface PickRequest { accept?: string; title?: string; timeout?: number }
|
|
47
|
+
/**
|
|
48
|
+
* What a persona hands over to be kept. `name` is a PROPOSAL — the person may rewrite it and
|
|
49
|
+
* will choose the folder — so give it the best default you have, extension included.
|
|
50
|
+
*/
|
|
51
|
+
interface SaveRequest {
|
|
52
|
+
/** A PROPOSAL. The person may rewrite it — the extension is shown beside the field, not in it. */
|
|
53
|
+
name?: string;
|
|
54
|
+
/** The persona's format. Kept out of the name box, the way a desktop save dialog keeps it out. */
|
|
55
|
+
ext?: string;
|
|
56
|
+
/** Save BACK to a file already saved once. NO QUESTION IS ASKED: it was answered the first time. */
|
|
57
|
+
id?: string;
|
|
58
|
+
bytes: ArrayBuffer | Uint8Array;
|
|
59
|
+
/**
|
|
60
|
+
* THE FILE'S FACE — the ecosystem's optional standard, and worth taking.
|
|
61
|
+
*
|
|
62
|
+
* Most files can show themselves; a persona's OWN format cannot, and the persona is the only
|
|
63
|
+
* thing in the system that knows what it looks like. Hand over a small render (a 256px PNG is
|
|
64
|
+
* plenty) and the file has a face in the list and in its detail sheet instead of a large empty
|
|
65
|
+
* box saying there is no preview for this kind. Say nothing and nothing is lost.
|
|
66
|
+
*
|
|
67
|
+
* Kept small on purpose: the kernel drops a face over 64 KB rather than let a thumbnail become
|
|
68
|
+
* a second copy of the work in an evictable quota. Over the ceiling the FILE is still written —
|
|
69
|
+
* a cosmetic loss must never become a refused save.
|
|
70
|
+
*/
|
|
71
|
+
thumb?: ArrayBuffer | Uint8Array | Blob;
|
|
72
|
+
mime?: string;
|
|
73
|
+
title?: string;
|
|
74
|
+
timeout?: number;
|
|
75
|
+
}
|
|
76
|
+
interface Saved { path: string; id: string; name: string }
|
|
77
|
+
|
|
78
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-picker]")) {
|
|
79
|
+
const id = root.dataset.picker!;
|
|
80
|
+
const host = document.getElementById(id) ?? root.closest("dialog");
|
|
81
|
+
const found = document.getElementById(`${id}-sheet`) as HTMLDialogElement | null;
|
|
82
|
+
const frame = root.querySelector<HTMLIFrameElement>("[data-frame]")!;
|
|
83
|
+
if (!found) continue;
|
|
84
|
+
const sheet = found;
|
|
85
|
+
|
|
86
|
+
let svc: any = null;
|
|
87
|
+
// THE GUEST HAS TO ARRIVE BEFORE ANYONE CAN BE ASKED ANYTHING.
|
|
88
|
+
//
|
|
89
|
+
// Caught by this cut's own spec on its first run, and it failed in the shape this ecosystem
|
|
90
|
+
// keeps naming: nothing threw where a person could see it. `proxy()` to a service that has not
|
|
91
|
+
// connected yet rejects immediately, the rejection was swallowed into `null`, and the sheet
|
|
92
|
+
// opened and closed again inside one frame — a picker that "just doesn't work", with a clean
|
|
93
|
+
// console. The frame is created on the first open, so the very first pick is ALWAYS the race.
|
|
94
|
+
//
|
|
95
|
+
// atrium already announces the arrival; this simply waits for it. Resolved once and kept: the
|
|
96
|
+
// guest connects once and stays.
|
|
97
|
+
let arrived: Promise<void> | null = null;
|
|
98
|
+
async function router() {
|
|
99
|
+
if (svc) return svc;
|
|
100
|
+
// A name of our own, so a persona already running atrium for something else is not disturbed.
|
|
101
|
+
//
|
|
102
|
+
// DERIVED FROM THE DOM id, AND SANITISED. atrium's ids are a grammar, not free text — "/"
|
|
103
|
+
// separates hops in a service path, so it rejects anything outside [a-z0-9._-]. A DOM id is
|
|
104
|
+
// free text, and `picker:logoPicker` threw on the very first run of this cut's own spec. The
|
|
105
|
+
// consumer must not have to know atrium's grammar to name a picker; that is the homework law.
|
|
106
|
+
svc = await atrium({
|
|
107
|
+
id: "picker-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x"),
|
|
108
|
+
router: true,
|
|
109
|
+
mount: ({ source }: any) => (source === frame.contentWindow ? "files" : null),
|
|
110
|
+
});
|
|
111
|
+
arrived = new Promise<void>((resolve) => {
|
|
112
|
+
svc.on("connected", (e: any) => { if (e?.name === "files") resolve(); });
|
|
113
|
+
});
|
|
114
|
+
return svc;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// queries: the picker and the saver share one frame · why is save a method on the picker ·
|
|
118
|
+
// asking the kernel over atrium · one sheet two questions
|
|
119
|
+
//
|
|
120
|
+
// ONE FRAME, ONE BUS, TWO QUESTIONS. Everything below the proxy call is identical for asking
|
|
121
|
+
// for a file and for handing one over — the lazy frame, the presentation declaration, the
|
|
122
|
+
// heading, the two ways a sheet can end. Extracted so the second question inherits the first
|
|
123
|
+
// one's scars instead of being written again and re-earning them.
|
|
124
|
+
// Set the frame's src on first use. THREE callers need it now, and the presentation declaration
|
|
125
|
+
// must be identical in all three — a fourth that copied it slightly wrong is how a page ends up
|
|
126
|
+
// announcing itself as standalone on the one path that forgot to say otherwise.
|
|
127
|
+
function ensureFrame() {
|
|
128
|
+
if (frame.getAttribute("src")) return;
|
|
129
|
+
// DECLARE THE PRESENTATION. A page opened by a person and a page opened by another persona
|
|
130
|
+
// are two different situations, and the same controls do not belong in both — standalone
|
|
131
|
+
// there is nothing to cancel. The frame is the slower truth (a handshake takes a beat), so
|
|
132
|
+
// the declaration rides in the URL and is available in the first paint. It is presentation
|
|
133
|
+
// only: nothing that must not be forged is decided by it.
|
|
134
|
+
const src = root.dataset.src!;
|
|
135
|
+
frame.setAttribute("src", src + (src.includes("?") ? "&" : "?") + PRESENTATION_PARAM + "=" + PRESENTATION_EMBEDDED);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function ask<T>(
|
|
139
|
+
req: { title?: string },
|
|
140
|
+
invoke: (bus: any) => Promise<T>,
|
|
141
|
+
): Promise<T | null> {
|
|
142
|
+
const bus = await router();
|
|
143
|
+
ensureFrame();
|
|
144
|
+
// ONE HEADING, and it is the sheet's. A per-call title retitles the sheet rather than being
|
|
145
|
+
// forwarded into the frame: the kernel renders a heading of its own for callers that have no
|
|
146
|
+
// sheet around them, and sending it one here printed "Upload logo" twice, one under the other.
|
|
147
|
+
const heading = sheet.querySelector<HTMLElement>(".sheet-title");
|
|
148
|
+
if (heading && req.title) heading.textContent = req.title;
|
|
149
|
+
sheet.showModal();
|
|
150
|
+
|
|
151
|
+
// TWO WAYS THIS ENDS, and only one of them is the kernel's.
|
|
152
|
+
//
|
|
153
|
+
// The person may answer inside the frame — that is the real reply. Or they may dismiss the
|
|
154
|
+
// sheet from OUT here, with Escape or the backdrop, which the kernel cannot see: it is a
|
|
155
|
+
// different document and nobody told it. So the dismissal resolves this promise with `null`
|
|
156
|
+
// and the kernel's later answer is dropped. Racing them is deliberate; the alternative is a
|
|
157
|
+
// caller left waiting on a sheet that is no longer on the screen.
|
|
158
|
+
const dismissed = new Promise<null>((resolve) => {
|
|
159
|
+
sheet.addEventListener("close", () => resolve(null), { once: true });
|
|
160
|
+
});
|
|
161
|
+
const answered = arrived!.then(() => invoke(bus)).catch(() => null);
|
|
162
|
+
|
|
163
|
+
const result = await Promise.race([answered, dismissed]);
|
|
164
|
+
if (sheet.open) sheet.close();
|
|
165
|
+
return (result as T) ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function pick(req: PickRequest = {}): Promise<Picked | null> {
|
|
169
|
+
const result = await ask(req, (bus) =>
|
|
170
|
+
bus.proxy({
|
|
171
|
+
service: "files",
|
|
172
|
+
method: "pick",
|
|
173
|
+
// `hostExit`: this cut draws the way out in its own sheet's bar, so the kernel must not
|
|
174
|
+
// draw a second one in its footer beside the three sources. TOLD, never inferred — the
|
|
175
|
+
// kernel is also embedded by hosts that frame it bare and give it no chrome at all, and
|
|
176
|
+
// one that hid its own exit on a guess would leave that person unable to answer "nowhere".
|
|
177
|
+
args: { accept: req.accept, hostExit: true },
|
|
178
|
+
options: { timeout: req.timeout ?? 300000 },
|
|
179
|
+
}));
|
|
180
|
+
if (!result) return null;
|
|
181
|
+
|
|
182
|
+
// THE CUT BRINGS ITS OWN GROUND, and bytes are not ground. Every consumer of this needs the
|
|
183
|
+
// file in some other shape, and the obvious hand-written conversion is a trap:
|
|
184
|
+
//
|
|
185
|
+
// btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
186
|
+
//
|
|
187
|
+
// makes one argument per BYTE, so a 7.5 MB photo is a 7.5-million-argument call and the stack
|
|
188
|
+
// gives out — "Maximum call stack size exceeded", thrown far from anything that mentions size,
|
|
189
|
+
// and invisible to any test whose fixture image is small. qr wrote exactly that line and a
|
|
190
|
+
// person found it with a real photograph. It belongs here once, not in every persona.
|
|
191
|
+
const r = result as any;
|
|
192
|
+
const blob = () => new Blob([r.bytes], { type: r.mime });
|
|
193
|
+
return {
|
|
194
|
+
...r,
|
|
195
|
+
blob,
|
|
196
|
+
dataUrl: () => new Promise<string>((resolve, reject) => {
|
|
197
|
+
const rd = new FileReader();
|
|
198
|
+
rd.onload = () => resolve(rd.result as string);
|
|
199
|
+
rd.onerror = () => reject(rd.error);
|
|
200
|
+
rd.readAsDataURL(blob()); // no ceiling, unlike the spread above
|
|
201
|
+
}),
|
|
202
|
+
} as Picked;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// queries: FilePicker save method · how does a persona keep what it made · save a project file ·
|
|
206
|
+
// the persona project file · save returns null
|
|
207
|
+
//
|
|
208
|
+
// THE OTHER DIRECTION. A persona that makes something has, until now, had exactly two ways to
|
|
209
|
+
// give it to the person who made it: the operating system's download folder, or a link they
|
|
210
|
+
// must keep themselves. Both mean "you take it from here" — the persona forgets immediately,
|
|
211
|
+
// and coming back tomorrow starts from nothing.
|
|
212
|
+
//
|
|
213
|
+
// This is the third: hand it to the person's own files, in this persona's origin, under a name
|
|
214
|
+
// they chose. It is not `write` — the caller does not decide the name or the folder, because
|
|
215
|
+
// those are what make a saved thing findable later and they are the person's to answer.
|
|
216
|
+
//
|
|
217
|
+
// await el.save({ name: "café wifi.qr", bytes, mime: "application/vnd.justai.qr+json" })
|
|
218
|
+
// // → { path, name } | null
|
|
219
|
+
//
|
|
220
|
+
// `null` is a person declining, and it is not an error — the same posture a cancelled pick has.
|
|
221
|
+
async function save(req: SaveRequest): Promise<Saved | null> {
|
|
222
|
+
// THE CUT BRINGS ITS OWN GROUND. A caller holding a Uint8Array (which anything that encoded
|
|
223
|
+
// text does) would otherwise have to know that the kernel wants the buffer, and that a typed
|
|
224
|
+
// array's `.buffer` may be a larger pool it is only a window onto — a subtle way to save a
|
|
225
|
+
// file with somebody else's bytes stuck to the end of it.
|
|
226
|
+
// THE CUT BRINGS ITS OWN GROUND for both payloads. A caller holds whatever its work produced
|
|
227
|
+
// — a typed array from an encoder, a Blob from a canvas — and must not have to know that a
|
|
228
|
+
// typed array's `.buffer` may be a larger pool it is only a window onto, which is a subtle
|
|
229
|
+
// way to save a file with somebody else's bytes stuck to the end of it.
|
|
230
|
+
const buffer = async (v: any): Promise<ArrayBuffer | undefined> => {
|
|
231
|
+
if (!v) return undefined;
|
|
232
|
+
if (v instanceof ArrayBuffer) return v;
|
|
233
|
+
if (typeof Blob !== "undefined" && v instanceof Blob) return v.arrayBuffer();
|
|
234
|
+
return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
|
|
235
|
+
};
|
|
236
|
+
const bytes = (await buffer(req.bytes))!;
|
|
237
|
+
const thumb = await buffer(req.thumb);
|
|
238
|
+
const args = { name: req.name, ext: req.ext, id: req.id, bytes, thumb, mime: req.mime };
|
|
239
|
+
// SAVING BACK OPENS NO SHEET, and that is the point rather than an optimisation. The person
|
|
240
|
+
// named this file the first time; asking again on every save is not care, it is a persona
|
|
241
|
+
// that cannot remember what it is working on. So this path never touches `ask`.
|
|
242
|
+
//
|
|
243
|
+
// BY ID AND NOT BY PATH: between opening a document and saving it, the person may have
|
|
244
|
+
// renamed or moved it in the very list they are looking at. A path captured at open time
|
|
245
|
+
// would then either miss — a second copy appearing under the old name — or land on whatever
|
|
246
|
+
// has since taken that name, which is worse and completely silent.
|
|
247
|
+
if (req.id) {
|
|
248
|
+
const bus = await router();
|
|
249
|
+
ensureFrame();
|
|
250
|
+
return arrived!
|
|
251
|
+
.then(() => bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 60000 } }))
|
|
252
|
+
.catch(() => null) as Promise<Saved | null>;
|
|
253
|
+
}
|
|
254
|
+
return ask(req, (bus) =>
|
|
255
|
+
bus.proxy({ service: "files", method: "save", args, options: { timeout: req.timeout ?? 300000 } }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// queries: read a file the persona already has a path to · FilePicker read · restore an asset a
|
|
259
|
+
// project points at · read without asking the person · does read open the sheet
|
|
260
|
+
//
|
|
261
|
+
// THE ONE VERB THAT ASKS NOBODY, AND WHY THAT IS NOT A HOLE.
|
|
262
|
+
//
|
|
263
|
+
// `pick` and `save` are interactions: the person answers. `read` is not — it takes a path the
|
|
264
|
+
// person ALREADY gave, on an earlier pick, and it exists because of what the alternative costs.
|
|
265
|
+
//
|
|
266
|
+
// A project file that references an asset ("this code wears that logo") is small. One that
|
|
267
|
+
// swallows a copy is not: a 7.5 MB photograph is ~10 MB as base64, saved again on every edit,
|
|
268
|
+
// and the kernel keeps five versions — 50 MB of an evictable ~1 GB quota, for one logo. The
|
|
269
|
+
// kernel's own corpus warns that a full origin does not merely lose history, it loses the
|
|
270
|
+
// CURRENT file. So references are the correct shape, and a reference nobody can follow is not
|
|
271
|
+
// a reference.
|
|
272
|
+
//
|
|
273
|
+
// It opens no sheet, because there is no question. The policy still answers "who is asking" —
|
|
274
|
+
// same-origin only, exactly as every other capability — so this widens no door: a caller that
|
|
275
|
+
// could not read here a moment ago still cannot.
|
|
276
|
+
async function read(ref: string | { path?: string; id?: string }): Promise<{ bytes: ArrayBuffer; blob(): Blob; dataUrl(): Promise<string> } | null> {
|
|
277
|
+
// A bare string is a PATH, because that is what a string reads as. An id must be said out
|
|
278
|
+
// loud — `read({ id })` — so a caller holding one kind can never pass it as the other.
|
|
279
|
+
const where = typeof ref === "string" ? { path: ref } : ref;
|
|
280
|
+
const bus = await router();
|
|
281
|
+
ensureFrame();
|
|
282
|
+
const [bytes, stat] = await arrived!
|
|
283
|
+
.then(() => Promise.all([
|
|
284
|
+
bus.proxy({ service: "files", method: "read", args: where, options: { timeout: 30000 } }),
|
|
285
|
+
bus.proxy({ service: "files", method: "stat", args: where, options: { timeout: 30000 } }),
|
|
286
|
+
]))
|
|
287
|
+
.catch(() => [null, null]);
|
|
288
|
+
// A file the person deleted since is simply gone. `null` rather than a throw: a caller
|
|
289
|
+
// restoring a project must be able to carry on without the asset, and an exception here
|
|
290
|
+
// would take the whole project down with the logo.
|
|
291
|
+
if (!bytes) return null;
|
|
292
|
+
const blob = () => new Blob([bytes as ArrayBuffer], { type: (stat as any)?.mime || "application/octet-stream" });
|
|
293
|
+
return {
|
|
294
|
+
bytes: bytes as ArrayBuffer,
|
|
295
|
+
blob,
|
|
296
|
+
dataUrl: () => new Promise<string>((resolve, reject) => {
|
|
297
|
+
const rd = new FileReader();
|
|
298
|
+
rd.onload = () => resolve(rd.result as string);
|
|
299
|
+
rd.onerror = () => reject(rd.error);
|
|
300
|
+
rd.readAsDataURL(blob());
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// queries: write a file without asking · FilePicker write · a persona's own cached asset ·
|
|
306
|
+
// keep a dependency a project points at · write vs save · save asks and I do not want
|
|
307
|
+
// to ask · store a file the person did not make · project dependency from outside files
|
|
308
|
+
//
|
|
309
|
+
// THE SECOND VERB THAT ASKS NOBODY, AND THE ONE THAT WAS MISSING.
|
|
310
|
+
//
|
|
311
|
+
// `save` is deliberately not a friendlier `write`. The kernel's own corpus states the split and
|
|
312
|
+
// it is not about permission — both write the same origin under the same policy:
|
|
313
|
+
//
|
|
314
|
+
// `write` is arithmetic and keeps the PERSONA's things — a cached asset, a draft it will
|
|
315
|
+
// reopen itself. `save` is an interaction and hands a person THEIRS, because the two questions
|
|
316
|
+
// that make a saved document a document — what is it called, where does it go — are the
|
|
317
|
+
// person's to answer.
|
|
318
|
+
//
|
|
319
|
+
// The service has had `write` since the beginning. This door did not expose it, and for fifteen
|
|
320
|
+
// personas that was invisible, because the only project dependency anybody had was `qr`'s logo —
|
|
321
|
+
// which arrives through `pick`, so it is already in files and already has an id.
|
|
322
|
+
//
|
|
323
|
+
// `3dviewer` is the first persona whose dependency comes from OUTSIDE: a model dragged off a
|
|
324
|
+
// desktop. Its project file must point at that model, so the model needs a home — and it is not
|
|
325
|
+
// a document the person made here, it is the asset this persona will reopen itself. Exactly the
|
|
326
|
+
// case the paragraph above describes, with no verb to express it. The persona was left choosing
|
|
327
|
+
// between a doctrine it could not follow and a second Save sheet asking somebody to name a file
|
|
328
|
+
// they did not create.
|
|
329
|
+
//
|
|
330
|
+
// ADDITIVE, and that matters: nothing that exists behaves differently. The policy still answers
|
|
331
|
+
// "who is asking" — same-origin only, exactly as `read` does — so this widens no door.
|
|
332
|
+
//
|
|
333
|
+
// It returns the NODE, because a caller writing a project dependency needs the `id`: a path is
|
|
334
|
+
// what a person renames and an id is what a thing IS.
|
|
335
|
+
async function write(
|
|
336
|
+
path: string,
|
|
337
|
+
bytes: ArrayBuffer | Uint8Array,
|
|
338
|
+
opts: { mime?: string; keepVersions?: number } = {},
|
|
339
|
+
): Promise<{ id: string; name: string; path: string } | null> {
|
|
340
|
+
const bus = await router();
|
|
341
|
+
ensureFrame();
|
|
342
|
+
const body = bytes instanceof Uint8Array
|
|
343
|
+
? (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
344
|
+
? bytes.buffer
|
|
345
|
+
: bytes.slice().buffer)
|
|
346
|
+
: bytes;
|
|
347
|
+
const node = await arrived!
|
|
348
|
+
.then(() => bus.proxy({
|
|
349
|
+
service: "files",
|
|
350
|
+
method: "write",
|
|
351
|
+
args: { path, bytes: body, mime: opts.mime, keepVersions: opts.keepVersions },
|
|
352
|
+
// The same 30s ceiling `read` uses. A model is tens of megabytes and a phone's storage is
|
|
353
|
+
// not fast; the default would time out on exactly the files this verb exists for.
|
|
354
|
+
options: { timeout: 30000 },
|
|
355
|
+
}))
|
|
356
|
+
.catch(() => null);
|
|
357
|
+
// A quota that is full, or a path the kernel refused. `null` rather than a throw, for the same
|
|
358
|
+
// reason `read` returns null: a caller must be able to carry on and say so.
|
|
359
|
+
if (!node) return null;
|
|
360
|
+
const n = node as { id: string; name: string };
|
|
361
|
+
return { id: n.id, name: n.name, path };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
(host as any).pick = pick;
|
|
365
|
+
(sheet as any).pick = pick;
|
|
366
|
+
(host as any).save = save;
|
|
367
|
+
(sheet as any).save = save;
|
|
368
|
+
(host as any).read = read;
|
|
369
|
+
(sheet as any).read = read;
|
|
370
|
+
(host as any).write = write;
|
|
371
|
+
(sheet as any).write = write;
|
|
372
|
+
}
|