@justai/cuts 0.146.0 → 0.147.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 +1 -1
- package/src/Preroll.astro +89 -12
- package/src/behaviour/preroll.ts +38 -1
package/package.json
CHANGED
package/src/Preroll.astro
CHANGED
|
@@ -43,9 +43,35 @@
|
|
|
43
43
|
// "unstyled" — it means the player watches the game boot through the loading screen.
|
|
44
44
|
import "./ground.css";
|
|
45
45
|
|
|
46
|
+
type StoreKey = "ios" | "mac" | "android" | "steam" | "windows" | "switch" | "playstation" | "xbox";
|
|
47
|
+
|
|
46
48
|
interface Props {
|
|
47
|
-
/** The game's own document, served from this persona's origin. */
|
|
48
|
-
src
|
|
49
|
+
/** The game's own document, served from this persona's origin. Give this OR `stores`, never both. */
|
|
50
|
+
src?: string;
|
|
51
|
+
|
|
52
|
+
// ── THE DOOR THAT OPENS OUTWARD ───────────────────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// queries: native game persona · a game persona for an app store title · the play button goes
|
|
55
|
+
// to the app store · store links on the preroll · door store · no frame · external store ·
|
|
56
|
+
// persona published on the app store · cob · which store does the button open
|
|
57
|
+
//
|
|
58
|
+
// THE KERNEL OF A GAME PERSONA IS NOT THE GAME; IT IS THE DOOR TO IT — and a door can open
|
|
59
|
+
// outward. A game that lives in the App Store, Google Play or Steam is still one character with
|
|
60
|
+
// one home on pin.games: the same card, the same feed, the same three child personas. What differs
|
|
61
|
+
// is where the button leads. With `stores` instead of `src` the door keeps its composition and its
|
|
62
|
+
// gesture, and the button becomes a link to the store this visitor's device can install from — no
|
|
63
|
+
// frame is ever created, nothing is fetched, atrium is never asked for a room.
|
|
64
|
+
//
|
|
65
|
+
// Declared in the persona's manifest as `door: store` (dna · persona.example.yml), and this is the
|
|
66
|
+
// shell's side of that declaration. The first citizen: pg-cob, Clash of Boats — the ecosystem's
|
|
67
|
+
// first persona published on external stores (2026-09-15).
|
|
68
|
+
//
|
|
69
|
+
// The keys are the platforms a title can be installed on; the values are the store pages. Order
|
|
70
|
+
// is preference: the first one is the button's address before the device has been asked, and the
|
|
71
|
+
// address for a device nothing below matches.
|
|
72
|
+
stores?: Partial<Record<StoreKey, string>>;
|
|
73
|
+
/** The small word before the row of every store, e.g. "Also on". Only read with `stores`. */
|
|
74
|
+
also?: string;
|
|
49
75
|
|
|
50
76
|
// ── THE FIVE FIELDS ARE THE PERSONA'S CARD ────────────────────────────────────────────────
|
|
51
77
|
//
|
|
@@ -86,11 +112,28 @@ interface Props {
|
|
|
86
112
|
/** atrium ids are a grammar — [a-z0-9._-] — so this is sanitised rather than trusted. */
|
|
87
113
|
id?: string;
|
|
88
114
|
}
|
|
89
|
-
const { src, kicker, name, tag, sub, play, title, loading = "Loading", id = "game" } = Astro.props;
|
|
115
|
+
const { src, stores, also = "Also on", kicker, name, tag, sub, play, title, loading = "Loading", id = "game" } = Astro.props;
|
|
90
116
|
const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x");
|
|
117
|
+
|
|
118
|
+
// ONE DOOR, ONE DIRECTION. A preroll with both a frame and a store list would render two answers to
|
|
119
|
+
// "what happens when I press play", and the one nobody meant is the one somebody finds in production
|
|
120
|
+
// — the same refusal the template makes for two Cores in one kernel. Refused at build, with a sentence.
|
|
121
|
+
const outward = Object.entries(stores ?? {}).filter(([, url]) => typeof url === "string" && url) as [StoreKey, string][];
|
|
122
|
+
if (src && outward.length) throw new Error("<Preroll> takes `src` (a game document) or `stores` (a native title), not both");
|
|
123
|
+
if (!src && !outward.length) throw new Error("<Preroll> needs a door: `src` for a game document, or `stores` with at least one store page");
|
|
124
|
+
|
|
125
|
+
// Proper nouns, not copy — a store's name is the same in every language, which is why these live
|
|
126
|
+
// here and not in a persona's dictionary. An unknown key is shown as itself rather than refused: the
|
|
127
|
+
// next console to exist should not need this file to change first.
|
|
128
|
+
const STORE_NAMES: Record<string, string> = {
|
|
129
|
+
ios: "App Store", mac: "Mac App Store", android: "Google Play", steam: "Steam",
|
|
130
|
+
windows: "Microsoft Store", switch: "Nintendo eShop", playstation: "PlayStation Store", xbox: "Xbox",
|
|
131
|
+
};
|
|
132
|
+
const storeRows = outward.map(([key, url]) => ({ key, url, label: STORE_NAMES[key] ?? key }));
|
|
133
|
+
const primary = storeRows[0]?.url;
|
|
91
134
|
---
|
|
92
135
|
|
|
93
|
-
<div class="pr" id="pr" data-src={src} data-atrium={safeId}>
|
|
136
|
+
<div class="pr" id="pr" data-src={src} data-atrium={src ? safeId : undefined} data-door={outward.length ? "store" : "frame"}>
|
|
94
137
|
{/* ── THE STAGE IS THE FLOW; THE DOOR IS A COVER OVER IT ──────────────────────────────────────
|
|
95
138
|
queries: the game boots at zero size · getBoundingClientRect returns 0 in the frame ·
|
|
96
139
|
the game jumps when the preroll hands over · display none iframe · hidden iframe
|
|
@@ -118,10 +161,29 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
118
161
|
{/* THE INVITATION BECOMES THE WAIT, IN PLACE. The first version replaced the whole door with a
|
|
119
162
|
bar in an empty room: two screens, and the player was moved from one to the other for no
|
|
120
163
|
reason they could see. The composition stays; this button fills. */}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
{outward.length ? (
|
|
165
|
+
<>
|
|
166
|
+
{/* THE DOOR THAT OPENS OUTWARD. The same button in the same place, and it is a LINK: it works
|
|
167
|
+
with no script at all, it opens the store app on a phone, and it keeps this persona open
|
|
168
|
+
behind it. The behaviour module only improves the address — it asks the device which
|
|
169
|
+
store it can install from and points the button there; the row below shows every other
|
|
170
|
+
way in, so a desktop visitor is never told "iPhone only" by a page that knew better. */}
|
|
171
|
+
<a class="pr-play" id="prPlay" href={primary} target="_blank" rel="noopener" data-label={play}>
|
|
172
|
+
<span id="prLabel">{play}</span>
|
|
173
|
+
</a>
|
|
174
|
+
<p class="pr-stores" id="prStores">
|
|
175
|
+
<span class="pr-stores-also">{also}</span>
|
|
176
|
+
{storeRows.map((s) => (
|
|
177
|
+
<a href={s.url} target="_blank" rel="noopener" data-store={s.key}>{s.label}</a>
|
|
178
|
+
))}
|
|
179
|
+
</p>
|
|
180
|
+
</>
|
|
181
|
+
) : (
|
|
182
|
+
<button class="pr-play" id="prPlay" type="button" data-label={play} data-loading={loading}>
|
|
183
|
+
<i class="pr-fill" id="prFill"></i>
|
|
184
|
+
<span id="prLabel">{play}</span>
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
125
187
|
|
|
126
188
|
{/* queries: preroll imprint · sponsor · brand on the preroll · monetization out of the frame
|
|
127
189
|
AN EMPTY SLOT ON PURPOSE, and it renders nothing until somebody fills it. The preroll is the
|
|
@@ -200,8 +262,8 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
200
262
|
* where the browser puts its own bar. He sent a screenshot from one: the URL bar sitting over the
|
|
201
263
|
* play button, and a strip of the next section showing beneath it. A composition that ends at the
|
|
202
264
|
* 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";
|
|
265
|
+
grid-template-rows: 50% auto auto auto minmax(0, 1fr);
|
|
266
|
+
grid-template-areas: "art" "copy" "play" "stores" "imprint";
|
|
205
267
|
padding: clamp(18px, 7cqw, 64px);
|
|
206
268
|
/* And the strip the OS keeps for its home indicator, on top of the inset — never instead of it. */
|
|
207
269
|
padding-block-end: calc(clamp(18px, 7cqw, 64px) + env(safe-area-inset-bottom, 0px));
|
|
@@ -273,7 +335,22 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
273
335
|
background: var(--accent); color: var(--accent-on); cursor: pointer;
|
|
274
336
|
transition: transform 0.18s var(--ease-spring), opacity 0.2s;
|
|
275
337
|
}
|
|
338
|
+
/* The outward door's button is an <a>, and a link has none of a button's box by default. Said
|
|
339
|
+
here once so both elements are the same shape to the eye and to the grid. */
|
|
340
|
+
.pr-play { display: inline-flex; align-items: center; justify-content: center; text-align: center; text-decoration: none; }
|
|
276
341
|
.pr-play:active { transform: scale(0.97); }
|
|
342
|
+
/* ── EVERY OTHER WAY IN ──────────────────────────────────────────────────────────────────────
|
|
343
|
+
Rendered only by the outward door: the word, then one link per store. The one the device
|
|
344
|
+
matched is marked and quietened, because it is already the button. Wraps rather than scrolls —
|
|
345
|
+
a landscape phone is the shape this row is most likely to be read in. */
|
|
346
|
+
.pr-stores {
|
|
347
|
+
grid-area: stores; margin: clamp(8px, 1.6cqh, 14px) 0 0;
|
|
348
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 0.35em 0.9em;
|
|
349
|
+
font-size: clamp(0.75rem, 3cqw, 0.95rem); font-weight: 500; color: #8b949e;
|
|
350
|
+
}
|
|
351
|
+
.pr-stores a { color: #f5f7fa; text-decoration: none; border-bottom: 1px solid color-mix(in srgb, #f5f7fa 30%, transparent); }
|
|
352
|
+
.pr-stores a[data-current] { color: #8b949e; border-bottom-color: transparent; }
|
|
353
|
+
@container pr (max-height: 380px) { .pr-stores { display: none; } }
|
|
277
354
|
.pr-play[data-busy] { cursor: default; }
|
|
278
355
|
.pr-play[data-busy]:active { transform: none; }
|
|
279
356
|
.pr-play span { position: relative; z-index: 1; }
|
|
@@ -301,8 +378,8 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
301
378
|
centred in the box rather than pinned to its top. */
|
|
302
379
|
.pr-door {
|
|
303
380
|
grid-template-columns: 1fr auto;
|
|
304
|
-
grid-template-rows: auto auto auto;
|
|
305
|
-
grid-template-areas: "copy art" "play art" "imprint art";
|
|
381
|
+
grid-template-rows: auto auto auto auto;
|
|
382
|
+
grid-template-areas: "copy art" "play art" "stores art" "imprint art";
|
|
306
383
|
align-content: center;
|
|
307
384
|
column-gap: clamp(24px, 5cqw, 72px);
|
|
308
385
|
}
|
package/src/behaviour/preroll.ts
CHANGED
|
@@ -15,8 +15,45 @@
|
|
|
15
15
|
// name lets any frame on the page claim to be the game.
|
|
16
16
|
import atrium from "@justai/atrium";
|
|
17
17
|
|
|
18
|
+
// ── THE DOOR THAT OPENS OUTWARD ─────────────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// queries: native game persona · store door · the play button opens the app store · which store ·
|
|
21
|
+
// door store · pg-cob · a game persona with no frame · iPad reports as a Mac
|
|
22
|
+
//
|
|
23
|
+
// A game that lives in a store is still a persona here; only the door's direction differs. The
|
|
24
|
+
// component already rendered the button as a LINK to the first declared store, so this module's
|
|
25
|
+
// whole job is to improve the address: ask the device which store it can install from, point the
|
|
26
|
+
// button there, and mark that store in the row beneath. Nothing else in this file runs — no frame,
|
|
27
|
+
// no router, no watchdog — because there is no frame to wait for.
|
|
28
|
+
//
|
|
29
|
+
// The device is asked, never the reader: no locale, no cookie, no guess about who they are.
|
|
30
|
+
// iPadOS is the one trap — Safari there reports a Mac with touch points, and a Mac App Store link on
|
|
31
|
+
// an iPad is a dead end — so a touch-capable "Mac" is read as iOS first.
|
|
32
|
+
export function storeFor(keys: string[], ua: string, platform: string, touchPoints: number): string | null {
|
|
33
|
+
const has = (k: string) => (keys.includes(k) ? k : null);
|
|
34
|
+
const iPadOS = /Mac/.test(platform) && touchPoints > 1;
|
|
35
|
+
if (/iPhone|iPad|iPod/.test(ua) || iPadOS) return has("ios") ?? has("mac");
|
|
36
|
+
if (/Android/.test(ua)) return has("android");
|
|
37
|
+
if (/Mac/.test(platform)) return has("mac") ?? has("ios") ?? has("steam");
|
|
38
|
+
if (/Win/.test(platform)) return has("windows") ?? has("steam") ?? has("xbox");
|
|
39
|
+
if (/Linux|X11/.test(platform)) return has("steam");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
const root = document.getElementById("pr");
|
|
19
|
-
if (root) {
|
|
44
|
+
if (root && root.dataset.door === "store") {
|
|
45
|
+
const playLink = document.getElementById("prPlay") as HTMLAnchorElement | null;
|
|
46
|
+
const rows = Array.from(document.querySelectorAll<HTMLAnchorElement>("#prStores a[data-store]"));
|
|
47
|
+
const keys = rows.map((a) => a.dataset.store as string);
|
|
48
|
+
const platform = (navigator as any).userAgentData?.platform || navigator.platform || "";
|
|
49
|
+
const chosen = storeFor(keys, navigator.userAgent, platform, navigator.maxTouchPoints || 0);
|
|
50
|
+
const row = rows.find((a) => a.dataset.store === chosen);
|
|
51
|
+
if (playLink && row) {
|
|
52
|
+
playLink.href = row.href;
|
|
53
|
+
playLink.dataset.store = chosen as string;
|
|
54
|
+
row.setAttribute("data-current", "");
|
|
55
|
+
}
|
|
56
|
+
} else if (root) {
|
|
20
57
|
const door = document.getElementById("prDoor") as HTMLElement;
|
|
21
58
|
const stage = document.getElementById("prStage") as HTMLElement;
|
|
22
59
|
const fill = document.getElementById("prFill") as HTMLElement;
|