@justai/cuts 0.115.0 → 0.117.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/Preroll.astro +271 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
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": {
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@justai/core": "^0.25.0",
44
+ "@justai/core": "^0.26.0",
45
45
  "@justai/ui": "^0.15.0",
46
46
  "@justai/atrium": "^0.1.9"
47
47
  }
package/src/Preroll.astro CHANGED
@@ -46,115 +46,302 @@ import "./ground.css";
46
46
  interface Props {
47
47
  /** The game's own document, served from this persona's origin. */
48
48
  src: string;
49
+
50
+ // ── THE FIVE FIELDS ARE THE PERSONA'S CARD ────────────────────────────────────────────────
51
+ //
52
+ // queries: preroll copy · what goes on the preroll · kicker name tag sub art · og card ·
53
+ // the preroll looks like a placeholder · social card and preroll
54
+ //
55
+ // THE PREROLL IS THE og CARD, ALIVE. Both answer one question — *what is this, in a glance,
56
+ // before I commit?* — and the persona already has to compose an answer, because `builder/card`
57
+ // draws its social card from these exact five fields. So the preroll takes the same five rather
58
+ // than inventing a second identity: kicker, name, tag, sub, art.
59
+ //
60
+ // That is not a saving, it is the point. A composition that exists in two places drifts; a
61
+ // composition that IS one thing rendered onto two surfaces cannot. And at a thousand personas the
62
+ // per-persona cost of a designed preroll drops to the copy somebody already wrote for the card.
63
+ //
64
+ // The founder's judgement of the first version, which was a centred icon and a pill button, is the
65
+ // brief this replaces it under: *"bir icon bir play butonu koyunca o preroll olmuyor. preroll senin
66
+ // estetik ve tasarım derinliğin."* The og card has the intent; this puts that intent on the screen.
67
+
68
+ /** The persona's own address and its category — `sudoku.pin.games · Puzzle`. It says where you are. */
69
+ kicker: string;
70
+ /** The persona's name, set large. */
71
+ name: string;
72
+ /** The line in the persona's own voice. HTML, so a phrase can take the accent — the card does the
73
+ * same with a `<span>`, and the two must be able to carry identical copy. */
74
+ tag: string;
75
+ /** Three concrete facts, small. Not marketing — the card's sub-line reads `Nine languages · five
76
+ * themes · always new`, and a persona that cannot write three true ones has a positioning problem
77
+ * rather than a copy problem. */
78
+ sub: string;
79
+
49
80
  /** The button. Keep it a verb. */
50
81
  play: string;
51
- /** One line under it — what this is, in the persona's voice. Optional. */
52
- note?: string;
82
+ /** Said on the button itself while the bytes are moving — the invitation becomes the wait. */
83
+ loading?: string;
53
84
  /** What the frame is called for somebody who cannot see it. */
54
85
  title: string;
55
- /** Said while the bytes are moving. */
56
- loading?: string;
57
86
  /** atrium ids are a grammar — [a-z0-9._-] — so this is sanitised rather than trusted. */
58
87
  id?: string;
59
88
  }
60
- const { src, play, note, title, loading = "Loading", id = "game" } = Astro.props;
89
+ const { src, kicker, name, tag, sub, play, title, loading = "Loading", id = "game" } = Astro.props;
61
90
  const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x");
62
91
  ---
63
92
 
64
93
  <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 ─────────────────────────
94
+ {/* ── THE STAGE IS THE FLOW; THE DOOR IS A COVER OVER IT ──────────────────────────────────────
66
95
  queries: the game boots at zero size · getBoundingClientRect returns 0 in the frame ·
67
96
  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
97
+ A GAME MUST BOOT AT ITS REAL SIZE. The first version of this cut switched between the door and
98
+ the stage with `display: none` — and a game loading inside a `display: none` frame has no
99
+ layout at all: every rect it measures is zero, images do not decode, rAF is suspended. It would
100
+ have booted blind and then been handed a viewport it had already measured as nothing.
101
+ So the stage is always laid out and the door sits ON TOP of it. The frame reaches its final
74
102
  size the moment it is created, and the handover is one element leaving rather than a reflow. */}
