@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
package/src/Preroll.astro
CHANGED
|
@@ -135,212 +135,216 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
135
135
|
</div>
|
|
136
136
|
|
|
137
137
|
<style>
|
|
138
|
-
/*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
*
|
|
143
|
-
* The founder, and it is the constraint the whole layout is built on: *"preroll dynamic bir design.
|
|
144
|
-
* kullanıcının kullandığı device'a göre game kernel layout'un size'ı değişebilir."*
|
|
145
|
-
*
|
|
146
|
-
* So every measurement below is in CONTAINER units. A media query would ask the window how wide it
|
|
147
|
-
* is — and the window is not what this cut lives in: the same viewport gives a 448px frame on a
|
|
148
|
-
* phone and a 638px one inside the desktop column, and a landscape phone gives a frame that is
|
|
149
|
-
* wide and 300px tall. Asking the window produces a composition that is right on one of those.
|
|
150
|
-
*
|
|
151
|
-
* `cqw`/`cqh` with a rem floor and ceiling, never a bare px: `clamp(1.75rem, 12cqw, 4rem)` scales
|
|
152
|
-
* with the box AND keeps iOS Dynamic Type moving the bounds, which the fleet's typography law
|
|
153
|
-
* requires. A pure `cqw` size ignores the reader's own setting entirely. */
|
|
154
|
-
.pr {
|
|
155
|
-
position: relative; flex: 1; min-height: 240px; display: flex; flex-direction: column;
|
|
156
|
-
border-radius: var(--preroll-radius, var(--radius-lg)); overflow: hidden;
|
|
157
|
-
container-type: size;
|
|
158
|
-
container-name: pr;
|
|
159
|
-
}
|
|
160
|
-
.pr-stage { flex: 1; min-height: 0; }
|
|
161
|
-
.pr-stage :global(iframe) { display: block; width: 100%; height: 100%; border: 0; }
|
|
162
|
-
|
|
163
|
-
/* ── THE GROUND IS THE CARD'S ────────────────────────────────────────────────────────────────
|
|
164
|
-
* queries: why is the preroll dark · preroll background · the glow · the dot lattice
|
|
165
|
-
*
|
|
166
|
-
* The same three layers `builder/card` paints, in the same order: a near-black field, a wide soft
|
|
167
|
-
* bloom of the persona's accent off the top-left with a cooler counter-glow top-right so the field
|
|
168
|
-
* never reads flat, and a barely-there dot lattice. It is dark on every persona, including the ones
|
|
169
|
-
* whose page is light — a game is a room you go into, and the card the ecosystem already prints for
|
|
170
|
-
* this persona is dark. The accent is the persona's, so the fleet stays various without being loud.
|
|
171
|
-
*
|
|
172
|
-
* OPAQUE, and not `--surface`: this covers a game that is drawing itself underneath. */
|
|
173
|
-
/* ── A GRID, NOT A COLUMN OF FLEX ────────────────────────────────────────────────────────────
|
|
174
|
-
* queries: the play button is in the wrong place · landscape preroll · the composition breaks on
|
|
175
|
-
* a wide box · grid-template-areas
|
|
176
|
-
*
|
|
177
|
-
* The four parts have to REGROUP, not just re-order: in a tall box they stack, and in a wide one
|
|
178
|
-
* the copy and the button become one column with the art beside them. Written as a flex row that
|
|
179
|
-
* was measured on a landscape phone, the button became a third column and floated off to the
|
|
180
|
-
* top-right on its own — flex can reorder siblings but it cannot make two of them share a column.
|
|
181
|
-
* Named areas can, and the two layouts are then two grids rather than a pile of overrides. */
|
|
182
|
-
.pr-door {
|
|
183
|
-
position: absolute; inset: 0; z-index: 1;
|
|
184
|
-
display: grid;
|
|
185
|
-
grid-template-columns: 1fr;
|
|
186
|
-
/* ── THE ART TAKES THE TOP HALF; THE COPY BEGINS ON THE CENTRE LINE ────────────────────────
|
|
187
|
-
* queries: preroll composition · where does the copy start · the play button is too low ·
|
|
188
|
-
* the button is under the browser bar · centre line · x-ray grid
|
|
138
|
+
/* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
|
|
139
|
+
out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
|
|
140
|
+
@layer justai.components {
|
|
141
|
+
/* ── THE COMPOSITION RESPONDS TO ITS OWN BOX, NOT TO THE WINDOW ──────────────────────────────
|
|
189
142
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* başlasa daha iyi olur."*
|
|
143
|
+
* queries: preroll responsive · the preroll is cramped · container query · cqw · dynamic preroll ·
|
|
144
|
+
* the game kernel size changes with the device
|
|
193
145
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* art centres in the half above it and the copy starts on it.
|
|
146
|
+
* The founder, and it is the constraint the whole layout is built on: *"preroll dynamic bir design.
|
|
147
|
+
* kullanıcının kullandığı device'a göre game kernel layout'un size'ı değişebilir."*
|
|
197
148
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
.pr-door[hidden] { display: none; }
|
|
216
|
-
|
|
217
|
-
/* THE ART TAKES THE TOP AND SITS TO THE RIGHT, which is the card's own diagonal stood up: art one
|
|
218
|
-
side, type the other. Everything centred is the safe composition and it is the one that reads as
|
|
219
|
-
a placeholder — the card is left-aligned and that is this family's voice. */
|
|
220
|
-
/* CENTRED, not pushed to the edge. The first version put the mark hard right to echo the card's
|
|
221
|
-
diagonal — and the card is a landscape rectangle where that diagonal has room to be one. Stood up
|
|
222
|
-
in a tall frame it stops reading as a composition and starts reading as a thing that drifted. */
|
|
223
|
-
.pr-art {
|
|
224
|
-
grid-area: art; min-height: 0;
|
|
225
|
-
display: flex; align-items: center; justify-content: center;
|
|
226
|
-
}
|
|
227
|
-
.pr-art :global(svg), .pr-art :global(img) {
|
|
228
|
-
display: block; width: auto; height: auto;
|
|
229
|
-
max-width: clamp(96px, 40cqw, 300px);
|
|
230
|
-
max-height: clamp(96px, 34cqh, 300px);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
.pr-copy { grid-area: copy; min-width: 0; }
|
|
234
|
-
.pr-kicker {
|
|
235
|
-
display: flex; align-items: center; gap: 0.55em; margin: 0 0 clamp(10px, 2.4cqh, 26px);
|
|
236
|
-
font-size: clamp(0.75rem, 3.1cqw, 1.05rem); font-weight: 600; color: #8b949e; letter-spacing: 0.01em;
|
|
237
|
-
}
|
|
238
|
-
.pr-kicker i { width: 0.62em; height: 0.62em; border-radius: 50%; background: var(--accent); flex: none; }
|
|
239
|
-
.pr-kicker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
149
|
+
* So every measurement below is in CONTAINER units. A media query would ask the window how wide it
|
|
150
|
+
* is — and the window is not what this cut lives in: the same viewport gives a 448px frame on a
|
|
151
|
+
* phone and a 638px one inside the desktop column, and a landscape phone gives a frame that is
|
|
152
|
+
* wide and 300px tall. Asking the window produces a composition that is right on one of those.
|
|
153
|
+
*
|
|
154
|
+
* `cqw`/`cqh` with a rem floor and ceiling, never a bare px: `clamp(1.75rem, 12cqw, 4rem)` scales
|
|
155
|
+
* with the box AND keeps iOS Dynamic Type moving the bounds, which the fleet's typography law
|
|
156
|
+
* requires. A pure `cqw` size ignores the reader's own setting entirely. */
|
|
157
|
+
.pr {
|
|
158
|
+
position: relative; flex: 1; min-height: 240px; display: flex; flex-direction: column;
|
|
159
|
+
border-radius: var(--preroll-radius, var(--radius-lg)); overflow: hidden;
|
|
160
|
+
container-type: size;
|
|
161
|
+
container-name: pr;
|
|
162
|
+
}
|
|
163
|
+
.pr-stage { flex: 1; min-height: 0; }
|
|
164
|
+
.pr-stage :global(iframe) { display: block; width: 100%; height: 100%; border: 0; }
|
|
240
165
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
166
|
+
/* ── THE GROUND IS THE CARD'S ────────────────────────────────────────────────────────────────
|
|
167
|
+
* queries: why is the preroll dark · preroll background · the glow · the dot lattice
|
|
168
|
+
*
|
|
169
|
+
* The same three layers `builder/card` paints, in the same order: a near-black field, a wide soft
|
|
170
|
+
* bloom of the persona's accent off the top-left with a cooler counter-glow top-right so the field
|
|
171
|
+
* never reads flat, and a barely-there dot lattice. It is dark on every persona, including the ones
|
|
172
|
+
* whose page is light — a game is a room you go into, and the card the ecosystem already prints for
|
|
173
|
+
* this persona is dark. The accent is the persona's, so the fleet stays various without being loud.
|
|
174
|
+
*
|
|
175
|
+
* OPAQUE, and not `--surface`: this covers a game that is drawing itself underneath. */
|
|
176
|
+
/* ── A GRID, NOT A COLUMN OF FLEX ────────────────────────────────────────────────────────────
|
|
177
|
+
* queries: the play button is in the wrong place · landscape preroll · the composition breaks on
|
|
178
|
+
* a wide box · grid-template-areas
|
|
179
|
+
*
|
|
180
|
+
* The four parts have to REGROUP, not just re-order: in a tall box they stack, and in a wide one
|
|
181
|
+
* the copy and the button become one column with the art beside them. Written as a flex row that
|
|
182
|
+
* was measured on a landscape phone, the button became a third column and floated off to the
|
|
183
|
+
* top-right on its own — flex can reorder siblings but it cannot make two of them share a column.
|
|
184
|
+
* Named areas can, and the two layouts are then two grids rather than a pile of overrides. */
|
|
185
|
+
.pr-door {
|
|
186
|
+
position: absolute; inset: 0; z-index: 1;
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-template-columns: 1fr;
|
|
189
|
+
/* ── THE ART TAKES THE TOP HALF; THE COPY BEGINS ON THE CENTRE LINE ────────────────────────
|
|
190
|
+
* queries: preroll composition · where does the copy start · the play button is too low ·
|
|
191
|
+
* the button is under the browser bar · centre line · x-ray grid
|
|
192
|
+
*
|
|
193
|
+
* The founder, describing it through the x-ray's own grid — which is what that instrument was
|
|
194
|
+
* for: *"logo 1. satırın 2. cell'inde ortada olsa daha iyi olur… içerik de 2. satırın top'undan
|
|
195
|
+
* başlasa daha iyi olur."*
|
|
196
|
+
*
|
|
197
|
+
* `50%` on the first row is exact rather than approximate: the door's padding is equal top and
|
|
198
|
+
* bottom, so half of its content box IS the frame's centre — the same line the x-ray draws. The
|
|
199
|
+
* art centres in the half above it and the copy starts on it.
|
|
200
|
+
*
|
|
201
|
+
* AND IT IS WHY THE BUTTON IS SAFE ON A REAL PHONE. The last row takes the slack, so the button
|
|
202
|
+
* sits directly under the copy instead of at the bottom of the frame — which on a real device is
|
|
203
|
+
* where the browser puts its own bar. He sent a screenshot from one: the URL bar sitting over the
|
|
204
|
+
* play button, and a strip of the next section showing beneath it. A composition that ends at the
|
|
205
|
+
* frame's bottom edge is a composition whose call to action a phone can cover. */
|
|
206
|
+
grid-template-rows: 50% auto auto minmax(0, 1fr);
|
|
207
|
+
grid-template-areas: "art" "copy" "play" "imprint";
|
|
208
|
+
padding: clamp(18px, 7cqw, 64px);
|
|
209
|
+
/* And the strip the OS keeps for its home indicator, on top of the inset — never instead of it. */
|
|
210
|
+
padding-block-end: calc(clamp(18px, 7cqw, 64px) + env(safe-area-inset-bottom, 0px));
|
|
211
|
+
background:
|
|
212
|
+
radial-gradient(90cqw 52cqh at 8% -12%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 62%),
|
|
213
|
+
radial-gradient(76cqw 46cqh at 96% 4%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
|
|
214
|
+
radial-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px) 0 0 / 24px 24px,
|
|
215
|
+
#080b12;
|
|
216
|
+
color: #f5f7fa;
|
|
217
|
+
}
|
|
218
|
+
.pr-door[hidden] { display: none; }
|
|
219
|
+
|
|
220
|
+
/* THE ART TAKES THE TOP AND SITS TO THE RIGHT, which is the card's own diagonal stood up: art one
|
|
221
|
+
side, type the other. Everything centred is the safe composition and it is the one that reads as
|
|
222
|
+
a placeholder — the card is left-aligned and that is this family's voice. */
|
|
223
|
+
/* CENTRED, not pushed to the edge. The first version put the mark hard right to echo the card's
|
|
224
|
+
diagonal — and the card is a landscape rectangle where that diagonal has room to be one. Stood up
|
|
225
|
+
in a tall frame it stops reading as a composition and starts reading as a thing that drifted. */
|
|
226
|
+
.pr-art {
|
|
227
|
+
grid-area: art; min-height: 0;
|
|
228
|
+
display: flex; align-items: center; justify-content: center;
|
|
229
|
+
}
|
|
230
|
+
.pr-art :global(svg), .pr-art :global(img) {
|
|
231
|
+
display: block; width: auto; height: auto;
|
|
232
|
+
max-width: clamp(96px, 40cqw, 300px);
|
|
233
|
+
max-height: clamp(96px, 34cqh, 300px);
|
|
234
|
+
}
|
|
259
235
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
grid-area: play; justify-self: stretch;
|
|
268
|
-
position: relative; overflow: hidden; isolation: isolate;
|
|
269
|
-
margin-top: clamp(16px, 3.6cqh, 40px);
|
|
270
|
-
font: inherit; font-weight: 650; font-size: clamp(0.9375rem, 4cqw, 1.35rem);
|
|
271
|
-
padding: clamp(12px, 2.4cqh, 22px) clamp(20px, 6cqw, 40px);
|
|
272
|
-
border: 0; border-radius: var(--radius-pill);
|
|
273
|
-
background: var(--accent); color: var(--accent-on); cursor: pointer;
|
|
274
|
-
transition: transform 0.18s var(--ease-spring), opacity 0.2s;
|
|
275
|
-
}
|
|
276
|
-
.pr-play:active { transform: scale(0.97); }
|
|
277
|
-
.pr-play[data-busy] { cursor: default; }
|
|
278
|
-
.pr-play[data-busy]:active { transform: none; }
|
|
279
|
-
.pr-play span { position: relative; z-index: 1; }
|
|
280
|
-
.pr-fill {
|
|
281
|
-
position: absolute; inset: 0 auto 0 0; z-index: 0; width: 0;
|
|
282
|
-
background: color-mix(in srgb, #ffffff 26%, transparent);
|
|
283
|
-
transition: width 0.3s var(--ease-out);
|
|
284
|
-
}
|
|
285
|
-
/* A game that reports nothing gets a sweep rather than a number — honest about not knowing. A bar
|
|
286
|
-
that moves without measuring anything teaches a player that the number means nothing. */
|
|
287
|
-
.pr-play[data-busy][data-indeterminate] .pr-fill {
|
|
288
|
-
width: 42%; animation: pr-sweep 1.15s var(--ease-out) infinite;
|
|
289
|
-
}
|
|
290
|
-
@keyframes pr-sweep { from { transform: translateX(-110%); } to { transform: translateX(250%); } }
|
|
236
|
+
.pr-copy { grid-area: copy; min-width: 0; }
|
|
237
|
+
.pr-kicker {
|
|
238
|
+
display: flex; align-items: center; gap: 0.55em; margin: 0 0 clamp(10px, 2.4cqh, 26px);
|
|
239
|
+
font-size: clamp(0.75rem, 3.1cqw, 1.05rem); font-weight: 600; color: #8b949e; letter-spacing: 0.01em;
|
|
240
|
+
}
|
|
241
|
+
.pr-kicker i { width: 0.62em; height: 0.62em; border-radius: 50%; background: var(--accent); flex: none; }
|
|
242
|
+
.pr-kicker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
291
243
|
|
|
292
|
-
|
|
293
|
-
|
|
244
|
+
.pr-name {
|
|
245
|
+
margin: 0 0 clamp(6px, 1.6cqh, 18px);
|
|
246
|
+
font-size: clamp(1.75rem, 13cqw, 4.25rem); font-weight: 700;
|
|
247
|
+
letter-spacing: -0.033em; line-height: 0.98;
|
|
248
|
+
}
|
|
249
|
+
.pr-tag {
|
|
250
|
+
margin: 0 0 clamp(6px, 1.4cqh, 16px);
|
|
251
|
+
font-size: clamp(1rem, 5.2cqw, 1.9rem); font-weight: 700; color: var(--accent);
|
|
252
|
+
letter-spacing: -0.012em; line-height: 1.18;
|
|
253
|
+
}
|
|
254
|
+
/* The card writes its second phrase in ink rather than accent, inside the copy itself. Globalised
|
|
255
|
+
because it arrives through `set:html` and carries no scope attribute — the lesson the home mark
|
|
256
|
+
taught this package the hard way. */
|
|
257
|
+
.pr-tag :global(span) { color: #f5f7fa; }
|
|
258
|
+
.pr-sub {
|
|
259
|
+
margin: 0; font-size: clamp(0.8125rem, 3.4cqw, 1.15rem); font-weight: 500; color: #8b949e;
|
|
260
|
+
letter-spacing: -0.004em;
|
|
261
|
+
}
|
|
294
262
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
263
|
+
/* ── THE BUTTON, AND IT IS ALSO THE PROGRESS ─────────────────────────────────────────────────
|
|
264
|
+
* queries: preroll progress · the loading bar · where does progress show · the button fills
|
|
265
|
+
*
|
|
266
|
+
* One element does both jobs, in one place. `.pr-fill` is a layer inside the button that grows
|
|
267
|
+
* from the left; the label sits above it and changes word. A player who presses a button and
|
|
268
|
+
* watches that same button work is never asked where their press went. */
|
|
269
|
+
.pr-play {
|
|
270
|
+
grid-area: play; justify-self: stretch;
|
|
271
|
+
position: relative; overflow: hidden; isolation: isolate;
|
|
272
|
+
margin-top: clamp(16px, 3.6cqh, 40px);
|
|
273
|
+
font: inherit; font-weight: 650; font-size: clamp(0.9375rem, 4cqw, 1.35rem);
|
|
274
|
+
padding: clamp(12px, 2.4cqh, 22px) clamp(20px, 6cqw, 40px);
|
|
275
|
+
border: 0; border-radius: var(--radius-pill);
|
|
276
|
+
background: var(--accent); color: var(--accent-on); cursor: pointer;
|
|
277
|
+
transition: transform 0.18s var(--ease-spring), opacity 0.2s;
|
|
278
|
+
}
|
|
279
|
+
.pr-play:active { transform: scale(0.97); }
|
|
280
|
+
.pr-play[data-busy] { cursor: default; }
|
|
281
|
+
.pr-play[data-busy]:active { transform: none; }
|
|
282
|
+
.pr-play span { position: relative; z-index: 1; }
|
|
283
|
+
.pr-fill {
|
|
284
|
+
position: absolute; inset: 0 auto 0 0; z-index: 0; width: 0;
|
|
285
|
+
background: color-mix(in srgb, #ffffff 26%, transparent);
|
|
286
|
+
transition: width 0.3s var(--ease-out);
|
|
287
|
+
}
|
|
288
|
+
/* A game that reports nothing gets a sweep rather than a number — honest about not knowing. A bar
|
|
289
|
+
that moves without measuring anything teaches a player that the number means nothing. */
|
|
290
|
+
.pr-play[data-busy][data-indeterminate] .pr-fill {
|
|
291
|
+
width: 42%; animation: pr-sweep 1.15s var(--ease-out) infinite;
|
|
292
|
+
}
|
|
293
|
+
@keyframes pr-sweep { from { transform: translateX(-110%); } to { transform: translateX(250%); } }
|
|
294
|
+
|
|
295
|
+
.pr-imprint:empty { display: none; }
|
|
296
|
+
.pr-imprint { grid-area: imprint; margin-top: clamp(10px, 2cqh, 22px); }
|
|
297
|
+
|
|
298
|
+
/* ── WIDE AND SHORT: THE CARD'S OWN LAYOUT ───────────────────────────────────────────────────
|
|
299
|
+
* A landscape phone, or a desktop column turned wide, gives a box the vertical stack cannot fill
|
|
300
|
+
* without floating everything in the middle. At that shape the composition becomes the card it
|
|
301
|
+
* came from: type on the left, art on the right, the button under the type. */
|
|
302
|
+
@container pr (min-aspect-ratio: 1/1) and (min-width: 460px) {
|
|
303
|
+
/* The card's own shape: copy and button stacked on the left, art beside them, the whole block
|
|
304
|
+
centred in the box rather than pinned to its top. */
|
|
305
|
+
.pr-door {
|
|
306
|
+
grid-template-columns: 1fr auto;
|
|
307
|
+
grid-template-rows: auto auto auto;
|
|
308
|
+
grid-template-areas: "copy art" "play art" "imprint art";
|
|
309
|
+
align-content: center;
|
|
310
|
+
column-gap: clamp(24px, 5cqw, 72px);
|
|
311
|
+
}
|
|
312
|
+
.pr-art { justify-content: center; }
|
|
313
|
+
.pr-play { justify-self: start; }
|
|
314
|
+
.pr-name { font-size: clamp(1.75rem, 7cqw, 4.25rem); }
|
|
315
|
+
.pr-tag { font-size: clamp(1rem, 2.9cqw, 1.9rem); }
|
|
316
|
+
.pr-sub { font-size: clamp(0.8125rem, 1.9cqw, 1.15rem); }
|
|
317
|
+
.pr-kicker { font-size: clamp(0.75rem, 1.7cqw, 1.05rem); }
|
|
318
|
+
.pr-play { font-size: clamp(0.9375rem, 2.2cqw, 1.35rem); padding: clamp(12px, 2.2cqh, 22px) clamp(24px, 3.4cqw, 44px); }
|
|
319
|
+
}
|
|
320
|
+
/* A genuinely short box — a landscape phone gives about 312px — drops the third line rather than
|
|
321
|
+
crushing the type, and shrinks the art rather than removing it. Hiding the mark was the first
|
|
322
|
+
attempt and it left the persona anonymous in exactly the orientation somebody plays a game in. */
|
|
323
|
+
@container pr (max-height: 380px) {
|
|
324
|
+
.pr-sub { display: none; }
|
|
325
|
+
.pr-art :global(svg), .pr-art :global(img) { max-width: 22cqw; max-height: 46cqh; }
|
|
326
|
+
.pr-play { margin-top: clamp(10px, 2cqh, 18px); }
|
|
308
327
|
}
|
|
309
|
-
.pr-art { justify-content: center; }
|
|
310
|
-
.pr-play { justify-self: start; }
|
|
311
|
-
.pr-name { font-size: clamp(1.75rem, 7cqw, 4.25rem); }
|
|
312
|
-
.pr-tag { font-size: clamp(1rem, 2.9cqw, 1.9rem); }
|
|
313
|
-
.pr-sub { font-size: clamp(0.8125rem, 1.9cqw, 1.15rem); }
|
|
314
|
-
.pr-kicker { font-size: clamp(0.75rem, 1.7cqw, 1.05rem); }
|
|
315
|
-
.pr-play { font-size: clamp(0.9375rem, 2.2cqw, 1.35rem); padding: clamp(12px, 2.2cqh, 22px) clamp(24px, 3.4cqw, 44px); }
|
|
316
|
-
}
|
|
317
|
-
/* A genuinely short box — a landscape phone gives about 312px — drops the third line rather than
|
|
318
|
-
crushing the type, and shrinks the art rather than removing it. Hiding the mark was the first
|
|
319
|
-
attempt and it left the persona anonymous in exactly the orientation somebody plays a game in. */
|
|
320
|
-
@container pr (max-height: 380px) {
|
|
321
|
-
.pr-sub { display: none; }
|
|
322
|
-
.pr-art :global(svg), .pr-art :global(img) { max-width: 22cqw; max-height: 46cqh; }
|
|
323
|
-
.pr-play { margin-top: clamp(10px, 2cqh, 18px); }
|
|
324
|
-
}
|
|
325
328
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
329
|
+
/* ── THE ENTRANCE ────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
* Transform and opacity only, so nothing here changes a layout — the shell's own lesson about the
|
|
331
|
+
* toolbar's strip applies to anything that animates near a scroll spy. Four beats, ~520ms, and the
|
|
332
|
+
* button is tappable from the first frame regardless: an invitation you have to wait for is not
|
|
333
|
+
* one. */
|
|
334
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
335
|
+
.pr-art, .pr-kicker, .pr-name, .pr-tag, .pr-sub, .pr-play {
|
|
336
|
+
animation: pr-rise 0.52s var(--ease-out) both;
|
|
337
|
+
}
|
|
338
|
+
.pr-kicker { animation-delay: 0.04s; }
|
|
339
|
+
.pr-name { animation-delay: 0.09s; }
|
|
340
|
+
.pr-tag { animation-delay: 0.14s; }
|
|
341
|
+
.pr-sub { animation-delay: 0.19s; }
|
|
342
|
+
.pr-play { animation-delay: 0.26s; }
|
|
343
|
+
}
|
|
344
|
+
@keyframes pr-rise {
|
|
345
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
346
|
+
to { opacity: 1; transform: none; }
|
|
334
347
|
}
|
|
335
|
-
.pr-kicker { animation-delay: 0.04s; }
|
|
336
|
-
.pr-name { animation-delay: 0.09s; }
|
|
337
|
-
.pr-tag { animation-delay: 0.14s; }
|
|
338
|
-
.pr-sub { animation-delay: 0.19s; }
|
|
339
|
-
.pr-play { animation-delay: 0.26s; }
|
|
340
|
-
}
|
|
341
|
-
@keyframes pr-rise {
|
|
342
|
-
from { opacity: 0; transform: translateY(10px); }
|
|
343
|
-
to { opacity: 1; transform: none; }
|
|
344
348
|
}
|
|
345
349
|
</style>
|
|
346
350
|
|
|
@@ -382,294 +386,7 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
382
386
|
* back the moment they pause. */
|
|
383
387
|
</style>
|
|
384
388
|
|
|
385
|
-
<script>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// discovers UPWARD, so the room has to be standing before anybody can walk into it. `mount` names
|
|
390
|
-
// the guest by its WINDOW — never by what it calls itself, because a guest that announces its own
|
|
391
|
-
// name lets any frame on the page claim to be the game.
|
|
392
|
-
import atrium from "@justai/atrium";
|
|
393
|
-
|
|
394
|
-
const root = document.getElementById("pr");
|
|
395
|
-
if (root) {
|
|
396
|
-
const door = document.getElementById("prDoor") as HTMLElement;
|
|
397
|
-
const stage = document.getElementById("prStage") as HTMLElement;
|
|
398
|
-
const fill = document.getElementById("prFill") as HTMLElement;
|
|
399
|
-
const playBtn = document.getElementById("prPlay") as HTMLButtonElement;
|
|
400
|
-
const label = document.getElementById("prLabel") as HTMLElement;
|
|
401
|
-
|
|
402
|
-
const src = root.dataset.src as string;
|
|
403
|
-
const atriumId = root.dataset.atrium as string;
|
|
404
|
-
|
|
405
|
-
let started = false;
|
|
406
|
-
|
|
407
|
-
// The stage is never switched — it is underneath the whole time, and the door is a cover over
|
|
408
|
-
// it. `open()` is the handover: the cover leaves and what was already drawing there is visible.
|
|
409
|
-
const open = () => { door.hidden = true; };
|
|
410
|
-
|
|
411
|
-
// ── PATIENCE IS MEASURED IN SILENCE, NOT IN SECONDS ──────────────────────────────────────
|
|
412
|
-
//
|
|
413
|
-
// queries: the game was cut off while it was still loading · the preroll gave up too early ·
|
|
414
|
-
// how long does the shell wait · slow connection · the bar vanished mid-load
|
|
415
|
-
//
|
|
416
|
-
// A total time limit is the wrong instrument and it fails in both directions: 20s abandons a
|
|
417
|
-
// real game on a real 3G connection, and 120s leaves a player staring at a dead frame. What
|
|
418
|
-
// separates the two cases is not how long it has taken — it is whether the game is still
|
|
419
|
-
// TALKING. So the clock is reset by every progress report, and only silence runs it down.
|
|
420
|
-
//
|
|
421
|
-
// A game that never speaks at all gets one window's worth from the moment the frame is created,
|
|
422
|
-
// which is the same promise made to a player whose game is simply broken.
|
|
423
|
-
const PATIENCE = 20000;
|
|
424
|
-
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
425
|
-
// Assigned where the promise it resolves is created — a no-op until then, because a progress
|
|
426
|
-
// report cannot arrive before the frame exists and a `bump` that throws would take the bar
|
|
427
|
-
// down with it.
|
|
428
|
-
let bump = () => {};
|
|
429
|
-
|
|
430
|
-
// THE BUTTON IS THE BAR. Its fill layer grows and its word changes; nothing moves and nothing is
|
|
431
|
-
// replaced. A player who pressed something and watches that same thing work never has to ask
|
|
432
|
-
// where their press went.
|
|
433
|
-
const setProgress = (v: unknown) => {
|
|
434
|
-
bump();
|
|
435
|
-
const n = typeof v === "number" ? v : (v as any)?.value;
|
|
436
|
-
if (typeof n !== "number" || !isFinite(n)) return;
|
|
437
|
-
playBtn.removeAttribute("data-indeterminate");
|
|
438
|
-
fill.style.width = Math.max(0, Math.min(1, n)) * 100 + "%";
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const start = async () => {
|
|
442
|
-
if (started) return;
|
|
443
|
-
started = true;
|
|
444
|
-
// Indeterminate until the game says otherwise, and `disabled` is deliberately NOT used: a
|
|
445
|
-
// disabled button greys out and reads as broken, when what is true is that it is working.
|
|
446
|
-
playBtn.setAttribute("data-busy", "");
|
|
447
|
-
playBtn.setAttribute("data-indeterminate", "");
|
|
448
|
-
playBtn.setAttribute("aria-busy", "true");
|
|
449
|
-
label.textContent = playBtn.dataset.loading || label.textContent;
|
|
450
|
-
|
|
451
|
-
const frame = document.createElement("iframe");
|
|
452
|
-
frame.title = stage.dataset.title || "";
|
|
453
|
-
// `allow` carries the gesture forward: the tap that opened this frame is what lets the game
|
|
454
|
-
// play sound at all, and a frame without it starts muted for reasons nobody can see.
|
|
455
|
-
frame.setAttribute("allow", "autoplay; fullscreen");
|
|
456
|
-
frame.setAttribute("referrerpolicy", "same-origin");
|
|
457
|
-
|
|
458
|
-
let bus: any = null;
|
|
459
|
-
try {
|
|
460
|
-
bus = await atrium({
|
|
461
|
-
id: atriumId,
|
|
462
|
-
router: true,
|
|
463
|
-
mount: ({ source }: any) => (source === frame.contentWindow ? "game" : null),
|
|
464
|
-
});
|
|
465
|
-
} catch {
|
|
466
|
-
// atrium failing must not cost the player the game: mount the frame and hand over blind.
|
|
467
|
-
// A game that cannot speak still runs; it simply gets no progress and no early curtain.
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── THE STAGE — v2 of the protocol: who owns attention, and who owns the chrome ─────────
|
|
471
|
-
//
|
|
472
|
-
// queries: stage playing · pause the game from the shell · mood accent · theater mode ·
|
|
473
|
-
// the cuts stay reachable while playing · focus protocol · oyun basladi
|
|
474
|
-
//
|
|
475
|
-
// The game declares STATE and the shell decides what it means — a game never asks for a
|
|
476
|
-
// toolbar to be hidden, it says what the player is doing. Three messages: `stage` and `mood`
|
|
477
|
-
// travel up (the same direction every progress report already travels), `pause` comes back.
|
|
478
|
-
//
|
|
479
|
-
// THE RETURN DIRECTION IS THE SAFETY OF THE WHOLE DESIGN. Whoever grants exclusive attention
|
|
480
|
-
// must be able to take it back — the player reached for the way out, the tab went to the
|
|
481
|
-
// background. A focus handover with no way back does not trap the shell, it traps the PLAYER,
|
|
482
|
-
// and on a phone the only escape from that is closing the tab. So: a game that has not
|
|
483
|
-
// declared protocol 2 in its boot answer is never given the stage. The declaration IS the
|
|
484
|
-
// promise that `pause` is registered, and the shell holds it to that.
|
|
485
|
-
// The declaration is the boot ANSWER, and a game may announce `playing` in the same breath
|
|
486
|
-
// as resolving boot — airdrop does, and its announcement beats its own answer onto the wire.
|
|
487
|
-
// So the stage handler never reads a variable; it AWAITS the declaration, and a message that
|
|
488
|
-
// arrives early is simply held until the shell knows who it is talking to. The watchdog path
|
|
489
|
-
// declares protocol 1, so a silent game's early announcement resolves to a refusal, not a hang.
|
|
490
|
-
let declareProtocol: (v: number) => void = () => {};
|
|
491
|
-
const declared: Promise<number> = new Promise((r) => { declareProtocol = r; });
|
|
492
|
-
if (bus) {
|
|
493
|
-
// Whatever theme the page carried before the stage took it — restored on pause, so a manual
|
|
494
|
-
// data-theme set by anything else survives a round trip through a game.
|
|
495
|
-
let priorTheme: string | undefined;
|
|
496
|
-
|
|
497
|
-
const setStage = (mode: string) => {
|
|
498
|
-
const playing = mode === "playing";
|
|
499
|
-
if (playing) {
|
|
500
|
-
document.documentElement.dataset.stage = "playing";
|
|
501
|
-
// The theater: the persona's own dark theme, switched on. See the style block above.
|
|
502
|
-
if (document.documentElement.dataset.theme !== "dark") {
|
|
503
|
-
priorTheme = document.documentElement.dataset.theme;
|
|
504
|
-
document.documentElement.dataset.theme = "dark";
|
|
505
|
-
}
|
|
506
|
-
} else {
|
|
507
|
-
delete document.documentElement.dataset.stage;
|
|
508
|
-
if (priorTheme === undefined) delete document.documentElement.dataset.theme;
|
|
509
|
-
else document.documentElement.dataset.theme = priorTheme;
|
|
510
|
-
priorTheme = undefined;
|
|
511
|
-
}
|
|
512
|
-
// The other cuts go INERT rather than display:none — the page must not reflow, and the
|
|
513
|
-
// player's scroll position has to survive a pause. inert takes them out of tab order and
|
|
514
|
-
// the accessibility tree both; a screen reader must not keep walking a page the player
|
|
515
|
-
// has left.
|
|
516
|
-
for (const id of ["profile", "posts"]) {
|
|
517
|
-
const el = document.getElementById(id);
|
|
518
|
-
if (el) el.toggleAttribute("inert", playing);
|
|
519
|
-
}
|
|
520
|
-
// The page is locked, so make sure what it is locked ON is the stage.
|
|
521
|
-
if (playing) stage.scrollIntoView({ block: "nearest" });
|
|
522
|
-
// queries: space does nothing · keyboard not reaching the game · input bind · focus
|
|
523
|
-
//
|
|
524
|
-
// AND THE KEYBOARD GOES WITH THE STAGE. The play press happened in THIS document, so this
|
|
525
|
-
// document holds the focus — and every key the player presses lands in the shell while
|
|
526
|
-
// the game hears nothing. The founder, on the space bar: *"bu haldeyken klavyeden space
|
|
527
|
-
// tusunu bile almiyor. tum input game frame'e bind olmali."* Granting the stage IS the
|
|
528
|
-
// input handover, so the frame is focused with it — and again on pause's reversal, so a
|
|
529
|
-
// resumed game does not come back deaf.
|
|
530
|
-
if (playing) frame.focus();
|
|
531
|
-
// The toolbar swaps its icons for the handle on this event — it owns its own row.
|
|
532
|
-
document.dispatchEvent(new CustomEvent("justai:stage", { detail: { mode } }));
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
// ── the mood: meaning, not pixels ─────────────────────────────────────────────────────
|
|
536
|
-
// A few calls a second at most, eased here over ~600ms — never sampled from a framebuffer.
|
|
537
|
-
// It leans on the ONE variable the whole ground is already built from, so the persona's
|
|
538
|
-
// page re-tints without anything being painted: the door's washes, the buttons, the links
|
|
539
|
-
// all read --accent already.
|
|
540
|
-
const moodHost = (document.getElementById("infinite") ?? document.documentElement) as HTMLElement;
|
|
541
|
-
const hex = (c: string) => {
|
|
542
|
-
const m = /^#([0-9a-f]{6})$/i.exec(c.trim());
|
|
543
|
-
return m ? [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)] : null;
|
|
544
|
-
};
|
|
545
|
-
let moodRaf = 0;
|
|
546
|
-
const moodTo = (args: any) => {
|
|
547
|
-
if (typeof args?.energy === "number")
|
|
548
|
-
moodHost.style.setProperty("--stage-energy", String(Math.max(0, Math.min(1, args.energy))));
|
|
549
|
-
const to = typeof args?.accent === "string" ? hex(args.accent) : null;
|
|
550
|
-
if (!to) return;
|
|
551
|
-
const from = hex(getComputedStyle(moodHost).getPropertyValue("--accent")) ?? to;
|
|
552
|
-
cancelAnimationFrame(moodRaf);
|
|
553
|
-
const t0 = performance.now();
|
|
554
|
-
const step = (now: number) => {
|
|
555
|
-
const k = Math.min(1, (now - t0) / 600);
|
|
556
|
-
const e = k * k * (3 - 2 * k);
|
|
557
|
-
const c = from.map((f, i) => Math.round(f + (to[i] - f) * e));
|
|
558
|
-
moodHost.style.setProperty("--accent", `rgb(${c[0]}, ${c[1]}, ${c[2]})`);
|
|
559
|
-
if (k < 1) moodRaf = requestAnimationFrame(step);
|
|
560
|
-
};
|
|
561
|
-
moodRaf = requestAnimationFrame(step);
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
bus.register({
|
|
565
|
-
method: "stage",
|
|
566
|
-
handler: async (a: any) => {
|
|
567
|
-
// No protocol 2, no stage — the rule, not a courtesy. Awaited, not read: see above.
|
|
568
|
-
if ((await declared) < 2) return { granted: false };
|
|
569
|
-
setStage(a?.mode === "playing" ? "playing" : "paused");
|
|
570
|
-
return { granted: true };
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
bus.register({ method: "mood", handler: (a: any) => { moodTo(a); return {}; } });
|
|
574
|
-
|
|
575
|
-
// The shell's asks, all funnelled through one line so every reason travels the same path.
|
|
576
|
-
const askPause = (reason: string) =>
|
|
577
|
-
bus.proxy({ method: "pause", service: "game", args: { reason }, options: { timeout: 3000 } })
|
|
578
|
-
.catch(() => {});
|
|
579
|
-
// The pause handle and the fullscreen control live in the toolbar's own row; it dispatches,
|
|
580
|
-
// this listens. The shell restores its chrome only when the GAME announces `paused` — one
|
|
581
|
-
// source of truth for state, which is the game, always.
|
|
582
|
-
document.addEventListener("justai:pause-request", () => askPause("chrome"));
|
|
583
|
-
document.addEventListener("justai:fullscreen-request", () =>
|
|
584
|
-
(stage as any).requestFullscreen?.().catch(() => {}));
|
|
585
|
-
document.addEventListener("visibilitychange", () => {
|
|
586
|
-
if (document.hidden && document.documentElement.dataset.stage === "playing") askPause("hidden");
|
|
587
|
-
});
|
|
588
|
-
document.addEventListener("keydown", (e) => {
|
|
589
|
-
if (e.key === "Escape" && document.documentElement.dataset.stage === "playing") askPause("chrome");
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const arrived = bus
|
|
594
|
-
? new Promise<void>((resolve) => bus.on("connected", (e: any) => { if (e?.name === "game") resolve(); }))
|
|
595
|
-
: Promise.resolve();
|
|
596
|
-
|
|
597
|
-
stage.appendChild(frame);
|
|
598
|
-
// ── WHAT CROSSES INTO THE FRAME: AN ALLOWLIST, AND A SHORT ONE ──────────────────────────
|
|
599
|
-
//
|
|
600
|
-
// Forwarding the persona's whole query string would be the obvious convenience and it is a
|
|
601
|
-
// privacy hole: a `?data=` envelope, a campaign's utm tags and anything else a person arrived
|
|
602
|
-
// carrying would be handed to a frame that will one day be a third party's deployment. So the
|
|
603
|
-
// shell passes exactly two flags, both of them DEBUG instruments that belong to whoever is
|
|
604
|
-
// looking at the page rather than to whoever built the game: `xray`, the framework's design
|
|
605
|
-
// grid, and `slow`, which asks a game to spread its own loading out far enough to watch.
|
|
606
|
-
// A game that does not understand either simply ignores them.
|
|
607
|
-
const here = new URLSearchParams(location.search);
|
|
608
|
-
const url = new URL(src, location.href);
|
|
609
|
-
for (const flag of ["xray", "slow"]) if (here.has(flag)) url.searchParams.set(flag, here.get(flag) ?? "");
|
|
610
|
-
frame.src = url.pathname + url.search;
|
|
611
|
-
|
|
612
|
-
// ── THE HANDOVER ─────────────────────────────────────────────────────────────────────────
|
|
613
|
-
//
|
|
614
|
-
// ONE CALL CARRIES BOTH. atrium's `proxy` takes a progress callback and resolves when the
|
|
615
|
-
// method returns, so `boot` is the whole protocol: the game reports as it loads and answers
|
|
616
|
-
// when it can draw. No second channel, no polling, and nothing to keep in sync.
|
|
617
|
-
//
|
|
618
|
-
// ** THE EXPLICIT `timeout` IS LOAD-BEARING. ** atrium's default transaction timeout is ONE
|
|
619
|
-
// SECOND — right for a `pick` or an `echo`, and catastrophic here: every game on earth takes
|
|
620
|
-
// longer than a second to load, so `boot` rejected at 1.000s, the catch below swallowed it,
|
|
621
|
-
// and the shell handed over an empty frame while the game was still fetching. It failed the
|
|
622
|
-
// same way every time and said nothing, which is the whole silent-failure genre in one line.
|
|
623
|
-
// Caught by the spec in harness/tests/behavior.spec.mjs, which asked the bar what it read.
|
|
624
|
-
//
|
|
625
|
-
// The number is deliberately far beyond any real load: the POLICY is the watchdog above, and
|
|
626
|
-
// two clocks racing to enforce one promise means the promise is whichever one you forgot.
|
|
627
|
-
const boot = (async () => {
|
|
628
|
-
if (!bus) return {};
|
|
629
|
-
await arrived;
|
|
630
|
-
return await bus.proxy({
|
|
631
|
-
method: "boot",
|
|
632
|
-
service: "game",
|
|
633
|
-
options: { timeout: 600000, callback: (e: any) => setProgress(e?.progress ?? e?.value ?? e) },
|
|
634
|
-
});
|
|
635
|
-
})();
|
|
636
|
-
|
|
637
|
-
// The watchdog is the other half of the promise made to the player: whatever is wrong in
|
|
638
|
-
// there, they are better off seeing it than seeing us wait for it.
|
|
639
|
-
const giveUp = new Promise<any>((resolve) => {
|
|
640
|
-
bump = () => {
|
|
641
|
-
if (watchdog) clearTimeout(watchdog);
|
|
642
|
-
watchdog = setTimeout(() => resolve({ lateBy: "silence" }), PATIENCE);
|
|
643
|
-
};
|
|
644
|
-
bump();
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
// A SWALLOWED REASON IS THE FAILURE ITSELF. The frame is handed over either way — a player must
|
|
648
|
-
// not lose the game to a broken handshake — but the catch SAYS what went wrong, because the
|
|
649
|
-
// symptom on the other side of it is "the wait ended early" and nothing else. This cut's own
|
|
650
|
-
// one-second bug lived inside an empty catch and was invisible until a spec read the bar.
|
|
651
|
-
const answer: any = await Promise.race([
|
|
652
|
-
boot.catch((e: any) => {
|
|
653
|
-
console.warn("[preroll] the game's boot did not complete:", e?.message || e);
|
|
654
|
-
return {};
|
|
655
|
-
}),
|
|
656
|
-
giveUp,
|
|
657
|
-
]);
|
|
658
|
-
if (watchdog) clearTimeout(watchdog);
|
|
659
|
-
|
|
660
|
-
// The boot answer carries the game's protocol version, and version 2 is the declaration
|
|
661
|
-
// that `pause` is registered and will be honoured. Resolved before open(), so a game
|
|
662
|
-
// announcing `playing` in its very first frame — or a breath before its own answer — is
|
|
663
|
-
// already trusted by the time the door goes up.
|
|
664
|
-
declareProtocol(typeof answer?.protocol === "number" ? answer.protocol : 1);
|
|
665
|
-
|
|
666
|
-
open();
|
|
667
|
-
// `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
|
|
668
|
-
// is what a console does, where the logo animation IS the loading screen. Nothing else here
|
|
669
|
-
// changes; the difference is only how long this cut keeps the door shut.
|
|
670
|
-
if (answer?.curtain) root.dataset.curtain = "";
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
playBtn.addEventListener("click", start, { once: true });
|
|
674
|
-
}
|
|
675
|
-
</script>
|
|
389
|
+
<script>
|
|
390
|
+
// The behaviour lives in ./behaviour/preroll.ts — see that file for why it is not in here.
|
|
391
|
+
import "./behaviour/preroll";
|
|
392
|
+
</script>
|