@justai/cuts 0.145.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.145.0",
3
+ "version": "0.147.0",
4
4
  "description": "A persona's named parts — 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.38.0",
44
+ "@justai/core": "^0.39.0",
45
45
  "@justai/ui": "^0.26.0",
46
46
  "@justai/atrium": "^0.1.9"
47
47
  },
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: string;
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
- <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>
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
  }
@@ -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;