75
103
  <div class="pr-stage" id="prStage" data-title={title}></div>
76
104
 
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. */}
105
+ {/* ── THE DOOR: THE CARD, STOOD UP ────────────────────────────────────────────────────────────
106
+ No iframe exists yet — that is the entire point, and it is why this is a composition and a
107
+ button rather than a frame with a cover over it. A hidden iframe still fetches. */}
79
108
  <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>
109
+ <div class="pr-art"><slot name="art" /></div>
84
110
 
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>
111
+ <div class="pr-copy">
112
+ <p class="pr-kicker"><i></i><span>{kicker}</span></p>
113
+ <h2 class="pr-name">{name}</h2>
114
+ <p class="pr-tag" set:html={tag}></p>
115
+ <p class="pr-sub">{sub}</p>
116
+ </div>
117
+
118
+ {/* THE INVITATION BECOMES THE WAIT, IN PLACE. The first version replaced the whole door with a
119
+ bar in an empty room: two screens, and the player was moved from one to the other for no
120
+ reason they could see. The composition stays; this button fills. */}
121
+ <button class="pr-play" id="prPlay" type="button" data-label={play} data-loading={loading}>
122
+ <i class="pr-fill" id="prFill"></i>
123
+ <span id="prLabel">{play}</span>
124
+ </button>
125
+
126
+ {/* queries: preroll imprint · sponsor · brand on the preroll · monetization out of the frame
127
+ AN EMPTY SLOT ON PURPOSE, and it renders nothing until somebody fills it. The preroll is the
128
+ one full-screen moment a game persona has before the game takes the display, which makes it
129
+ the surface the founder's thesis actually stands on: monetization leaves the game frame and
130
+ lives on the persona's own surface, as an imprint rather than an interruption. Reserving its
131
+ place in the composition now is the difference between a brand sitting IN the design later
132
+ and a banner bolted onto it. */}
133
+ <div class="pr-imprint"><slot name="imprint" /></div>
90
134
  </div>
91
135
  </div>
92
136
 
93
137
  <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 ──────────────────────
138
+ /* ── THE COMPOSITION RESPONDS TO ITS OWN BOX, NOT TO THE WINDOW ──────────────────────────────
98
139
  *
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.
140
+ * queries: preroll responsive · the preroll is cramped · container query · cqw · dynamic preroll ·
141
+ * the game kernel size changes with the device
103
142
  *
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
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."*
108
145
  *
109
- * ── A COMPONENT BRINGS ITS OWN HEIGHT, NOT ONLY ITS OWN COLOURS ────────────────────────────
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.
110
150
  *
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. */
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. */
120
154
  .pr {
121
155
  position: relative; flex: 1; min-height: 240px; display: flex; flex-direction: column;
122
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
189
+ *
190
+ * The founder, describing it through the x-ray's own grid — which is what that instrument was
191
+ * for: *"logo 1. satırın 2. cell'inde ortada olsa daha iyi olur… içerik de 2. satırın top'undan
192
+ * başlasa daha iyi olur."*
193
+ *
194
+ * `50%` on the first row is exact rather than approximate: the door's padding is equal top and
195
+ * bottom, so half of its content box IS the frame's centre — the same line the x-ray draws. The
196
+ * art centres in the half above it and the copy starts on it.
197
+ *
198
+ * AND IT IS WHY THE BUTTON IS SAFE ON A REAL PHONE. The last row takes the slack, so the button
199
+ * sits directly under the copy instead of at the bottom of the frame — which on a real device is
200
+ * where the browser puts its own bar. He sent a screenshot from one: the URL bar sitting over the
201
+ * play button, and a strip of the next section showing beneath it. A composition that ends at the
202
+ * frame's bottom edge is a composition whose call to action a phone can cover. */
203
+ grid-template-rows: 50% auto auto minmax(0, 1fr);
204
+ grid-template-areas: "art" "copy" "play" "imprint";
205
+ padding: clamp(18px, 7cqw, 64px);
206
+ /* And the strip the OS keeps for its home indicator, on top of the inset — never instead of it. */
207
+ padding-block-end: calc(clamp(18px, 7cqw, 64px) + env(safe-area-inset-bottom, 0px));
208
+ background:
209
+ radial-gradient(90cqw 52cqh at 8% -12%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 62%),
210
+ radial-gradient(76cqw 46cqh at 96% 4%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
211
+ radial-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px) 0 0 / 24px 24px,
212
+ #080b12;
213
+ color: #f5f7fa;
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);
123
231
  }
