@justai/cuts 0.111.0 → 0.112.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.111.0",
3
+ "version": "0.112.1",
4
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": {
@@ -13,25 +13,26 @@
13
13
  "src"
14
14
  ],
15
15
  "exports": {
16
+ "./ActionList.astro": "./src/ActionList.astro",
17
+ "./ActionRow.astro": "./src/ActionRow.astro",
18
+ "./AuthMount.astro": "./src/AuthMount.astro",
19
+ "./BackToZone.astro": "./src/BackToZone.astro",
16
20
  "./Base.astro": "./src/Base.astro",
21
+ "./Catalogue.astro": "./src/Catalogue.astro",
22
+ "./CatalogueCard.astro": "./src/CatalogueCard.astro",
23
+ "./ChildPersona.astro": "./src/ChildPersona.astro",
24
+ "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
25
+ "./FilePicker.astro": "./src/FilePicker.astro",
26
+ "./Files.astro": "./src/FilePicker.astro",
17
27
  "./LangPicker.astro": "./src/LangPicker.astro",
18
- "./BackToZone.astro": "./src/BackToZone.astro",
19
28
  "./Posts.astro": "./src/Posts.astro",
20
- "./Toolbar.astro": "./src/Toolbar.astro",
29
+ "./Preroll.astro": "./src/Preroll.astro",
21
30
  "./Profile.astro": "./src/Profile.astro",
22
- "./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
23
- "./Sheet.astro": "./src/Sheet.astro",
24
31
  "./Segmented.astro": "./src/Segmented.astro",
32
+ "./Sheet.astro": "./src/Sheet.astro",
33
+ "./Toolbar.astro": "./src/Toolbar.astro",
25
34
  "./ground.css": "./src/ground.css",
26
- "./FilePicker.astro": "./src/FilePicker.astro",
27
- "./ActionList.astro": "./src/ActionList.astro",
28
- "./ActionRow.astro": "./src/ActionRow.astro",
29
- "./Files.astro": "./src/FilePicker.astro",
30
- "./AuthMount.astro": "./src/AuthMount.astro",
31
- "./ChildPersona.astro": "./src/ChildPersona.astro",
32
- "./tagline.js": "./src/tagline.js",
33
- "./Catalogue.astro": "./src/Catalogue.astro",
34
- "./CatalogueCard.astro": "./src/CatalogueCard.astro"
35
+ "./tagline.js": "./src/tagline.js"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "astro": ">=5"
@@ -0,0 +1,316 @@
1
+ ---
2
+ // ── PREROLL — the shell holds the door until somebody knocks ────────────────────────────────────
3
+ //
4
+ // queries: preroll · the game loads on page open · why does the game not start immediately ·
5
+ // game persona kernel · tap to play · loading progress · game ready · atrium game ·
6
+ // first interaction · autoplay gesture · the frame is empty until you tap
7
+ //
8
+ // A game persona is two things at one address: the SHELL — profile, toolbar, feed, child personas —
9
+ // and the GAME, which runs in a frame of its own. This cut is the line between them, and it is the
10
+ // shell's side of the game–shell protocol (pin.games' `.justai/expert/the-game-shell-protocol.md`).
11
+ //
12
+ // ── WHY THE GAME IS NOT LOADED ON PAGE LOAD ───────────────────────────────────────────────────
13
+ //
14
+ // Three arguments, and they land on the same design, which is usually the sign it is the right one.
15
+ //
16
+ // IT COSTS THE PERSONA ITS BENCHMARKS. sudoku's game is 3.9 MB over 92 requests and reaches a
17
+ // playable state in about 4.7s over the network. Spent at first paint, that is LCP and blocking
18
+ // time on a page a search engine judges — and the persona is more than its game.
19
+ //
20
+ // THE VISITOR MAY NOT HAVE COME FOR THE GAME. They may want the feed, or to meet the character, or
21
+ // to see what it keeps about them. And a player who DOES tap play may want to read the feed while
22
+ // it loads and come back, which is only possible when the load is not the page.
23
+ //
24
+ // AND THE BROWSER ALREADY REQUIRES A GESTURE. Audio will not autoplay without one. A game with
25
+ // sound needs a tap before it can be itself, so the tap is not a cost this cut invents — it is one
26
+ // the platform imposes, turned into the moment the game introduces itself.
27
+ //
28
+ // ── THE HANDOVER, AND THE DISTINCTION IT RESTS ON ─────────────────────────────────────────────
29
+ //
30
+ // A LOADING SCREEN AND A CURTAIN ARE NOT THE SAME THING. A loading screen exists because waiting
31
+ // exists, and it belongs here — this cut has the gesture and it has the progress. A curtain is a
32
+ // title card, a logo animation, the game's own identity, and it is played even on an instant load;
33
+ // it belongs to the game.
34
+ //
35
+ // So a game that answers `{ curtain: true }` is handed the frame EARLY and covers the rest of its
36
+ // own loading. One that says nothing is held until it reports ready. What must never happen is both:
37
+ // a bar that fills here and a "Loading…" that starts there is a player waiting twice, with the
38
+ // second wait being theatre pretending to be work.
39
+
40
+ // THE GROUND rides with the cut. Law 1: a component that renders broken unless its consumer defined
41
+ // --accent, --surface-2 and --text-dim is a template with homework. The wait is an OPAQUE cover over
42
+ // a game that is drawing itself underneath, so a ground that resolves to nothing here does not mean
43
+ // "unstyled" — it means the player watches the game boot through the loading screen.
44
+ import "./ground.css";
45
+
46
+ interface Props {
47
+ /** The game's own document, served from this persona's origin. */
48
+ src: string;
49
+ /** The button. Keep it a verb. */
50
+ play: string;
51
+ /** One line under it — what this is, in the persona's voice. Optional. */
52
+ note?: string;
53
+ /** What the frame is called for somebody who cannot see it. */
54
+ title: string;
55
+ /** Said while the bytes are moving. */
56
+ loading?: string;
57
+ /** atrium ids are a grammar — [a-z0-9._-] — so this is sanitised rather than trusted. */
58
+ id?: string;
59
+ }
60
+ const { src, play, note, title, loading = "Loading", id = "game" } = Astro.props;
61
+ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x");
62
+ ---
63
+
64
+ <div class="pr" id="pr" data-src={src} data-atrium={safeId}>
65
+ {/* ── THE STAGE IS THE FLOW; THE DOOR AND THE WAIT ARE COVERS OVER IT ─────────────────────────
66
+ queries: the game boots at zero size · getBoundingClientRect returns 0 in the frame ·
67
+ the game jumps when the preroll hands over · display none iframe · hidden iframe
68
+ A GAME MUST BOOT AT ITS REAL SIZE. The first version of this cut put the stage, the door and
69
+ the wait side by side and switched between them with `display: none` — and a game loading
70
+ inside a `display: none` frame has no layout at all: every rect it measures is zero, images do
71
+ not decode, and rAF is suspended. It would have booted blind and then been handed a viewport it
72
+ had already measured as nothing.
73
+ So the stage is always laid out and the covers sit ON TOP of it. The frame reaches its final
74
+ size the moment it is created, and the handover is one element leaving rather than a reflow. */}
75
+ <div class="pr-stage" id="prStage" data-title={title}></div>
76
+
77
+ {/* THE DOOR. No iframe exists yet — that is the entire point, and it is why this markup is a
78
+ button and a slot rather than a frame with a cover over it. A hidden iframe still fetches. */}
79
+ <div class="pr-door" id="prDoor">
80
+ <slot name="art" />
81
+ <button class="pr-play" id="prPlay" type="button">{play}</button>
82
+ {note && <p class="pr-note">{note}</p>}
83
+ </div>
84
+
85
+ {/* The wait. Indeterminate until the game says otherwise: a bar that moves without measuring
86
+ anything teaches a player that the number means nothing. */}
87
+ <div class="pr-wait" id="prWait" hidden>
88
+ <div class="pr-bar" id="prBar" data-indeterminate><i id="prFill"></i></div>
89
+ <p class="pr-note">{loading}</p>
90
+ </div>
91
+ </div>
92
+
93
+ <style>
94
+ /* queries: the game frame has square corners · the game's colours do not match the page ·
95
+ * a seam around the game · full-bleed game · preroll radius
96
+ *
97
+ * ── THE SHELL OWNS THE FRAME'S SHAPE; THE GAME OWNS WHAT IS INSIDE IT ──────────────────────
98
+ *
99
+ * A game paints its own ground and it will not match the persona's — sudoku's art direction is
100
+ * nothing like app.zone's grey. Rendered edge to edge with square corners, that mismatch reads as
101
+ * a page that failed rather than as a game that has a look; rounded and clipped, it reads as an
102
+ * object ON the page, which is what it is. So the seam is made deliberate instead of denied.
103
+ *
104
+ * `--preroll-radius` is the escape hatch, and it is one line: a game persona that wants the frame
105
+ * to BE the screen — the shape sudoku takes — sets it to 0 and cancels the gutter itself. */
106
+ /* queries: the play button cannot be clicked · something else intercepts pointer events ·
107
+ * the preroll has no height · blank page preroll · law 1 geometry
108
+ *
109
+ * ── A COMPONENT BRINGS ITS OWN HEIGHT, NOT ONLY ITS OWN COLOURS ────────────────────────────
110
+ *
111
+ * `flex: 1` gives this cut the kernel's whole height — in a kernel. On a page that is not a flex
112
+ * column it means nothing, the only in-flow child is an empty stage, and the box collapses to
113
+ * zero: the door is then an absolutely positioned overlay inside a zero-height parent, clipped by
114
+ * the `overflow` below and sitting under whatever the page put above it. The blank-page fixture
115
+ * caught it as `<p>bare page — no consumer css</p> intercepts pointer events`, which is the same
116
+ * law 1 the ground.css import two screens up answers for colour.
117
+ *
118
+ * The floor is small enough that the flex case always wins it and large enough that a door is a
119
+ * door. Nothing real is shorter: the narrowest phone leaves ~486px under the toolbar. */
120
+ .pr {
121
+ position: relative; flex: 1; min-height: 240px; display: flex; flex-direction: column;
122
+ border-radius: var(--preroll-radius, var(--radius-lg)); overflow: hidden;
123
+ }
124
+
125
+ /* OPAQUE, and `--bg` rather than `--surface`: the fleet's `--surface` is a translucent overlay
126
+ tint, so a cover painted with it shows the booting game through itself. */
127
+ .pr-door, .pr-wait {
128
+ position: absolute; inset: 0; z-index: 1; background: var(--bg);
129
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
130
+ }
131
+ .pr-door[hidden], .pr-wait[hidden] { display: none; }
132
+
133
+ .pr-play {
134
+ font: inherit; font-weight: 600; font-size: var(--fs-md);
135
+ padding: 14px 40px; border: 0; border-radius: var(--radius-pill);
136
+ background: var(--accent); color: var(--accent-on); cursor: pointer;
137
+ transition: transform 0.18s var(--ease-spring);
138
+ }
139
+ .pr-play:active { transform: scale(0.94); }
140
+ .pr-note { margin: 0; color: var(--text-dim); font-size: var(--fs-sm); text-align: center; }
141
+
142
+ .pr-bar {
143
+ width: min(240px, 70%); height: 4px; border-radius: 999px;
144
+ background: var(--surface-2); overflow: hidden;
145
+ }
146
+ .pr-bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); }
147
+ /* A game that reports nothing gets a sweep rather than a number — honest about not knowing. */
148
+ .pr-bar[data-indeterminate] i {
149
+ width: 40%; animation: pr-sweep 1.1s var(--ease-out) infinite;
150
+ }
151
+ @keyframes pr-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
152
+ @media (prefers-reduced-motion: reduce) {
153
+ .pr-bar[data-indeterminate] i { animation: none; width: 100%; opacity: 0.5; }
154
+ }
155
+
156
+ .pr-stage { flex: 1; min-height: 0; }
157
+ .pr-stage :global(iframe) { display: block; width: 100%; height: 100%; border: 0; }
158
+ </style>
159
+
160
+ <script>
161
+ // queries: preroll script · the game never connects · atrium router game · boot · curtain
162
+ //
163
+ // THE SHELL IS THE ROUTER, AND IT IS ONE BEFORE THE FRAME EXISTS. atrium's rule is that a guest
164
+ // discovers UPWARD, so the room has to be standing before anybody can walk into it. `mount` names
165
+ // the guest by its WINDOW — never by what it calls itself, because a guest that announces its own
166
+ // name lets any frame on the page claim to be the game.
167
+ import atrium from "@justai/atrium";
168
+
169
+ const root = document.getElementById("pr");
170
+ if (root) {
171
+ const door = document.getElementById("prDoor") as HTMLElement;
172
+ const wait = document.getElementById("prWait") as HTMLElement;
173
+ const stage = document.getElementById("prStage") as HTMLElement;
174
+ const bar = document.getElementById("prBar") as HTMLElement;
175
+ const fill = document.getElementById("prFill") as HTMLElement;
176
+ const playBtn = document.getElementById("prPlay") as HTMLButtonElement;
177
+
178
+ const src = root.dataset.src as string;
179
+ const atriumId = root.dataset.atrium as string;
180
+
181
+ let started = false;
182
+
183
+ // The stage is never switched — it is underneath the whole time. `show(null)` is the handover:
184
+ // both covers leave and what was already drawing there is simply visible.
185
+ const show = (el: HTMLElement | null) => {
186
+ for (const n of [door, wait]) n.hidden = n !== el;
187
+ };
188
+
189
+ // ── PATIENCE IS MEASURED IN SILENCE, NOT IN SECONDS ──────────────────────────────────────
190
+ //
191
+ // queries: the game was cut off while it was still loading · the preroll gave up too early ·
192
+ // how long does the shell wait · slow connection · the bar vanished mid-load
193
+ //
194
+ // A total time limit is the wrong instrument and it fails in both directions: 20s abandons a
195
+ // real game on a real 3G connection, and 120s leaves a player staring at a dead frame. What
196
+ // separates the two cases is not how long it has taken — it is whether the game is still
197
+ // TALKING. So the clock is reset by every progress report, and only silence runs it down.
198
+ //
199
+ // A game that never speaks at all gets one window's worth from the moment the frame is created,
200
+ // which is the same promise made to a player whose game is simply broken.
201
+ const PATIENCE = 20000;
202
+ let watchdog: ReturnType<typeof setTimeout> | null = null;
203
+ // Assigned where the promise it resolves is created — a no-op until then, because a progress
204
+ // report cannot arrive before the frame exists and a `bump` that throws would take the bar
205
+ // down with it.
206
+ let bump = () => {};
207
+
208
+ const setProgress = (v: unknown) => {
209
+ bump();
210
+ const n = typeof v === "number" ? v : (v as any)?.value;
211
+ if (typeof n !== "number" || !isFinite(n)) return;
212
+ bar.removeAttribute("data-indeterminate");
213
+ fill.style.width = Math.max(0, Math.min(1, n)) * 100 + "%";
214
+ };
215
+
216
+ const start = async () => {
217
+ if (started) return;
218
+ started = true;
219
+ show(wait);
220
+
221
+ const frame = document.createElement("iframe");
222
+ frame.title = stage.dataset.title || "";
223
+ // `allow` carries the gesture forward: the tap that opened this frame is what lets the game
224
+ // play sound at all, and a frame without it starts muted for reasons nobody can see.
225
+ frame.setAttribute("allow", "autoplay; fullscreen");
226
+ frame.setAttribute("referrerpolicy", "same-origin");
227
+
228
+ let bus: any = null;
229
+ try {
230
+ bus = await atrium({
231
+ id: atriumId,
232
+ router: true,
233
+ mount: ({ source }: any) => (source === frame.contentWindow ? "game" : null),
234
+ });
235
+ } catch {
236
+ // atrium failing must not cost the player the game: mount the frame and hand over blind.
237
+ // A game that cannot speak still runs; it simply gets no progress and no early curtain.
238
+ }
239
+
240
+ const arrived = bus
241
+ ? new Promise<void>((resolve) => bus.on("connected", (e: any) => { if (e?.name === "game") resolve(); }))
242
+ : Promise.resolve();
243
+
244
+ stage.appendChild(frame);
245
+ // ── WHAT CROSSES INTO THE FRAME: AN ALLOWLIST, AND A SHORT ONE ──────────────────────────
246
+ //
247
+ // Forwarding the persona's whole query string would be the obvious convenience and it is a
248
+ // privacy hole: a `?data=` envelope, a campaign's utm tags and anything else a person arrived
249
+ // carrying would be handed to a frame that will one day be a third party's deployment. So the
250
+ // shell passes exactly two flags, both of them DEBUG instruments that belong to whoever is
251
+ // looking at the page rather than to whoever built the game: `xray`, the framework's design
252
+ // grid, and `slow`, which asks a game to spread its own loading out far enough to watch.
253
+ // A game that does not understand either simply ignores them.
254
+ const here = new URLSearchParams(location.search);
255
+ const url = new URL(src, location.href);
256
+ for (const flag of ["xray", "slow"]) if (here.has(flag)) url.searchParams.set(flag, here.get(flag) ?? "");
257
+ frame.src = url.pathname + url.search;
258
+
259
+ // ── THE HANDOVER ─────────────────────────────────────────────────────────────────────────
260
+ //
261
+ // ONE CALL CARRIES BOTH. atrium's `proxy` takes a progress callback and resolves when the
262
+ // method returns, so `boot` is the whole protocol: the game reports as it loads and answers
263
+ // when it can draw. No second channel, no polling, and nothing to keep in sync.
264
+ //
265
+ // ** THE EXPLICIT `timeout` IS LOAD-BEARING. ** atrium's default transaction timeout is ONE
266
+ // SECOND — right for a `pick` or an `echo`, and catastrophic here: every game on earth takes
267
+ // longer than a second to load, so `boot` rejected at 1.000s, the catch below swallowed it,
268
+ // and the shell handed over an empty frame while the game was still fetching. It failed the
269
+ // same way every time and said nothing, which is the whole silent-failure genre in one line.
270
+ // Caught by the spec in harness/tests/behavior.spec.mjs, which asked the bar what it read.
271
+ //
272
+ // The number is deliberately far beyond any real load: the POLICY is the watchdog above, and
273
+ // two clocks racing to enforce one promise means the promise is whichever one you forgot.
274
+ const boot = (async () => {
275
+ if (!bus) return {};
276
+ await arrived;
277
+ return await bus.proxy({
278
+ method: "boot",
279
+ service: "game",
280
+ options: { timeout: 600000, callback: (e: any) => setProgress(e?.progress ?? e?.value ?? e) },
281
+ });
282
+ })();
283
+
284
+ // The watchdog is the other half of the promise made to the player: whatever is wrong in
285
+ // there, they are better off seeing it than seeing us wait for it.
286
+ const giveUp = new Promise<any>((resolve) => {
287
+ bump = () => {
288
+ if (watchdog) clearTimeout(watchdog);
289
+ watchdog = setTimeout(() => resolve({ lateBy: "silence" }), PATIENCE);
290
+ };
291
+ bump();
292
+ });
293
+
294
+ // A SWALLOWED REASON IS THE FAILURE ITSELF. The frame is handed over either way — a player must
295
+ // not lose the game to a broken handshake — but the catch SAYS what went wrong, because the
296
+ // symptom on the other side of it is "the wait ended early" and nothing else. This cut's own
297
+ // one-second bug lived inside an empty catch and was invisible until a spec read the bar.
298
+ const answer: any = await Promise.race([
299
+ boot.catch((e: any) => {
300
+ console.warn("[preroll] the game's boot did not complete:", e?.message || e);
301
+ return {};
302
+ }),
303
+ giveUp,
304
+ ]);
305
+ if (watchdog) clearTimeout(watchdog);
306
+
307
+ show(null);
308
+ // `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
309
+ // is what a console does, where the logo animation IS the loading screen. Nothing else here
310
+ // changes; the difference is only how long this cut keeps the door shut.
311
+ if (answer?.curtain) root.dataset.curtain = "";
312
+ };
313
+
314
+ playBtn.addEventListener("click", start, { once: true });
315
+ }
316
+ </script>
package/src/ground.css CHANGED
@@ -54,6 +54,15 @@
54
54
  --surface: rgba(255, 255, 255, 0.04);
55
55
  --surface-2: rgba(255, 255, 255, 0.07);
56
56
  --accent: #0a84ff;
57
+ /* THE INK ON A FILLED ACCENT. Every persona already declares this — the template writes it from
58
+ the accent's own `color.on` — but no cut had rendered a filled accent button until <Preroll>'s
59
+ play surface, so it had never needed a ground and did not have one. It arrives with the same
60
+ lesson the card tokens above carry: a token is not grounded because the fleet defines it, it is
61
+ grounded because THIS package does. The value pairs with the `--accent` on the line above and
62
+ is not scheme-aware, because that accent is not either.
63
+ A persona whose accent is pale — a yellow, an orange — must override it, and every one of them
64
+ already does; white ink on a #f5a623 button is the failure this token exists to prevent. */
65
+ --accent-on: #ffffff;
57
66
  /* THE ecosystem's destructive red — Apple's system red, and one of it. Written here rather than
58
67
  in each component because a confirmation sheet and a Sign Out row saying "this ends something"
59
68
  in two different reds is exactly the drift the ConfirmSheet's own header warns about. It is