@justai/cuts 0.148.0 → 0.150.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.148.0",
3
+ "version": "0.150.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": {
@@ -13,8 +13,10 @@
13
13
  // THE TWO MARKS ARE SIBLINGS, DRAWN THE SAME WAY. app.zone is four separated corner brackets around a
14
14
  // centre — a zone is the space the corners enclose. pin.games is three vertex brackets around a play
15
15
  // triangle, drawn with app.zone's own part (pin-games, 2026-08-25). The zone has four corners; the
16
- // play has three. Same radii, same palette, same idea of a figure assembled from pieces, which is
17
- // what keeps them reading as one family at 20px.
16
+ // play has three. City in Motion is a marker: two chevrons, the circle its head (app-zone,
17
+ // the-family-of-marks). Same
18
+ // radii, same palette, same idea of a figure assembled from pieces, which is what keeps them reading
19
+ // as one family at 20px.
18
20
  //
19
21
  // Home affordance → the apex (the platform / App Store home). Standard on every app:
20
22
  // always visible at the top-left of the toolbar. Also tidies the ?ref / ?lang hints
@@ -83,7 +85,11 @@ const href = `https://${apex}`;
83
85
  * It self-corrects: the day a household has a mark here, every persona under it grows the affordance
84
86
  * on its next build, with no persona-level change anywhere.
85
87
  */
86
- const MARKS = ["app.zone", "pin.games"];
88
+ //
89
+ // cityinmotion.nl joined the table on 2026-10-05. For six weeks its personas had declined the control,
90
+ // exactly as written above — the honest outcome while there was no mark here, and a missing way home
91
+ // once there was one: every CiM persona already passed apex="cityinmotion.nl" and was waiting for it.
92
+ const MARKS = ["app.zone", "pin.games", "cityinmotion.nl"];
87
93
  const drawable = MARKS.includes(apex);
88
94
 
89
95
  // queries: the home mark is missing arms · the mark is the wrong size · 36px · scoped styles do
@@ -113,6 +119,18 @@ const drawable = MARKS.includes(apex);
113
119
  <path class="arm a3" d="M550.15 91.57L399.09 170.67A17.93 17.93 0 0 1 374.89 163.1L346.57 109.02A17.93 17.93 0 0 1 354.14 84.82L505.19 5.72A6.45 6.45 0 0 0 505.19 -5.72L354.14 -84.82A17.93 17.93 0 0 1 346.57 -109.02L374.89 -163.1A17.93 17.93 0 0 1 399.09 -170.67L550.15 -91.57A103.36 103.36 0 0 1 550.15 91.57Z"/>
114
120
  <path class="ctr" d="M158.3 -92.3Q143.0 -100.3 143.0 -83.1L143.0 83.1Q143.0 100.3 158.3 92.3L319.3 8.0Q334.6 0.0 319.3 -8.0Z"/>
115
121
  </svg>
122
+ ) : apex === "cityinmotion.nl" ? (
123
+ <svg class="cimmark" viewBox="-353.2 -386.42 706.39 725.18" aria-hidden="true">
124
+ <g transform="scale(1.04)">
125
+ <path class="arm a1" d="M-110.04 -35.11L-333.8 -240.15A17.93 17.93 0 0 1 -334.9 -265.48L-293.66 -310.49A17.93 17.93 0 0 1 -268.33 -311.6L-44.57 -106.56A65.97 65.97 0 0 0 44.57 -106.56L268.33 -311.6A17.93 17.93 0 0 1 293.66 -310.49L334.9 -265.48A17.93 17.93 0 0 1 333.8 -240.15L110.04 -35.11A162.88 162.88 0 0 1 -110.04 -35.11Z"/>
126
+ <path class="arm a2" d="M-110.04 282.94L-333.8 77.9A17.93 17.93 0 0 1 -334.9 52.57L-293.66 7.56A17.93 17.93 0 0 1 -268.33 6.46L-44.57 211.49A65.97 65.97 0 0 0 44.57 211.49L268.33 6.46A17.93 17.93 0 0 1 293.66 7.56L334.9 52.57A17.93 17.93 0 0 1 333.8 77.9L110.04 282.94A162.88 162.88 0 0 1 -110.04 282.94Z"/>
127
+ <circle class="ctr" cx="0" cy="-278.85" r="92.71"/>
128
+ </g>
129
+ <defs>
130
+ <linearGradient id="cimHome1" x1="0" y1="-287.99" x2="0" y2="96.91" gradientUnits="userSpaceOnUse"><stop stop-color="#5285DC"/><stop offset="1" stop-color="#2E62C6"/></linearGradient>
131
+ <linearGradient id="cimHome2" x1="0" y1="30.07" x2="0" y2="414.96" gradientUnits="userSpaceOnUse"><stop stop-color="#F2694A"/><stop offset="1" stop-color="#E04A30"/></linearGradient>
132
+ </defs>
133
+ </svg>
116
134
  ) : (
117
135
  <svg class="azmark" viewBox="-8.5 0 928 928" aria-hidden="true">
118
136
  <path class="arm a1" d="M360.365 0C372.599 0 382.517 9.91778 382.517 22.152V97.7555C382.517 109.99 372.599 119.907 360.365 119.907H203.398C157.082 119.907 119.537 157.453 119.537 203.769V353.64C119.537 370.244 106.077 383.704 89.4732 383.704H22.152C9.91778 383.704 0 373.786 0 361.552V175.633C0 78.6337 78.6338 0 175.634 0H360.365Z"/>
@@ -167,6 +185,19 @@ const drawable = MARKS.includes(apex);
167
185
  :global(html:not([data-theme="dark"])) .home:hover .pgmark .ctr, :global(html:not([data-theme="dark"])) .home:focus-visible .pgmark .ctr { fill: #2B2724; }
168
186
  }
169
187
 
188
+ /* queries: City in Motion's mark in the toolbar · cimmark · the CiM home control
189
+ cityinmotion.nl's own public/mark.svg, unlit, by the same rules: a marker, two chevrons and the
190
+ circle for its head, the viewBox the ink's own bounds (measured with the arcs and the 1.04 scale the apex draws
191
+ at), ink at rest, its own gradients and the ground's centre when engaged. */
192
+ .cimmark .arm,
193
+ .cimmark .ctr { fill: currentColor; }
194
+ .home:hover .cimmark .a1, .home:focus-visible .cimmark .a1 { fill: url(#cimHome1); }
195
+ .home:hover .cimmark .a2, .home:focus-visible .cimmark .a2 { fill: url(#cimHome2); }
196
+ .home:hover .cimmark .ctr, .home:focus-visible .cimmark .ctr { fill: #F5F1E8; }
197
+ @media (prefers-color-scheme: light) {
198
+ :global(html:not([data-theme="dark"])) .home:hover .cimmark .ctr, :global(html:not([data-theme="dark"])) .home:focus-visible .cimmark .ctr { fill: #2B2724; }
199
+ }
200
+
170
201
  .home:hover .azmark .a1, .home:focus-visible .azmark .a1 { fill: url(#azHome1); }
171
202
  .home:hover .azmark .a2, .home:focus-visible .azmark .a2 { fill: url(#azHome2); }
172
203
  .home:hover .azmark .a3, .home:focus-visible .azmark .a3 { fill: url(#azHome3); }
package/src/Base.astro CHANGED
@@ -92,11 +92,14 @@ 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";
100
103
  }
101
104
 
102
105
  const {
@@ -147,8 +150,9 @@ if (siteProp && Astro.site && new URL(siteProp).origin !== Astro.site.origin) {
147
150
  // bottom of the screen because there is nothing there that is not the room. A persona that wants
148
151
  // otherwise passes its own — this is a default, not a law.
149
152
  const GAME_GROUND = "#080b12";
150
- const dark = themeDark ?? (kind === "game" ? GAME_GROUND : "#000000");
151
- const light = themeLight ?? (kind === "game" ? GAME_GROUND : "#f2f2f7");
153
+ const placeKind = kind === "game" || kind === "art"; // a place you go into: the dark ground in both schemes
154
+ const dark = themeDark ?? (placeKind ? GAME_GROUND : "#000000");
155
+ const light = themeLight ?? (placeKind ? GAME_GROUND : "#f2f2f7");
152
156
 
153
157
  const d = dirOf(lang);
154
158
  const consent = consentCopy(lang);
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