@justai/cuts 0.156.0 → 0.158.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.156.0",
3
+ "version": "0.158.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": {
@@ -32,6 +32,10 @@
32
32
  "./Sheet.astro": "./src/Sheet.astro",
33
33
  "./Toolbar.astro": "./src/Toolbar.astro",
34
34
  "./ground.css": "./src/ground.css",
35
+ "./personaHref": {
36
+ "types": "./src/personaHref.d.ts",
37
+ "default": "./src/personaHref.js"
38
+ },
35
39
  "./tagline.js": "./src/tagline.js"
36
40
  },
37
41
  "peerDependencies": {
@@ -38,6 +38,7 @@
38
38
  // statement about what the ecosystem IS, living in one page's markup, twice. It is now a question
39
39
  // asked of the registry, which has carried `personaKind` since long before anything read it.
40
40
  import CatalogueCard, { type CatalogueEntry } from "./CatalogueCard.astro";
41
+ import { personaHref } from "./personaHref.js";
41
42
 
42
43
  interface Props {
43
44
  /** BCP-47 code of the page — the language the registry is asked for. */
@@ -63,6 +64,19 @@ interface Props {
63
64
  kind?: string;
64
65
  /** The apex's own host, used for the referral parameter on every card. */
65
66
  apexHost: string;
67
+ /**
68
+ * queries: localeLinks · clean locale links · ref=app.zone · lang parameter · data-locale-links
69
+ *
70
+ * LINK STRAIGHT TO THE PERSONA'S PAGE IN THIS PAGE'S LANGUAGE — `https://<home>/<lang>/`, no query
71
+ * string — instead of `https://<home>/?ref=<apex>&lang=<lang>`.
72
+ *
73
+ * An apex turns this on when its citizens all serve the apex's languages at `/<lang>/` (app.zone:
74
+ * measured, 37 of 37 personas × 13 languages). It is a statement only the apex can make, which is
75
+ * why it is a prop and not the default: a persona that lacks the language answers 404 at that
76
+ * address, where the old hint was quietly ignored. See ./personaHref.js for the measurements that
77
+ * made the parameters worth removing.
78
+ */
79
+ localeLinks?: boolean;
66
80
  /** Layer one. Empty is valid and correct for an apex with nothing seeded yet. */
67
81
  seed?: CatalogueEntry[];
68
82
  /** The curated sequence, keyed by host — read by the runtime sort. */
@@ -102,7 +116,7 @@ interface Props {
102
116
  };
103
117
  }
104
118
  const {
105
- lang, apexHost,
119
+ lang, apexHost, localeLinks = false,
106
120
  seed = [], order = [], taglines = {}, catLabels = {}, entries = {},
107
121
  locales, strings,
108
122
  } = Astro.props;
@@ -228,8 +242,8 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
228
242
  {/* taglines ride the same channel as newLabel and catMap: a data attribute on the grid. The
229
243
  runtime append builds its cards in plain strings and cannot reach this component's scope, so
230
244
  anything it needs has to arrive this way. Keyed by host, which is what the API calls `home`. */}
231
- <section class="grid" id="grid" data-apex={apexHost} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
232
- {seed.map((app) => <CatalogueCard app={app} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
245
+ <section class="grid" id="grid" data-apex={apexHost} data-locale-links={localeLinks ? "" : undefined} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
246
+ {seed.map((app) => <CatalogueCard app={app} href={localeLinks ? personaHref(hostOf(app.url), lang) : undefined} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
233
247
  </section>
234
248
  {/* queries: runtime cards are unstyled · the card has no CSS · an apex with no seed · scope cid ·
235
249
  data-astro-cid · pin.games cards render as blue links
@@ -264,8 +278,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
264
278
  // locale list has ONE home and is never typed here. A literal array once lived on this line and
265
279
  // drifted: it was edited by hand when Italian shipped, and any locale missing from it fell through
266
280
  // to "en", which the receiving persona then WROTE as the visitor's saved language choice.
267
- // Pass the visitor's language + a referral to each app link, so apps open in the same
268
- // language and show their "home → app.zone" affordance.
281
+ // queries: ?ref=app.zone&lang= indexed · parameterised persona url in search console · crawler sees
282
+ // ref and lang · Google indexed the hint, not the page · when does a card get ?ref
283
+ //
284
+ // THE VISITOR'S LANGUAGE AND A REFERRAL, HANDED OVER AT THE MOMENT OF THE TAP — never written into
285
+ // the page. This used to rewrite every card's href on load. A crawler renders the script too, so the
286
+ // links it followed were `https://<home>/?ref=<apex>&lang=<xx>`. That address is a page whose HTML is
287
+ // the default language, whose canonical is the root, and whose script goes elsewhere. Search Console
288
+ // then filed those addresses as pages of their own: 232 "Page with redirect" and 67 indexed on
289
+ // app.zone (2026-10-11, see ./personaHref.js).
290
+ //
291
+ // Now the href in the DOM stays whatever the card was rendered with: the persona's own page in this
292
+ // language when the apex opted in (`localeLinks`), its bare root otherwise. The hint is added only
293
+ // when a person actually opens a card: pointerdown, keyboard Enter or a context menu, delegated so
294
+ // runtime-appended cards are covered. A crawler does none of those. An apex on `localeLinks` needs no
295
+ // hint at all, because the address is already the language.
269
296
  (function () {
270
297
  function detectLang() {
271
298
  const saved = localStorage.getItem("lang");
@@ -277,15 +304,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
277
304
  }
278
305
  return "en";
279
306
  }
280
- const lang = detectLang();
281
- for (const a of document.querySelectorAll(".card")) {
307
+ const grid = document.getElementById("grid");
308
+ if (!grid || grid.dataset.localeLinks !== undefined) return;
309
+ function hint(e) {
310
+ const a = e.target && e.target.closest ? e.target.closest("a.card") : null;
311
+ if (!a || !grid.contains(a)) return;
282
312
  try {
283
313
  const u = new URL(a.href);
284
314
  u.searchParams.set("ref", apexHost);
285
- u.searchParams.set("lang", lang);
315
+ u.searchParams.set("lang", detectLang());
286
316
  a.href = u.toString();
287
- } catch (e) {}
317
+ } catch (err) {}
288
318
  }
319
+ // pointerdown precedes click, middle-click and cmd-click alike; keydown covers Enter on a focused
320
+ // card; contextmenu covers "open in new tab". Capture phase, so nothing below can skip it.
321
+ for (const type of ["pointerdown", "keydown", "contextmenu"]) document.addEventListener(type, hint, true);
289
322
  })();
290
323
 
291
324
  // queries: can a campaign link to just one app or one category · what is the ?apps= URL parameter ·
@@ -17,8 +17,10 @@ export interface CatalogueEntry {
17
17
  // words repeated down the page — "Fitness" four times — carrying almost no information per card, while
18
18
  // costing every card a block-level line. As a filter it earns its space instead, and it surfaces the
19
19
  // ?category= slice the catalog already supported but never showed. data-category stays: the filter reads it.
20
- const { app, name, desc, isNew, newLabel } = Astro.props as {
21
- app: App; name: string; desc: string; isNew: boolean; newLabel: string;
20
+ // `href` — where the card goes, when the apex has a better answer than the persona's bare root (its page in
21
+ // this page's language; see Catalogue's `localeLinks`). The icon still comes from `app.url`, the root.
22
+ const { app, name, desc, isNew, newLabel, href } = Astro.props as {
23
+ app: App; name: string; desc: string; isNew: boolean; newLabel: string; href?: string;
22
24
  };
23
25
  // The persona's canonical icon — every persona serves /icon.svg on its own host. One face everywhere.
24
26
  const icon = `${app.url}/icon.svg`;
@@ -26,7 +28,7 @@ const icon = `${app.url}/icon.svg`;
26
28
 
27
29
  <a
28
30
  class="card"
29
- href={app.url}
31
+ href={href ?? app.url}
30
32
  data-name={`${name} ${app.name}`.toLowerCase()}
31
33
  data-category={app.category}
32
34
  data-id={app.id.toLowerCase()}
package/src/Preroll.astro CHANGED
@@ -457,6 +457,22 @@ const primary = storeRows[0]?.url;
457
457
  * container, and layout containment makes it the containing block for FIXED descendants too — a
458
458
  * fixed overlay written inside it would be clipped to the frame it is meant to surround. */
459
459
  html[data-stage="playing"] { overflow: hidden; }
460
+ /* queries: oyun başlayınca toolbar gitsin · the toolbar hides while playing · full screen game ·
461
+ * tam ekran oyun · ipad oyun ekranı · the hud is under the toolbar
462
+ *
463
+ * ── WHILE A GAME PLAYS, THE TOOLBAR STEPS OUT AND THE GAME TAKES THE WHOLE SCREEN ──────────────
464
+ *
465
+ * The founder, 2026-10-22, on an iPad and a phone: the game's own money, day and full-screen keys
466
+ * were crowded by the bar above them. So while the stage is `playing` the bar's sticky slot leaves
467
+ * the flow and the game kernel grows by exactly what it gave up — the whole painted screen. The
468
+ * moment the game announces `paused` (its own pause key, the tab hidden, leaving full screen) the
469
+ * attribute goes and the bar comes back with the page as it was. The protocol is unchanged: the
470
+ * game says what the player is doing, and this is only what the shell does with it. */
471
+ html[data-stage="playing"] .tb-slot { display: none; }
472
+ /* What the shell takes above the kernel is nothing, while it plays: the one number the game
473
+ * kernel's height law and its landing offset both read (@justai/ui base.css). `!important` because
474
+ * the shell measures the bar and writes the number inline on the root — and the bar is gone. */
475
+ html[data-stage="playing"] { --shell-top: 0px !important; }
460
476
  /* queries: theater mode · where did the dim layer go · dark theme while playing
461
477
  *
462
478
  * ── THE THEATER IS THE PERSONA'S OWN DARK THEME, NOT A LAYER ────────────────────────────────
@@ -21,6 +21,8 @@
21
21
  // nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
22
22
  // the append, seeds and newcomers together, so ordering is a property of the merged list, not of
23
23
  // either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
24
+ import { personaHref } from "../personaHref.js";
25
+
24
26
  (function () {
25
27
  const grid = document.getElementById("grid");
26
28
  if (!grid) return;
@@ -40,6 +42,8 @@
40
42
  // household where they part — its citizens are sometimes games and sometimes apps — and asking it
41
43
  // for one kind returns half a family and calls that the storefront.
42
44
  const apexHost = grid.dataset.apex || "app.zone";
45
+ // Present (even empty) when the apex opted in to clean locale links — see Catalogue's `localeLinks`.
46
+ const localeLinks = grid.dataset.localeLinks !== undefined;
43
47
  // The category label must be localized like the SSR path (catLabel), but the API returns the
44
48
  // category in English regardless of ?locale=. So carry this page's cat dictionary through a data
45
49
  // attribute (same channel as newLabel) and map through it — the English key stays the stable
@@ -73,7 +77,7 @@
73
77
  const esc = (s: unknown): string =>
74
78
  String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
75
79
 
76
- type Entry = { handle: string; home: string; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
80
+ type Entry = { handle: string; home: string; locales?: string[] | null; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
77
81
  const cardHTML = (a: Entry): string => {
78
82
  const url = `https://${a.home}`;
79
83
  // the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
@@ -133,7 +137,16 @@
133
137
  el.setAttribute(scopeAttr, "");
134
138
  el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
135
139
  }
136
- try { const u = new URL(el.href); u.searchParams.set("ref", apexHost); u.searchParams.set("lang", locale); el.href = u.toString(); } catch {}
140
+ // queries: ref=app.zone · lang parameter on a card · clean locale link · data-locale-links
141
+ //
142
+ // WHERE THE CARD GOES. An apex that has opted in (`localeLinks` on Catalogue) sends the visitor
143
+ // straight to the persona's page in this page's language — the canonical address, no query
144
+ // string; see ../personaHref.js for what the parameters cost in Search Console. An apex that
145
+ // has not keeps the bare root it was built with, because only the apex can vouch that its
146
+ // citizens speak its languages, and a persona without `/<xx>/` answers 404 there. Its
147
+ // visitor's language still travels: Catalogue.astro adds the hint at the moment of the tap,
148
+ // never into the page a crawler renders.
149
+ if (localeLinks) el.href = personaHref(a.home, locale, { locales: a.locales });
137
150
  }
138
151
 
139
152
  // The filter panel is rendered from the build-time seed, so a persona that registered afterwards
@@ -180,8 +180,11 @@ if (root && root.dataset.door === "store") {
180
180
  const el = document.getElementById(id);
181
181
  if (el) el.toggleAttribute("inert", playing);
182
182
  }
183
- // The page is locked, so make sure what it is locked ON is the stage.
184
- if (playing) stage.scrollIntoView({ block: "nearest" });
183
+ // The page is locked, so make sure what it is locked ON is the stage — the whole of it, now
184
+ // that the toolbar has stepped out (see Preroll.astro). On pause the bar is back, and the
185
+ // kernel lands under it again by its own scroll-margin.
186
+ if (playing) (document.getElementById("kernel") ?? stage).scrollIntoView({ block: "start" });
187
+ else document.getElementById("kernel")?.scrollIntoView({ block: "start" });
185
188
  // queries: space does nothing · keyboard not reaching the game · input bind · focus
186
189
  //
187
190
  // AND THE KEYBOARD GOES WITH THE STAGE. The play press happened in THIS document, so this
@@ -245,6 +248,11 @@ if (root && root.dataset.door === "store") {
245
248
  document.addEventListener("justai:pause-request", () => askPause("chrome"));
246
249
  document.addEventListener("justai:fullscreen-request", () =>
247
250
  (stage as any).requestFullscreen?.().catch(() => {}));
251
+ // Leaving full screen is leaving the game for a moment: it pauses, and with the pause the
252
+ // toolbar comes back — the same way back as the game's own pause key.
253
+ document.addEventListener("fullscreenchange", () => {
254
+ if (!document.fullscreenElement && document.documentElement.dataset.stage === "playing") askPause("fullscreen");
255
+ });
248
256
  document.addEventListener("visibilitychange", () => {
249
257
  if (document.hidden && document.documentElement.dataset.stage === "playing") askPause("hidden");
250
258
  });
@@ -0,0 +1,6 @@
1
+ /** The address an apex links a persona by: the persona's own page in the reader's language, no query string. */
2
+ export function personaHref(
3
+ home: string,
4
+ lang: string,
5
+ opts?: { defaultLocale?: string; locales?: readonly string[] | null },
6
+ ): string;
@@ -0,0 +1,46 @@
1
+ // THE ADDRESS AN APEX HANDS A VISITOR WHEN IT SENDS THEM TO A PERSONA — one rule, one copy.
2
+ //
3
+ // queries: persona link · app.zone card href · ref=app.zone · lang parameter · locale link ·
4
+ // clean locale url · page with redirect in search console · parameterised url indexed ·
5
+ // how does a storefront link to a persona in the reader's language
6
+ //
7
+ // ── WHAT IT REPLACES, AND WHY ───────────────────────────────────────────────────────────────────
8
+ //
9
+ // A storefront used to link to `https://<home>/?ref=<apex>&lang=<xx>`. The persona's root then read
10
+ // `lang` as a hint and replaced the location with `/<xx>/?ref=…&lang=…` in the browser. For a person
11
+ // that is one extra hop. For a search engine it is a page whose HTML is the default language, whose
12
+ // canonical is the root, and whose script goes somewhere else — three statements that disagree.
13
+ //
14
+ // Measured in Search Console for app.zone on 2026-10-11: 232 of those addresses filed as "Page with
15
+ // redirect", 67 indexed as pages of their own, and for 49 of the 67 the real `/<xx>/` page was still
16
+ // waiting to be crawled. The parameterised address had taken the localized page's place.
17
+ //
18
+ // So the link is the page itself: `https://<home>/` for the default language, `https://<home>/<xx>/`
19
+ // for any other. No query string. `ref` had no reader — the arrival's own `document.referrer` already
20
+ // says which storefront it came from.
21
+ //
22
+ // ── THE ONE THING THIS CANNOT KNOW BY ITSELF ────────────────────────────────────────────────────
23
+ //
24
+ // Whether the persona HAS that language. A persona without `/<xx>/` answers 404 there, where the old
25
+ // hint was simply ignored. So:
26
+ // · `locales` given (the persona's own list) → the localized page only if it is in the list.
27
+ // · `locales` not given → the caller has vouched for it (see `localeLinks`
28
+ // on Catalogue: an apex opts in when its citizens
29
+ // all speak the apex's languages).
30
+ // A language code that is not a plain BCP-47 tag never reaches a path at all.
31
+ import { localePath } from "@justai/ui/locale";
32
+
33
+ /**
34
+ * @param {string} home the persona's host, e.g. "merge-pdf.app.zone" (a scheme or path is ignored)
35
+ * @param {string} lang the page's language
36
+ * @param {{ defaultLocale?: string, locales?: readonly string[] | null }} [opts]
37
+ * @returns {string} an absolute https URL with no query string and no fragment
38
+ */
39
+ export function personaHref(home, lang, opts) {
40
+ const def = (opts && opts.defaultLocale) || "en";
41
+ const host = String(home || "").trim().replace(/^[a-z]+:\/\//i, "").replace(/[/?#].*$/, "").toLowerCase();
42
+ const code = String(lang || "").trim().toLowerCase();
43
+ const known = opts && Array.isArray(opts.locales) ? opts.locales : null;
44
+ const speaks = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(code) && (!known || known.indexOf(code) !== -1);
45
+ return `https://${host}${localePath(speaks ? code : def, def)}`;
46
+ }