@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 +5 -1
- package/src/Catalogue.astro +42 -9
- package/src/CatalogueCard.astro +5 -3
- package/src/Preroll.astro +16 -0
- package/src/behaviour/catalogue.ts +15 -2
- package/src/behaviour/preroll.ts +10 -2
- package/src/personaHref.d.ts +6 -0
- package/src/personaHref.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "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": {
|
package/src/Catalogue.astro
CHANGED
|
@@ -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
|
-
//
|
|
268
|
-
//
|
|
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
|
|
281
|
-
|
|
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",
|
|
315
|
+
u.searchParams.set("lang", detectLang());
|
|
286
316
|
a.href = u.toString();
|
|
287
|
-
} catch (
|
|
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 ·
|
package/src/CatalogueCard.astro
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
-
|
|
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
|
package/src/behaviour/preroll.ts
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
}
|