@justai/cuts 0.149.0 → 0.151.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.149.0",
3
+ "version": "0.151.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,8 +41,8 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@justai/core": "^0.39.0",
45
- "@justai/ui": "^0.26.0",
44
+ "@justai/core": "^0.40.0",
45
+ "@justai/ui": "^0.27.0",
46
46
  "@justai/atrium": "^0.1.9"
47
47
  },
48
48
  "scripts": {
package/src/Base.astro CHANGED
@@ -92,11 +92,42 @@ interface Props {
92
92
  * sides and is *at least* the active view's height; a GAME kernel keeps no inset at all and is
93
93
  * *exactly* that height.
94
94
  *
95
+ * `art` (2026-10-09) — a musical or visual work that embeds its piece — takes the game's geometry and
96
+ * its dark ground: it is entered, not scrolled.
97
+ *
95
98
  * IT DEFAULTS TO `app` BECAUSE FIFTEEN OF THE SIXTEEN PERSONAS ARE ONE. The failure of getting it
96
99
  * wrong is quiet in the right direction: a game persona that forgets it renders as an app — margins
97
100
  * where it wanted none — rather than an app persona bleeding to the edges.
98
101
  */
99
- kind?: "app" | "game" | "now" | "expert" | "knower" | "platform";
102
+ kind?: "app" | "game" | "art" | "now" | "expert" | "knower" | "platform";
103
+ /**
104
+ * queries: a persona with pages of its own · canonical points at the home · every page has the
105
+ * persona's og image · per-page title · per-page share card · hreflang for a sub-page ·
106
+ * a QR opens a page that previews as the home
107
+ *
108
+ * A PAGE OF ITS OWN — for a persona whose kernel is many named things, each at its own address
109
+ * (`card.cityinmotion.nl/where-waters-meet`). Leave it out and this layout writes the persona's
110
+ * HOME, which is right for every persona that has one page: the persona's title, its `/og.jpg`, and
111
+ * a canonical at the locale's root.
112
+ *
113
+ * Written for a page that is NOT the home, those three are false in the worst way: nothing is red,
114
+ * the page renders, and every one of its tags says "I am the home". A search engine then folds all
115
+ * of them into one result (a canonical is an instruction, not a hint it ignores), and a link to one
116
+ * of them pasted into a chat previews as the persona's generic card instead of the thing linked —
117
+ * which, for a printed QR code, is the one impression there is. Birth 10 found it before the first
118
+ * page shipped.
119
+ *
120
+ * `path(locale)` is the page's address in each locale, so the alternates name THIS page in every
121
+ * language rather than the other languages' homes. `image` is absolute or site-relative; its size is
122
+ * required because a preview that has to fetch the image to learn its shape renders late or not at
123
+ * all.
124
+ */
125
+ page?: {
126
+ path: (locale: string) => string;
127
+ title: string;
128
+ description: string;
129
+ image?: { url: string; width: number; height: number; type?: string; alt?: string };
130
+ };
100
131
  }
101
132
 
102
133
  const {
@@ -105,6 +136,7 @@ const {
105
136
  themeLight,
106
137
  gaId = "G-13L3BP6XCW",
107
138
  kind = "app",
139
+ page,
108
140
  } = Astro.props;
109
141
 
110
142
  // ── ONE HOME FOR THE ORIGIN ───────────────────────────────────────────────────────────────────
@@ -147,12 +179,23 @@ if (siteProp && Astro.site && new URL(siteProp).origin !== Astro.site.origin) {
147
179
  // bottom of the screen because there is nothing there that is not the room. A persona that wants
148
180
  // otherwise passes its own — this is a default, not a law.
149
181
  const GAME_GROUND = "#080b12";
150
- const dark = themeDark ?? (kind === "game" ? GAME_GROUND : "#000000");
151
- const light = themeLight ?? (kind === "game" ? GAME_GROUND : "#f2f2f7");
182
+ const placeKind = kind === "game" || kind === "art"; // a place you go into: the dark ground in both schemes
183
+ const dark = themeDark ?? (placeKind ? GAME_GROUND : "#000000");
184
+ const light = themeLight ?? (placeKind ? GAME_GROUND : "#f2f2f7");
152
185
 
153
186
  const d = dirOf(lang);
154
187
  const consent = consentCopy(lang);
155
188
  const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
189
+
190
+ // The head speaks for the page when the page has its own address, and for the persona otherwise —
191
+ // see `page` above. One set of values, so the canonical, og:url and the alternates cannot disagree.
192
+ const hrefOf = (l: string) => `${site}${page ? page.path(l) : localePath(l)}`;
193
+ const canonical = hrefOf(lang);
194
+ const headTitle = page?.title ?? t.metaTitle;
195
+ const headDescription = page?.description ?? t.metaDescription;
196
+ const ogImage = page?.image
197
+ ? { ...page.image, url: new URL(page.image.url, Astro.site).href }
198
+ : { url: new URL("/og.jpg", Astro.site).href, width: 1200, height: 630, type: "image/jpeg", alt: undefined };
156
199
  ---
157
200
 
158
201
  <!doctype html>
@@ -244,26 +287,27 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
244
287
  } catch (e) {}
245
288
  })();
