@justai/cuts 0.114.0 → 0.116.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 +2 -2
- package/src/Preroll.astro +249 -79
- package/src/Toolbar.astro +45 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.116.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.
|
|
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,280 @@ 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
|
-
/**
|
|
52
|
-
|
|
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,
|
|
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
|
|
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
|
|
69
|
-
the
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
/*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
+
grid-template-rows: 1fr auto auto auto;
|
|
187
|
+
grid-template-areas: "art" "copy" "play" "imprint";
|
|
188
|
+
padding: clamp(18px, 7cqw, 64px);
|
|
189
|
+
background:
|
|
190
|
+
radial-gradient(90cqw 52cqh at 8% -12%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 62%),
|
|
191
|
+
radial-gradient(76cqw 46cqh at 96% 4%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
|
|
192
|
+
radial-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px) 0 0 / 24px 24px,
|
|
193
|
+
#080b12;
|
|
194
|
+
color: #f5f7fa;
|
|
195
|
+
}
|
|
196
|
+
.pr-door[hidden] { display: none; }
|
|
197
|
+
|
|
198
|
+
/* THE ART TAKES THE TOP AND SITS TO THE RIGHT, which is the card's own diagonal stood up: art one
|
|
199
|
+
side, type the other. Everything centred is the safe composition and it is the one that reads as
|
|
200
|
+
a placeholder — the card is left-aligned and that is this family's voice. */
|
|
201
|
+
.pr-art {
|
|
202
|
+
grid-area: art; min-height: 0;
|
|
203
|
+
display: flex; align-items: center; justify-content: flex-end;
|
|
204
|
+
}
|
|
205
|
+
.pr-art :global(svg), .pr-art :global(img) {
|
|
206
|
+
display: block; width: auto; height: auto;
|
|
207
|
+
max-width: clamp(96px, 40cqw, 300px);
|
|
208
|
+
max-height: clamp(96px, 34cqh, 300px);
|
|
123
209
|
}
|
|
124
210
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
|
211
|
+
.pr-copy { grid-area: copy; min-width: 0; }
|
|
212
|
+
.pr-kicker {
|
|
213
|
+
display: flex; align-items: center; gap: 0.55em; margin: 0 0 clamp(10px, 2.4cqh, 26px);
|
|
214
|
+
font-size: clamp(0.75rem, 3.1cqw, 1.05rem); font-weight: 600; color: #8b949e; letter-spacing: 0.01em;
|
|
130
215
|
}
|
|
131
|
-
.pr-
|
|
216
|
+
.pr-kicker i { width: 0.62em; height: 0.62em; border-radius: 50%; background: var(--accent); flex: none; }
|
|
217
|
+
.pr-kicker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
132
218
|
|
|
219
|
+
.pr-name {
|
|
220
|
+
margin: 0 0 clamp(6px, 1.6cqh, 18px);
|
|
221
|
+
font-size: clamp(1.75rem, 13cqw, 4.25rem); font-weight: 700;
|
|
222
|
+
letter-spacing: -0.033em; line-height: 0.98;
|
|
223
|
+
}
|
|
224
|
+
.pr-tag {
|
|
225
|
+
margin: 0 0 clamp(6px, 1.4cqh, 16px);
|
|
226
|
+
font-size: clamp(1rem, 5.2cqw, 1.9rem); font-weight: 700; color: var(--accent);
|
|
227
|
+
letter-spacing: -0.012em; line-height: 1.18;
|
|
228
|
+
}
|
|
229
|
+
/* The card writes its second phrase in ink rather than accent, inside the copy itself. Globalised
|
|
230
|
+
because it arrives through `set:html` and carries no scope attribute — the lesson the home mark
|
|
231
|
+
taught this package the hard way. */
|
|
232
|
+
.pr-tag :global(span) { color: #f5f7fa; }
|
|
233
|
+
.pr-sub {
|
|
234
|
+
margin: 0; font-size: clamp(0.8125rem, 3.4cqw, 1.15rem); font-weight: 500; color: #8b949e;
|
|
235
|
+
letter-spacing: -0.004em;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ── THE BUTTON, AND IT IS ALSO THE PROGRESS ─────────────────────────────────────────────────
|
|
239
|
+
* queries: preroll progress · the loading bar · where does progress show · the button fills
|
|
240
|
+
*
|
|
241
|
+
* One element does both jobs, in one place. `.pr-fill` is a layer inside the button that grows
|
|
242
|
+
* from the left; the label sits above it and changes word. A player who presses a button and
|
|
243
|
+
* watches that same button work is never asked where their press went. */
|
|
133
244
|
.pr-play {
|
|
134
|
-
|
|
135
|
-
|
|
245
|
+
grid-area: play; justify-self: stretch;
|
|
246
|
+
position: relative; overflow: hidden; isolation: isolate;
|
|
247
|
+
margin-top: clamp(16px, 3.6cqh, 40px);
|
|
248
|
+
font: inherit; font-weight: 650; font-size: clamp(0.9375rem, 4cqw, 1.35rem);
|
|
249
|
+
padding: clamp(12px, 2.4cqh, 22px) clamp(20px, 6cqw, 40px);
|
|
250
|
+
border: 0; border-radius: var(--radius-pill);
|
|
136
251
|
background: var(--accent); color: var(--accent-on); cursor: pointer;
|
|
137
|
-
transition: transform 0.18s var(--ease-spring);
|
|
252
|
+
transition: transform 0.18s var(--ease-spring), opacity 0.2s;
|
|
138
253
|
}
|
|
139
|
-
.pr-play:active { transform: scale(0.
|
|
140
|
-
.pr-
|
|
141
|
-
|
|
142
|
-
.pr-
|
|
143
|
-
|
|
144
|
-
|
|
254
|
+
.pr-play:active { transform: scale(0.97); }
|
|
255
|
+
.pr-play[data-busy] { cursor: default; }
|
|
256
|
+
.pr-play[data-busy]:active { transform: none; }
|
|
257
|
+
.pr-play span { position: relative; z-index: 1; }
|
|
258
|
+
.pr-fill {
|
|
259
|
+
position: absolute; inset: 0 auto 0 0; z-index: 0; width: 0;
|
|
260
|
+
background: color-mix(in srgb, #ffffff 26%, transparent);
|
|
261
|
+
transition: width 0.3s var(--ease-out);
|
|
145
262
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
.pr-
|
|
149
|
-
width:
|
|
263
|
+
/* A game that reports nothing gets a sweep rather than a number — honest about not knowing. A bar
|
|
264
|
+
that moves without measuring anything teaches a player that the number means nothing. */
|
|
265
|
+
.pr-play[data-busy][data-indeterminate] .pr-fill {
|
|
266
|
+
width: 42%; animation: pr-sweep 1.15s var(--ease-out) infinite;
|
|
150
267
|
}
|
|
151
|
-
@keyframes pr-sweep { from { transform: translateX(-
|
|
152
|
-
|
|
153
|
-
|
|
268
|
+
@keyframes pr-sweep { from { transform: translateX(-110%); } to { transform: translateX(250%); } }
|
|
269
|
+
|
|
270
|
+
.pr-imprint:empty { display: none; }
|
|
271
|
+
.pr-imprint { grid-area: imprint; margin-top: clamp(10px, 2cqh, 22px); }
|
|
272
|
+
|
|
273
|
+
/* ── WIDE AND SHORT: THE CARD'S OWN LAYOUT ───────────────────────────────────────────────────
|
|
274
|
+
* A landscape phone, or a desktop column turned wide, gives a box the vertical stack cannot fill
|
|
275
|
+
* without floating everything in the middle. At that shape the composition becomes the card it
|
|
276
|
+
* came from: type on the left, art on the right, the button under the type. */
|
|
277
|
+
@container pr (min-aspect-ratio: 1/1) and (min-width: 460px) {
|
|
278
|
+
/* The card's own shape: copy and button stacked on the left, art beside them, the whole block
|
|
279
|
+
centred in the box rather than pinned to its top. */
|
|
280
|
+
.pr-door {
|
|
281
|
+
grid-template-columns: 1fr auto;
|
|
282
|
+
grid-template-rows: auto auto auto;
|
|
283
|
+
grid-template-areas: "copy art" "play art" "imprint art";
|
|
284
|
+
align-content: center;
|
|
285
|
+
column-gap: clamp(24px, 5cqw, 72px);
|
|
286
|
+
}
|
|
287
|
+
.pr-art { justify-content: center; }
|
|
288
|
+
.pr-play { justify-self: start; }
|
|
289
|
+
.pr-name { font-size: clamp(1.75rem, 7cqw, 4.25rem); }
|
|
290
|
+
.pr-tag { font-size: clamp(1rem, 2.9cqw, 1.9rem); }
|
|
291
|
+
.pr-sub { font-size: clamp(0.8125rem, 1.9cqw, 1.15rem); }
|
|
292
|
+
.pr-kicker { font-size: clamp(0.75rem, 1.7cqw, 1.05rem); }
|
|
293
|
+
.pr-play { font-size: clamp(0.9375rem, 2.2cqw, 1.35rem); padding: clamp(12px, 2.2cqh, 22px) clamp(24px, 3.4cqw, 44px); }
|
|
294
|
+
}
|
|
295
|
+
/* A genuinely short box — a landscape phone gives about 312px — drops the third line rather than
|
|
296
|
+
crushing the type, and shrinks the art rather than removing it. Hiding the mark was the first
|
|
297
|
+
attempt and it left the persona anonymous in exactly the orientation somebody plays a game in. */
|
|
298
|
+
@container pr (max-height: 380px) {
|
|
299
|
+
.pr-sub { display: none; }
|
|
300
|
+
.pr-art :global(svg), .pr-art :global(img) { max-width: 22cqw; max-height: 46cqh; }
|
|
301
|
+
.pr-play { margin-top: clamp(10px, 2cqh, 18px); }
|
|
154
302
|
}
|
|
155
303
|
|
|
156
|
-
|
|
157
|
-
|
|
304
|
+
/* ── THE ENTRANCE ────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
* Transform and opacity only, so nothing here changes a layout — the shell's own lesson about the
|
|
306
|
+
* toolbar's strip applies to anything that animates near a scroll spy. Four beats, ~520ms, and the
|
|
307
|
+
* button is tappable from the first frame regardless: an invitation you have to wait for is not
|
|
308
|
+
* one. */
|
|
309
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
310
|
+
.pr-art, .pr-kicker, .pr-name, .pr-tag, .pr-sub, .pr-play {
|
|
311
|
+
animation: pr-rise 0.52s var(--ease-out) both;
|
|
312
|
+
}
|
|
313
|
+
.pr-kicker { animation-delay: 0.04s; }
|
|
314
|
+
.pr-name { animation-delay: 0.09s; }
|
|
315
|
+
.pr-tag { animation-delay: 0.14s; }
|
|
316
|
+
.pr-sub { animation-delay: 0.19s; }
|
|
317
|
+
.pr-play { animation-delay: 0.26s; }
|
|
318
|
+
}
|
|
319
|
+
@keyframes pr-rise {
|
|
320
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
321
|
+
to { opacity: 1; transform: none; }
|
|
322
|
+
}
|
|
158
323
|
</style>
|
|
159
324
|
|
|
160
325
|
<script>
|
|
@@ -169,22 +334,19 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
169
334
|
const root = document.getElementById("pr");
|
|
170
335
|
if (root) {
|
|
171
336
|
const door = document.getElementById("prDoor") as HTMLElement;
|
|
172
|
-
const wait = document.getElementById("prWait") as HTMLElement;
|
|
173
337
|
const stage = document.getElementById("prStage") as HTMLElement;
|
|
174
|
-
const bar = document.getElementById("prBar") as HTMLElement;
|
|
175
338
|
const fill = document.getElementById("prFill") as HTMLElement;
|
|
176
339
|
const playBtn = document.getElementById("prPlay") as HTMLButtonElement;
|
|
340
|
+
const label = document.getElementById("prLabel") as HTMLElement;
|
|
177
341
|
|
|
178
342
|
const src = root.dataset.src as string;
|
|
179
343
|
const atriumId = root.dataset.atrium as string;
|
|
180
344
|
|
|
181
345
|
let started = false;
|
|
182
346
|
|
|
183
|
-
// The stage is never switched — it is underneath the whole time
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
for (const n of [door, wait]) n.hidden = n !== el;
|
|
187
|
-
};
|
|
347
|
+
// The stage is never switched — it is underneath the whole time, and the door is a cover over
|
|
348
|
+
// it. `open()` is the handover: the cover leaves and what was already drawing there is visible.
|
|
349
|
+
const open = () => { door.hidden = true; };
|
|
188
350
|
|
|
189
351
|
// ── PATIENCE IS MEASURED IN SILENCE, NOT IN SECONDS ──────────────────────────────────────
|
|
190
352
|
//
|
|
@@ -205,18 +367,26 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
205
367
|
// down with it.
|
|
206
368
|
let bump = () => {};
|
|
207
369
|
|
|
370
|
+
// THE BUTTON IS THE BAR. Its fill layer grows and its word changes; nothing moves and nothing is
|
|
371
|
+
// replaced. A player who pressed something and watches that same thing work never has to ask
|
|
372
|
+
// where their press went.
|
|
208
373
|
const setProgress = (v: unknown) => {
|
|
209
374
|
bump();
|
|
210
375
|
const n = typeof v === "number" ? v : (v as any)?.value;
|
|
211
376
|
if (typeof n !== "number" || !isFinite(n)) return;
|
|
212
|
-
|
|
377
|
+
playBtn.removeAttribute("data-indeterminate");
|
|
213
378
|
fill.style.width = Math.max(0, Math.min(1, n)) * 100 + "%";
|
|
214
379
|
};
|
|
215
380
|
|
|
216
381
|
const start = async () => {
|
|
217
382
|
if (started) return;
|
|
218
383
|
started = true;
|
|
219
|
-
|
|
384
|
+
// Indeterminate until the game says otherwise, and `disabled` is deliberately NOT used: a
|
|
385
|
+
// disabled button greys out and reads as broken, when what is true is that it is working.
|
|
386
|
+
playBtn.setAttribute("data-busy", "");
|
|
387
|
+
playBtn.setAttribute("data-indeterminate", "");
|
|
388
|
+
playBtn.setAttribute("aria-busy", "true");
|
|
389
|
+
label.textContent = playBtn.dataset.loading || label.textContent;
|
|
220
390
|
|
|
221
391
|
const frame = document.createElement("iframe");
|
|
222
392
|
frame.title = stage.dataset.title || "";
|
|
@@ -304,7 +474,7 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
304
474
|
]);
|
|
305
475
|
if (watchdog) clearTimeout(watchdog);
|
|
306
476
|
|
|
307
|
-
|
|
477
|
+
open();
|
|
308
478
|
// `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
|
|
309
479
|
// is what a console does, where the logo animation IS the loading screen. Nothing else here
|
|
310
480
|
// changes; the difference is only how long this cut keeps the door shut.
|
package/src/Toolbar.astro
CHANGED
|
@@ -328,8 +328,52 @@ import AuthMount from "./AuthMount.astro";
|
|
|
328
328
|
@media (min-width: 640px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
|
|
329
329
|
/* icons row + the scroll-reactive title strip below it */
|
|
330
330
|
.tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
331
|
+
/* queries: --tb-strip · the title strip's height · the toolbar changes height · 26px ·
|
|
332
|
+
* the kernel is 26px short · the game frame does not reach the fold
|
|
333
|
+
*
|
|
334
|
+
* ── THE ONE NUMBER THAT MAKES THIS ROW'S HEIGHT A MOVING TARGET, DECLARED SO IT CAN BE READ ──
|
|
335
|
+
*
|
|
336
|
+
* The strip opens and closes over 0.35s, so `.toolbar`'s height is anywhere in a 26px range at any
|
|
337
|
+
* given moment — and anything that measures the row and feeds a LAYOUT with it is sampling an
|
|
338
|
+
* animation. `--shell-top` did exactly that and the kernel's height came out 26px short whenever
|
|
339
|
+
* the page happened to be measured with the strip open, which is to say whenever somebody opened
|
|
340
|
+
* the persona at #kernel. It reads as a strip of the next section showing above the fold.
|
|
341
|
+
*
|
|
342
|
+
* So the number is a custom property rather than a literal: this rule uses it, and @justai/core's
|
|
343
|
+
* `personaScroll` reads it to compute the row's EXPANDED height without waiting for or racing the
|
|
344
|
+
* transition — `toolbar - strip` is the row, and the row plus this is what the bar will be. One
|
|
345
|
+
* declaration, two readers, and nothing samples a moving value. */
|
|
346
|
+
.toolbar { --tb-strip: 26px; }
|
|
347
|
+
/* queries: the bar grows after load · why does the strip animate on arrival · data-motion ·
|
|
348
|
+
* first paint animation · can this transition wait for the first interaction
|
|
349
|
+
*
|
|
350
|
+
* ── THE TRANSITION IS ALSO A DAMPER, WHICH IS WHY IT CANNOT SIMPLY WAIT ─────────────────────
|
|
351
|
+
*
|
|
352
|
+
* The founder asked the obvious and right question: *"şu geçiş süresi güzel — ama neden bu özellik
|
|
353
|
+
* kullanıcının ilk interaction'ından sonra aktif edilmiyor?"* A persona lands on its kernel, so the
|
|
354
|
+
* strip's correct state on the first frame is open; animating into it grows the bar 26px just after
|
|
355
|
+
* the page appears, which is a glitch nobody asked for.
|
|
356
|
+
*
|
|
357
|
+
* IT WAS TRIED, AND IT BREAKS SOMETHING ELSE. Gated behind a `data-motion` flag set on the first
|
|
358
|
+
* pointer/key/wheel, the strip snaps open during the landing — and that 26px arrives in a SINGLE
|
|
359
|
+
* frame, inside `#below`, while the scroll spy is deciding which cut is active. That is the
|
|
360
|
+
* bistable oscillator `personaScroll` documents at length: the spy measures a page its own output
|
|
361
|
+
* moved. Measured: with the gate in place, `#navProfile` no longer navigates — the page re-asserts
|
|
362
|
+
* the kernel and stays. The 0.35s is not only easing; it is what keeps that step out of one frame.
|
|
363
|
+
*
|
|
364
|
+
* SO THE HEIGHT IS NEVER READ INSTEAD. `--shell-top` is computed as `row + --tb-strip` — the row
|
|
365
|
+
* being the bar minus whatever the strip currently is, which is the same number at every frame of
|
|
366
|
+
* the transition. Nothing samples the animation, so nothing depends on when it was sampled.
|
|
367
|
+
*
|
|
368
|
+
* The real fix for the founder's question is a toolbar whose height does not change at all: reserve
|
|
369
|
+
* the strip's space always and fade only its content. That removes the feedback at the source and
|
|
370
|
+
* makes the gate free — and it is a fleet-wide design decision about how the bar looks at the
|
|
371
|
+
* profile, so it is his to take rather than one to slip in. */
|
|
331
372
|
.tb-title { display: flex; align-items: center; height: 0; overflow: hidden; opacity: 0; transition: height 0.35s var(--ease-out), opacity 0.25s var(--ease-out); }
|
|
332
|
-
|
|
373
|
+
/* The fallback is not decoration: the token is declared on `.toolbar` and consumed here, which is
|
|
374
|
+
a different selector — so the framework's own token spec reads it as ungrounded and is right to.
|
|
375
|
+
A rule that silently collapses to `height: 0` would close the strip and say nothing. */
|
|
376
|
+
.tb-title.show { height: 26px; height: var(--tb-strip, 26px); opacity: 1; }
|
|
333
377
|
.tb-title-text { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.16s var(--ease-out); }
|
|
334
378
|
.tb-title-text.fade { opacity: 0; }
|
|
335
379
|
/* nav lives on the left, beside the back affordance — the middle is intentionally empty */
|