124
232
 
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;
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;
130
237
  }
131
- .pr-door[hidden], .pr-wait[hidden] { display: none; }
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; }
132
240
 
241
+ .pr-name {
242
+ margin: 0 0 clamp(6px, 1.6cqh, 18px);
243
+ font-size: clamp(1.75rem, 13cqw, 4.25rem); font-weight: 700;
244
+ letter-spacing: -0.033em; line-height: 0.98;
245
+ }
246
+ .pr-tag {
247
+ margin: 0 0 clamp(6px, 1.4cqh, 16px);
248
+ font-size: clamp(1rem, 5.2cqw, 1.9rem); font-weight: 700; color: var(--accent);
249
+ letter-spacing: -0.012em; line-height: 1.18;
250
+ }
251
+ /* The card writes its second phrase in ink rather than accent, inside the copy itself. Globalised
252
+ because it arrives through `set:html` and carries no scope attribute — the lesson the home mark
253
+ taught this package the hard way. */
254
+ .pr-tag :global(span) { color: #f5f7fa; }
255
+ .pr-sub {
256
+ margin: 0; font-size: clamp(0.8125rem, 3.4cqw, 1.15rem); font-weight: 500; color: #8b949e;
257
+ letter-spacing: -0.004em;
258
+ }
259
+
260
+ /* ── THE BUTTON, AND IT IS ALSO THE PROGRESS ─────────────────────────────────────────────────
261
+ * queries: preroll progress · the loading bar · where does progress show · the button fills
262
+ *
263
+ * One element does both jobs, in one place. `.pr-fill` is a layer inside the button that grows
264
+ * from the left; the label sits above it and changes word. A player who presses a button and
265
+ * watches that same button work is never asked where their press went. */
133
266
  .pr-play {
134
- font: inherit; font-weight: 600; font-size: var(--fs-md);
135
- padding: 14px 40px; border: 0; border-radius: var(--radius-pill);
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);
136
273
  background: var(--accent); color: var(--accent-on); cursor: pointer;
137
- transition: transform 0.18s var(--ease-spring);
274
+ transition: transform 0.18s var(--ease-spring), opacity 0.2s;
138
275
  }
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;
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);
145
284
  }
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;
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;
150
289
  }
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; }
290
+ @keyframes pr-sweep { from { transform: translateX(-110%); } to { transform: translateX(250%); } }
291
+
292
+ .pr-imprint:empty { display: none; }
293
+ .pr-imprint { grid-area: imprint; margin-top: clamp(10px, 2cqh, 22px); }
294
+
295
+ /* ── WIDE AND SHORT: THE CARD'S OWN LAYOUT ───────────────────────────────────────────────────
296
+ * A landscape phone, or a desktop column turned wide, gives a box the vertical stack cannot fill
297
+ * without floating everything in the middle. At that shape the composition becomes the card it
298
+ * came from: type on the left, art on the right, the button under the type. */
299
+ @container pr (min-aspect-ratio: 1/1) and (min-width: 460px) {
300
+ /* The card's own shape: copy and button stacked on the left, art beside them, the whole block
301
+ centred in the box rather than pinned to its top. */
302
+ .pr-door {
303
+ grid-template-columns: 1fr auto;
304
+ grid-template-rows: auto auto auto;
305
+ grid-template-areas: "copy art" "play art" "imprint art";
306
+ align-content: center;
307
+ column-gap: clamp(24px, 5cqw, 72px);
308
+ }
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); }
154
324
  }