246
289
  </script>
247
- <title>{t.metaTitle}</title>
248
- <meta name="description" content={t.metaDescription} />
249
- <meta property="og:title" content={t.metaTitle} />
250
- <meta property="og:description" content={t.metaDescription} />
290
+ <title>{headTitle}</title>
291
+ <meta name="description" content={headDescription} />
292
+ <meta property="og:title" content={headTitle} />
293
+ <meta property="og:description" content={headDescription} />
251
294
  <meta property="og:type" content="website" />
252
- <meta property="og:image" content={new URL("/og.jpg", Astro.site)} />
253
- <meta property="og:image:type" content="image/jpeg" />
254
- <meta property="og:image:width" content="1200" />
255
- <meta property="og:image:height" content="630" />
256
- <meta property="og:url" content={new URL(Astro.url.pathname, Astro.site)} />
295
+ <meta property="og:image" content={ogImage.url} />
296
+ {ogImage.type && <meta property="og:image:type" content={ogImage.type} />}
297
+ <meta property="og:image:width" content={String(ogImage.width)} />
298
+ <meta property="og:image:height" content={String(ogImage.height)} />
299
+ {ogImage.alt && <meta property="og:image:alt" content={ogImage.alt} />}
300
+ <meta property="og:url" content={page ? canonical : new URL(Astro.url.pathname, Astro.site).href} />
257
301
  <meta property="og:site_name" content={new URL(site).host} />
258
302
  <meta name="twitter:card" content="summary_large_image" />
259
- <meta name="twitter:image" content={new URL("/og.jpg", Astro.site)} />
303
+ <meta name="twitter:image" content={ogImage.url} />
260
304
  <meta name="theme-color" content={dark} media="(prefers-color-scheme: dark)" />
261
305
  <meta name="theme-color" content={light} media="(prefers-color-scheme: light)" />
262
- <link rel="canonical" href={`${site}${localePath(lang)}`} />
306
+ <link rel="canonical" href={canonical} />
263
307
  {locales.map((l) => (
264
- <link rel="alternate" hreflang={l} href={`${site}${localePath(l)}`} />
308
+ <link rel="alternate" hreflang={l} href={hrefOf(l)} />
265
309
  ))}
266
- <link rel="alternate" hreflang="x-default" href={`${site}/`} />
310
+ <link rel="alternate" hreflang="x-default" href={page ? hrefOf(defaultLocale) : `${site}/`} />
267
311
  <link rel="icon" href="/favicon.ico" sizes="32x32" />
268
312
  <link
269
313
  rel="icon"
package/src/Preroll.astro CHANGED
@@ -46,9 +46,24 @@ import "./ground.css";
46
46
  type StoreKey = "ios" | "mac" | "android" | "steam" | "windows" | "switch" | "playstation" | "xbox";
47
47
 