155
325
 
156
- .pr-stage { flex: 1; min-height: 0; }
157
- .pr-stage :global(iframe) { display: block; width: 100%; height: 100%; border: 0; }
326
+ /* ── THE ENTRANCE ────────────────────────────────────────────────────────────────────────────
327
+ * Transform and opacity only, so nothing here changes a layout — the shell's own lesson about the
328
+ * toolbar's strip applies to anything that animates near a scroll spy. Four beats, ~520ms, and the
329
+ * button is tappable from the first frame regardless: an invitation you have to wait for is not
330
+ * one. */
331
+ @media (prefers-reduced-motion: no-preference) {
332
+ .pr-art, .pr-kicker, .pr-name, .pr-tag, .pr-sub, .pr-play {
333
+ animation: pr-rise 0.52s var(--ease-out) both;
334
+ }
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
+ }
158
345
  </style>
159
346
 
160
347
  <script>
@@ -169,22 +356,19 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
169
356
  const root = document.getElementById("pr");
170
357
  if (root) {
171
358
  const door = document.getElementById("prDoor") as HTMLElement;
172
- const wait = document.getElementById("prWait") as HTMLElement;
173
359
  const stage = document.getElementById("prStage") as HTMLElement;
174
- const bar = document.getElementById("prBar") as HTMLElement;
175
360
  const fill = document.getElementById("prFill") as HTMLElement;
176
361
  const playBtn = document.getElementById("prPlay") as HTMLButtonElement;
362
+ const label = document.getElementById("prLabel") as HTMLElement;
177
363
 
178
364
  const src = root.dataset.src as string;
179
365
  const atriumId = root.dataset.atrium as string;
180
366
 
181
367
  let started = false;
182
368
 
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
- };
369
+ // The stage is never switched — it is underneath the whole time, and the door is a cover over
370
+ // it. `open()` is the handover: the cover leaves and what was already drawing there is visible.
371
+ const open = () => { door.hidden = true; };
188
372
 
189
373
  // ── PATIENCE IS MEASURED IN SILENCE, NOT IN SECONDS ──────────────────────────────────────
190
374
  //
@@ -205,18 +389,26 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
205
389
  // down with it.
206
390
  let bump = () => {};
207
391
 
392
+ // THE BUTTON IS THE BAR. Its fill layer grows and its word changes; nothing moves and nothing is
393
+ // replaced. A player who pressed something and watches that same thing work never has to ask
394
+ // where their press went.
208
395
  const setProgress = (v: unknown) => {
209
396
  bump();
210
397
  const n = typeof v === "number" ? v : (v as any)?.value;
211
398
  if (typeof n !== "number" || !isFinite(n)) return;
212
- bar.removeAttribute("data-indeterminate");
399
+ playBtn.removeAttribute("data-indeterminate");
213
400
  fill.style.width = Math.max(0, Math.min(1, n)) * 100 + "%";
214
401
  };
215
402
 
216
403
  const start = async () => {
217
404
  if (started) return;
218
405
  started = true;
219
- show(wait);
406
+ // Indeterminate until the game says otherwise, and `disabled` is deliberately NOT used: a
407
+ // disabled button greys out and reads as broken, when what is true is that it is working.
408
+ playBtn.setAttribute("data-busy", "");
409
+ playBtn.setAttribute("data-indeterminate", "");
410
+ playBtn.setAttribute("aria-busy", "true");
411
+ label.textContent = playBtn.dataset.loading || label.textContent;
220
412
 
221
413
  const frame = document.createElement("iframe");
222
414
  frame.title = stage.dataset.title || "";
@@ -304,7 +496,7 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
304
496
  ]);
305
497
  if (watchdog) clearTimeout(watchdog);
306
498
 
307
- show(null);
499
+ open();
308
500
  // `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
309
501
  // is what a console does, where the logo animation IS the loading screen. Nothing else here
310
502
  // changes; the difference is only how long this cut keeps the door shut.