48
48
  interface Props {
49
- /** The game's own document, served from this persona's origin. Give this OR `stores`, never both. */
49
+ /** The game's own document, served from this persona's origin — or a WORK at its own address
50
+ * (`https://…`), embedded as it is. Give this OR `stores`, never both. */
50
51
  src?: string;
51
52
 
53
+ // ── A WORK THAT LIVES AT ITS OWN ADDRESS ──────────────────────────────────────────────────
54
+ //
55
+ // queries: art persona · embed a work by iframe · the kernel embeds another site · city rises ·
56
+ // the work updates without the shell · handover on load · a frame that does not speak atrium
57
+ //
58
+ // An `art` persona's kernel does not carry the work, it EMBEDS it: City Rises lives at
59
+ // city-rises.justrd.pro and keeps changing there, and city-rises.cityinmotion.nl shows it without a
60
+ // release of its own. Such a work does not speak the game–shell protocol — it has its own gate and
61
+ // asks for its own gesture — so waiting for a `boot` answer would hold the door for the whole
62
+ // patience window. `handover` says what ends the wait: `boot` (the protocol's answer, the default
63
+ // for a document on this origin) or `load` (the frame's own load event, the default for a `src`
64
+ // that names another origin). Same door, same gesture, same bar; only the signal differs.
65
+ handover?: "boot" | "load";
66
+
52
67
  // ── THE DOOR THAT OPENS OUTWARD ───────────────────────────────────────────────────────────
53
68
  //
54
69
  // queries: native game persona · a game persona for an app store title · the play button goes
@@ -113,6 +128,7 @@ interface Props {
113
128
  id?: string;
114
129
  }
115
130
  const { src, stores, also = "Also on", kicker, name, tag, sub, play, title, loading = "Loading", id = "game" } = Astro.props;
131
+ const handover = Astro.props.handover ?? (/^https?:\/\//i.test(src ?? "") ? "load" : "boot");
116
132
  const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "x");
117
133
 
118
134
  // ONE DOOR, ONE DIRECTION. A preroll with both a frame and a store list would render two answers to
@@ -133,7 +149,7 @@ const storeRows = outward.map(([key, url]) => ({ key, url, label: STORE_NAMES[ke
133
149
  const primary = storeRows[0]?.url;
134
150
  ---
135
151
 
136
- <div class="pr" id="pr" data-src={src} data-atrium={src ? safeId : undefined} data-door={outward.length ? "store" : "frame"}>
152
+ <div class="pr" id="pr" data-src={src} data-handover={src ? handover : undefined} data-atrium={src ? safeId : undefined} data-door={outward.length ? "store" : "frame"}>
137
153
  {/* ── THE STAGE IS THE FLOW; THE DOOR IS A COVER OVER IT ──────────────────────────────────────
138
154
  queries: the game boots at zero size · getBoundingClientRect returns 0 in the frame ·
139
155
  the game jumps when the preroll hands over · display none iframe · hidden iframe
@@ -62,6 +62,8 @@ if (root && root.dataset.door === "store") {
62
62
 
63
63
  const src = root.dataset.src as string;
64
64
  const atriumId = root.dataset.atrium as string;
65
+ // `load` for a work at its own address (see Preroll.astro, "A WORK THAT LIVES AT ITS OWN ADDRESS").
66
+ const onLoad = root.dataset.handover === "load";
65
67
 
66
68
  let started = false;
67
69
 
@@ -268,7 +270,10 @@ if (root && root.dataset.door === "store") {
268
270
  const here = new URLSearchParams(location.search);
269
271
  const url = new URL(src, location.href);
270
272
  for (const flag of ["xray", "slow"]) if (here.has(flag)) url.searchParams.set(flag, here.get(flag) ?? "");
271
- frame.src = url.pathname + url.search;
273
+ // A document on this origin is named by its path, as it always was; a work at another address
274
+ // keeps its whole address — the path alone would load a page of this persona into the frame.
275
+ frame.src = url.origin === location.origin ? url.pathname + url.search : url.href;
276
+ const loaded = new Promise<any>((resolve) => frame.addEventListener("load", () => resolve({}), { once: true }));
272
277
 
273
278
  // ── THE HANDOVER ─────────────────────────────────────────────────────────────────────────
274
279
  //
@@ -309,12 +314,16 @@ if (root && root.dataset.door === "store") {
309
314
  // not lose the game to a broken handshake — but the catch SAYS what went wrong, because the
310
315
  // symptom on the other side of it is "the wait ended early" and nothing else. This cut's own
311
316
  // one-second bug lived inside an empty catch and was invisible until a spec read the bar.
317
+ // A work that does not speak the protocol is ready when its document has loaded — it draws its
318
+ // own gate from there. Its frame's load event joins the race only for `handover: load`: a game on
319
+ // this origin loads its document long before it can draw, and that is what the boot answer is for.
312
320
  const answer: any = await Promise.race([
313
321
  boot.catch((e: any) => {
314
322
  console.warn("[preroll] the game's boot did not complete:", e?.message || e);
315
323
  return {};
316
324
  }),
317
325
  giveUp,
326
+ ...(onLoad ? [loaded] : []),
318
327
  ]);
319
328
  if (watchdog) clearTimeout(watchdog);
320
329