@justai/cuts 0.98.0 → 0.100.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.98.0",
3
+ "version": "0.100.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": {
@@ -28,7 +28,10 @@
28
28
  "./ActionRow.astro": "./src/ActionRow.astro",
29
29
  "./Files.astro": "./src/FilePicker.astro",
30
30
  "./AuthMount.astro": "./src/AuthMount.astro",
31
- "./ChildPersona.astro": "./src/ChildPersona.astro"
31
+ "./ChildPersona.astro": "./src/ChildPersona.astro",
32
+ "./tagline.js": "./src/tagline.js",
33
+ "./Catalogue.astro": "./src/Catalogue.astro",
34
+ "./CatalogueCard.astro": "./src/CatalogueCard.astro"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "astro": ">=5"
@@ -0,0 +1,639 @@
1
+ ---
2
+ // THE CATALOGUE — an apex's storefront, and the last part of an apex that was not already shared.
3
+ //
4
+ // queries: app.zone grid · pin.games catalogue · the storefront · how does a new persona appear ·
5
+ // three-layer catalog · seed and runtime append · apex grid · Catalogue cut
6
+ //
7
+ // ── WHY THIS IS A CUT ────────────────────────────────────────────────────────────────────────────
8
+ //
9
+ // app.zone's own answer sheet called the grid "the one thing genuinely app.zone's own", and that was
10
+ // true while there was one apex. There are two now, and the founder's description of the difference
11
+ // is exact: the same experience, one listing app personas and the other game personas.
12
+ //
13
+ // Measured before moving it: 92% of app.zone's kernel is generic, and the difference between the two
14
+ // storefronts is about forty lines. Copying to change forty would have forked 966 — including a
15
+ // stylesheet, which would have become the largest single duplication in the fleet and the one most
16
+ // certain to drift.
17
+ //
18
+ // ── THE SEAM IS MACHINERY VERSUS WORDS ──────────────────────────────────────────────────────────
19
+ //
20
+ // Not "app.zone versus pin.games". The three layers, the card, the filter panel, the search, the deep
21
+ // links, the sort, the NEW badge are machinery and live here. The seed, the taglines, the curated
22
+ // order, the category labels are content and stay with the apex that owns them, arriving as props.
23
+ //
24
+ // ── THE THREE LAYERS ────────────────────────────────────────────────────────────────────────────
25
+ //
26
+ // 1. A BUILD-TIME SEED — hardcoded, and emphatically not the roster of record. It exists so a crawler
27
+ // with no JavaScript sees a real grid and so the build works offline. A persona does NOT have to
28
+ // be in it to appear. An apex with nothing seeded yet passes an empty list and is correct.
29
+ // 2. A BUILD-TIME DESCRIPTION OVERRIDE — each seeded line replaced by the persona's own bio in the
30
+ // reader's language, from the live registry, wrapped so failure is survivable: an outage at build
31
+ // costs freshness, not a deploy.
32
+ // 3. A RUNTIME APPEND — everything the registry knows that the seed does not. This is how a persona
33
+ // that registered an hour ago reaches the storefront with nobody redeploying.
34
+ //
35
+ // ── AND THE ONE LINE THAT DECIDES WHAT THE SITE IS ──────────────────────────────────────────────
36
+ //
37
+ // `kind`. It used to be `home.endsWith(".app.zone")` in a template, and again in llms.txt — a
38
+ // statement about what the ecosystem IS, living in one page's markup, twice. It is now a question
39
+ // asked of the registry, which has carried `personaKind` since long before anything read it.
40
+ import CatalogueCard, { type CatalogueEntry } from "./CatalogueCard.astro";
41
+
42
+ interface Props {
43
+ /** BCP-47 code of the page — the language the registry is asked for. */
44
+ lang: string;
45
+ /** Which citizens this apex lists. The whole difference between two storefronts. */
46
+ kind: string;
47
+ /** The apex's own host, used for the referral parameter on every card. */
48
+ apexHost: string;
49
+ /** Layer one. Empty is valid and correct for an apex with nothing seeded yet. */
50
+ seed?: CatalogueEntry[];
51
+ /** The curated sequence, keyed by host — read by the runtime sort. */
52
+ order?: Record<string, number>;
53
+ /** A shelf label per host: a card has one line to earn a tap, and a bio clamps. Keyed by host. */
54
+ taglines?: Record<string, string>;
55
+ /** Localized category names. The English key stays the stable filter value. */
56
+ catLabels?: Record<string, string>;
57
+ /** Per-id name and description for the seed, in the reader's language. */
58
+ entries?: Record<string, { name: string; desc: string }>;
59
+ /** Every locale this apex serves — the deep-link script strips a language prefix with it. */
60
+ locales: readonly string[];
61
+ strings: {
62
+ search: string;
63
+ filter: string;
64
+ eyebrow: string;
65
+ all: string;
66
+ empty: string;
67
+ new: string;
68
+ };
69
+ }
70
+ const {
71
+ lang, kind, apexHost,
72
+ seed = [], order = {}, taglines = {}, catLabels = {}, entries = {},
73
+ locales, strings,
74
+ } = Astro.props;
75
+
76
+ // localized category label; the category key (English) stays the stable filter value
77
+ const catLabel = (c: string): string => catLabels[c] ?? c;
78
+
79
+ // The filter panel's rows, rendered server-side so the shelves exist without JS and for SEO. Biggest
80
+ // category first, then alphabetical — a stable order that puts the fullest shelf at the front.
81
+ // Counts are category SIZES, not visible-result counts: they do not move when the search box narrows the
82
+ // grid, so a row always answers "how many apps are there of this kind".
83
+ // Derived from the build-time seed only. A persona that registers later can bring a category that is not
84
+ // here, so the runtime append adds any missing row itself (see the catalog script).
85
+ const catCounts: Record<string, number> = {};
86
+ for (const a of seed) catCounts[a.category] = (catCounts[a.category] ?? 0) + 1;
87
+ const catChips = Object.entries(catCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
88
+
89
+ // localized app-card content, keyed by the app's stable id; English is the fallback
90
+ type Loc = { name: string; desc: string };
91
+ const appName = (a: App): string => entries[a.id]?.name ?? a.name;
92
+
93
+ // queries: where do the app descriptions on app.zone come from · what happens to the build if
94
+ // api.justai.pro is down · which description wins, the API bio or the bundled copy ·
95
+ // how is the NEW badge decided · how long does an app show the NEW badge
96
+ //
97
+ // Build-time: single-source each app's description from its persona's bio, in the reader's language,
98
+ // via api.justai.pro (the catalog service app.zone declares). The bundled i18n is the fallback if the
99
+ // API is unreachable at build — the design never depends on the network.
100
+ let catalogByHome: Record<string, string> = {};
101
+ try {
102
+ const res = await fetch(`https://api.justai.pro/api/catalog?locale=${lang}&kind=${encodeURIComponent(kind)}`);
103
+ if (res.ok) {
104
+ const data = await res.json();
105
+ for (const p of data.personas ?? []) if (p.home && p.bio) catalogByHome[p.home] = p.bio;
106
+ }
107
+ } catch {
108
+ /* offline build → fall back to the bundled i18n; the page never depends on the network */
109
+ }
110
+ const hostOf = (url: string): string => {
111
+ try {
112
+ return new URL(url).host;
113
+ } catch {
114
+ return "";
115
+ }
116
+ };
117
+ // queries: why is the card's line shorter than the persona's bio · where does the card copy come from ·
118
+ // can I change what a card says without touching the persona
119
+ //
120
+ // THE CARD AND THE PROFILE SPEAK IN DIFFERENT VOICES, and that is the whole point of this line.
121
+ // A bio is the persona introducing itself, in the first person, at profile length: "önemsediğin her
122
+ // saati tek bakışta tutarım". A card is a shelf label in a storefront, and it should ask for the tap:
123
+ // "Dünyadaki saatleri tek bakışta takip et". Second person, imperative, one line.
124
+ //
125
+ // It is also what fixes the truncation. The card clamps at two lines and five of the nine visible
126
+ // cards were ending in an ellipsis — because a sentence written for a profile was being shown in a
127
+ // card. That is a wrong-field problem, not a long-copy problem.
128
+ //
129
+ // KEYED BY HOST, exactly as src/data/order.ts is keyed, and for exactly its reason: an app reaches
130
+ // this grid by two routes with two different identifiers. `stt` and `tts` registered at runtime and
131
+ // have no entry in the seed at all, so a seed-id key could never reach them.
132
+ //
133
+ // TEMPORARY HOME. These belong to the persona, not to app.zone — the three-layer catalogue promises
134
+ // that a persona can register and appear here with no change to this repo, and a tagline held here
135
+ // breaks that for every future arrival. They live here while the copy is still being written, because
136
+ // iterating a sentence through twelve repos and a registry sync is the wrong loop for editing prose.
137
+ // Once the wording settles they move into the persona contract beside bio and meta, and this map goes.
138
+ const tagline = (a: App): string | undefined =>
139
+ taglines?.[hostOf(a.url)];
140
+ const appDesc = (a: App): string =>
141
+ tagline(a) ?? catalogByHome[hostOf(a.url)] ?? entries[a.id]?.desc ?? a.description;
142
+ // A persona wears the NEW badge only for its first week — computed from when it joined (honest, not a
143
+ // hardcoded flag). The same rule runs at runtime for API-fetched cards (see the catalog script below).
144
+ const NEW_DAYS = 7;
145
+ const isNew = (added: string): boolean => { const t0 = Date.parse(added || ""); return Number.isFinite(t0) && Date.now() - t0 < NEW_DAYS * 864e5; };
146
+ ---
147
+
148
+ <div class="kbody">
149
+ <!-- The shelves used to be a chip row under the field. Six categories, and FOUR of them held a
150
+ single app — a filter whose options mostly resolve to one card is a shortcut wearing a
151
+ filter's clothes, and it cost a whole row of height to say so. Worse, the row overflowed
152
+ with `scrollbar-width: none`, so the shelves past the edge had no affordance at all: they
153
+ were there and nothing said so. Collapsed into the field's own end, the same options get a
154
+ vertical list where a long label ("Yardımcı Programlar") fits whole and the count sits
155
+ beside it, and the default view goes back to being the cards. -->
156
+ <div class="controls">
157
+ <div class="fwrap">
158
+ <div class="search">
159
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.3-4.3" /></svg>
160
+ <input id="search" type="search" placeholder={strings.search} autocomplete="off" aria-label={strings.search} />
161
+ <!-- aria-expanded is the button's own state and aria-controls names what it opens, so the
162
+ panel is reachable by a screen reader as this button's disclosure rather than as a
163
+ loose group that happens to follow it. -->
164
+ <button class="fbtn" id="fbtn" type="button" aria-label={strings.filter} title={strings.filter} aria-expanded="false" aria-controls="fpanel">
165
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M7 12h10M10 18h4" /></svg>
166
+ </button>
167
+ </div>
168
+
169
+ <!-- Toggle buttons, not links: the filter is client-side and instant. aria-pressed carries
170
+ which shelf is showing. `hidden` rather than display:none in CSS, so the closed panel is
171
+ out of the accessibility tree and out of the tab order without a second source of truth. -->
172
+ <div class="fpanel" id="fpanel" role="group" aria-label={strings.eyebrow} hidden>
173
+ <button class="opt active" type="button" data-cat="" aria-pressed="true">
174
+ <svg class="tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
175
+ <span class="lb">{strings.all}</span><span class="n">{seed.length}</span>
176
+ </button>
177
+ {catChips.map(([c, n]) => (
178
+ <button class="opt" type="button" data-cat={c} aria-pressed="false">
179
+ <svg class="tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
180
+ <span class="lb">{catLabel(c)}</span><span class="n">{n}</span>
181
+ </button>
182
+ ))}
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- data-order carries the curated sequence to the runtime sort, the same channel newLabel and
188
+ catMap already use. Passed as data rather than imported into the client bundle so the order the
189
+ page is serving is readable in the served HTML. -->
190
+ {/* taglines ride the same channel as newLabel and catMap: a data attribute on the grid. The
191
+ runtime append builds its cards in plain strings and cannot reach this component's scope, so
192
+ anything it needs has to arrive this way. Keyed by host, which is what the API calls `home`. */}
193
+ <section class="grid" id="grid" data-kind={kind} data-apex={apexHost} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
194
+ {seed.map((app) => <CatalogueCard app={app} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
195
+ </section>
196
+ <p class="empty" id="empty" hidden>{strings.empty}</p>
197
+ </div>
198
+
199
+ {/* `apexHost` rides define:vars for the same reason `supported` does: this block is emitted
200
+ VERBATIM and cannot see the component's props. It was the literal "app.zone" until the second apex
201
+ existed. */}
202
+ <script is:inline define:vars={{ supported: locales, apexHost }}>
203
+ // PLAIN BROWSER JS ONLY — no TypeScript in this block. `define:vars` forces the script `is:inline`,
204
+ // and Astro emits an inline script VERBATIM: it neither bundles nor type-strips it, unlike the
205
+ // hoisted `<script>` below (esbuild transpiles that one, so TS is fine there). A single `as
206
+ // HTMLInputElement` here reaches the browser as source and dies at parse with "Unexpected
207
+ // identifier 'as'" — which kills the WHOLE block, so the live search and the ?category=/?apps=
208
+ // deep links all silently stop working. See the corpus: an inline script is not TypeScript.
209
+ //
210
+ // `supported` is injected from src/i18n via define:vars — the shell's own technique — so the
211
+ // locale list has ONE home and is never typed here. A literal array once lived on this line and
212
+ // drifted: it was edited by hand when Italian shipped, and any locale missing from it fell through
213
+ // to "en", which the receiving persona then WROTE as the visitor's saved language choice.
214
+ // Pass the visitor's language + a referral to each app link, so apps open in the same
215
+ // language and show their "home → app.zone" affordance.
216
+ (function () {
217
+ function detectLang() {
218
+ const saved = localStorage.getItem("lang");
219
+ if (saved && supported.includes(saved)) return saved;
220
+ const langs = navigator.languages || [navigator.language || "en"];
221
+ for (const l of langs) {
222
+ const c = (l || "").toLowerCase().split("-")[0];
223
+ if (supported.includes(c)) return c;
224
+ }
225
+ return "en";
226
+ }
227
+ const lang = detectLang();
228
+ for (const a of document.querySelectorAll(".card")) {
229
+ try {
230
+ const u = new URL(a.href);
231
+ u.searchParams.set("ref", apexHost);
232
+ u.searchParams.set("lang", lang);
233
+ a.href = u.toString();
234
+ } catch (e) {}
235
+ }
236
+ })();
237
+
238
+ // queries: can a campaign link to just one app or one category · what is the ?apps= URL parameter ·
239
+ // ?category= ?apps= ?persona= deep link · land a campaign click on one slice of the catalog
240
+ //
241
+ // gallery search + category filter + URL-driven filter. The panel is the visible face of the
242
+ // ?category= slice the catalog always supported: choosing one is the same operation a campaign link
243
+ // performs, so a person can reach by hand exactly where an ad can land them, and the URL stays
244
+ // shareable either way. ?apps= stays URL-only — it is a campaign instrument, not a browse control.
245
+ const search = document.getElementById("search");
246
+ const empty = document.getElementById("empty");
247
+ const panel = document.getElementById("fpanel");
248
+ const fbtn = document.getElementById("fbtn");
249
+ const params = new URLSearchParams(location.search);
250
+ // MUTABLE: the panel writes to it. Everything downstream reads `activeCat`, never the URL again.
251
+ let activeCat = (params.get("category") || "").trim().toLowerCase();
252
+ const appsParam = (params.get("apps") || params.get("persona") || params.get("personas") || "").trim().toLowerCase();
253
+ const onlyApps = appsParam ? new Set(appsParam.split(",").map((s) => s.trim()).filter(Boolean)) : null;
254
+
255
+ // Paint the options to match `activeCat`. Comparison is lowercased because data-cat carries the
256
+ // canonical casing ("Fitness") while a hand-typed ?category=fitness will not.
257
+ function paintChips() {
258
+ if (!panel) return;
259
+ let matched = false;
260
+ for (const b of panel.querySelectorAll(".opt")) {
261
+ const on = (b.dataset.cat || "").toLowerCase() === activeCat;
262
+ if (on) matched = true;
263
+ b.classList.toggle("active", on);
264
+ b.setAttribute("aria-pressed", on ? "true" : "false");
265
+ }
266
+ // ?category=nosuchthing — nothing matched, so no row is ticked rather than the wrong one. The grid
267
+ // will be empty and say so, which is the honest reading of the URL.
268
+ if (!matched && activeCat === "") panel.querySelector('.opt[data-cat=""]')?.classList.add("active");
269
+ // With the row gone, this button is the only place a closed panel can say a filter is on.
270
+ if (fbtn) fbtn.classList.toggle("on", activeCat !== "");
271
+ }
272
+
273
+ // `hidden` is the single source of truth for open/closed — read it rather than tracking a second flag
274
+ // that can disagree with the DOM.
275
+ function setOpen(open) {
276
+ if (!panel || !fbtn) return;
277
+ panel.hidden = !open;
278
+ fbtn.setAttribute("aria-expanded", open ? "true" : "false");
279
+ }
280
+ const isOpen = () => !!panel && !panel.hidden;
281
+
282
+ // Keep the address bar honest without stacking history: a filter is a view of this page, not a
283
+ // destination, so replaceState — the same treatment the ?ref/?lang hints get.
284
+ function syncUrl() {
285
+ const u = new URL(location.href);
286
+ if (activeCat) u.searchParams.set("category", activeCat);
287
+ else u.searchParams.delete("category");
288
+ history.replaceState(null, "", u.pathname + (u.search || "") + u.hash);
289
+ }
290
+
291
+ if (panel && fbtn) {
292
+ fbtn.addEventListener("click", () => setOpen(!isOpen()));
293
+
294
+ panel.addEventListener("click", (e) => {
295
+ const b = e.target.closest(".opt");
296
+ if (!b) return;
297
+ const c = (b.dataset.cat || "").toLowerCase();
298
+ activeCat = c === activeCat ? "" : c; // choosing the ticked row clears it
299
+ paintChips();
300
+ syncUrl();
301
+ apply();
302
+ setOpen(false); // the choice is made and the grid behind it just changed — get out of the way
303
+ });
304
+
305
+ // Outside click closes. Bound on the document, so it also catches a click on the search input; the
306
+ // guard keeps the button's own click from closing what it just opened.
307
+ document.addEventListener("click", (e) => {
308
+ if (!isOpen()) return;
309
+ if (panel.contains(e.target) || fbtn.contains(e.target)) return;
310
+ setOpen(false);
311
+ });
312
+
313
+ // Escape closes and hands focus back, because a keyboard user who opened this has nowhere to go
314
+ // otherwise — the panel's rows are the only thing between them and the grid.
315
+ document.addEventListener("keydown", (e) => {
316
+ if (e.key !== "Escape" || !isOpen()) return;
317
+ setOpen(false);
318
+ fbtn.focus();
319
+ });
320
+ }
321
+
322
+ function apply() {
323
+ if (!empty) return;
324
+ const q = (search?.value ?? "").trim().toLowerCase();
325
+ let shown = 0;
326
+ // re-query each time so runtime-appended cards (new personas) obey the filter too
327
+ for (const card of document.querySelectorAll(".card")) {
328
+ const name = card.dataset.name ?? "";
329
+ const c = (card.dataset.category ?? "").toLowerCase();
330
+ const id = (card.dataset.id ?? "").toLowerCase();
331
+ const ok =
332
+ (!activeCat || c === activeCat) &&
333
+ (!onlyApps || onlyApps.has(id)) &&
334
+ (q === "" || name.includes(q));
335
+ card.style.display = ok ? "" : "none";
336
+ if (ok) shown++;
337
+ }
338
+ empty.hidden = shown !== 0;
339
+ }
340
+
341
+ if (search) search.addEventListener("input", apply);
342
+ // runtime catalog cards arrive after the build-time ones — re-apply, and repaint the panel because the
343
+ // append may have added one for a category the build-time seed never knew about
344
+ document.addEventListener("zone:cards", () => { paintChips(); apply(); });
345
+ paintChips();
346
+ apply();
347
+ </script>
348
+
349
+ <script>
350
+ // queries: how does a new app appear on app.zone · my app is not on app.zone · new app missing
351
+ // from app.zone · runtime append registry · app.zone card unstyled · data-astro-cid
352
+ // insertAdjacentHTML · app.zone grid not sorted · where does a runtime card's category
353
+ // come from · why is an app's icon fetched from its own host
354
+ //
355
+ // app.zone's catalog: SSR renders the curated apps (for SEO + design); at RUNTIME we fetch the live
356
+ // registry (api.justai.pro/api/catalog) and APPEND any persona registered since the build — a
357
+ // *.app.zone citizen not already on the page — so a new app appears with NO app.zone redeploy.
358
+ //
359
+ // Two traps live in here. (1) Nodes built with insertAdjacentHTML sit OUTSIDE Astro's scoped CSS,
360
+ // so the scope attribute has to be copied off a real card onto every injected node — forget it and
361
+ // nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
362
+ // the append, seeds and newcomers together, so ordering is a property of the merged list, not of
363
+ // either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
364
+ (function () {
365
+ const grid = document.getElementById("grid");
366
+ if (!grid) return;
367
+ const locale = document.documentElement.lang || "en";
368
+ const NEW_MS = 7 * 864e5; // a persona wears NEW only for its first week (honest — from its createdAt)
369
+ const newLabel = grid.dataset.newLabel || "New";
370
+ // queries: which personas appear on this apex · runtime append kind · why is my game not listed
371
+ //
372
+ // WHICH CITIZENS THIS APEX LISTS — where the hostname test used to be. A bundled script cannot
373
+ // reach this component's props, so the two facts it needs ride the same data-attribute channel as
374
+ // newLabel and catMap. And `kind` is asked of the registry rather than inferred from a suffix: a
375
+ // persona's apex is a property of the persona, not a shape of its address.
376
+ const apexKind = grid.dataset.kind || "app";
377
+ const apexHost = grid.dataset.apex || "app.zone";
378
+ // The category label must be localized like the SSR path (catLabel), but the API returns the
379
+ // category in English regardless of ?locale=. So carry this page's cat dictionary through a data
380
+ // attribute (same channel as newLabel) and map through it — the English key stays the stable
381
+ // filter value in data-category, only the visible label is translated.
382
+ let catMap: Record<string, string> = {};
383
+ try { catMap = JSON.parse(grid.dataset.catMap || "{}"); } catch (e) {}
384
+ const catLabel = (c: string): string => catMap[c] ?? c;
385
+ // The card's line, in the storefront's voice rather than the persona's — same map the SSR path
386
+ // reads, arriving through the same channel as the two above. Keyed by host, which the API calls
387
+ // `home`. Missing entry falls through to the bio, so a locale with no taglines yet, or a persona
388
+ // that registered after they were written, simply keeps what it had.
389
+ let tagMap: Record<string, string> = {};
390
+ try { tagMap = JSON.parse(grid.dataset.taglines || "{}"); } catch (e) {}
391
+
392
+ // hosts already on the page (don't duplicate the SSR cards)
393
+ const have = new Set(
394
+ Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card")).map((a) => {
395
+ try { return new URL(a.href).host; } catch { return ""; }
396
+ }),
397
+ );
398
+ // the appended cards must inherit AppCard's scoped styles → copy the cid attribute off a real card
399
+ const sample = grid.querySelector<HTMLElement>("a.card");
400
+ const scopeAttr = sample ? sample.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null : null;
401
+
402
+ const esc = (s: unknown): string =>
403
+ String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
404
+
405
+ 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 };
406
+ const cardHTML = (a: Entry): string => {
407
+ const url = `https://${a.home}`;
408
+ // the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
409
+ // NO inline style. It used to carry the whole .icon rule inline, on the grounds that a node inserted with
410
+ // insertAdjacentHTML sits outside Astro's scoped CSS — but the append copies the cid onto every injected
411
+ // node, including this img, so the class does apply. What the inline copy actually did was OVERRIDE it:
412
+ // when the icon grew from 60px to 72px in AppCard.astro, this stayed at 60 and won on specificity, so the
413
+ // three seeded cards rendered a fifth larger than the seven that arrive from the registry. Nobody saw it
414
+ // until a reorder put them next to each other. (72 did not last — the size is back to 60, see
415
+ // AppCard.astro — but the trap is the same at any value: these attributes and that rule move together.)
416
+ // It protected nothing either: if the cid copy ever failed, the whole card would be unstyled, not just
417
+ // this image. One rule, in AppCard.astro.
418
+ const icon = `<img class="icon" src="${esc(a.avatar || `${url}/icon.svg`)}" alt="" width="60" height="60" loading="lazy" decoding="async">`;
419
+ // NEW only for the first week from the persona's own createdAt — never the hardcoded a.badge / a.status.
420
+ const created = a.createdAt ? Date.parse(a.createdAt) : NaN;
421
+ const isNew = Number.isFinite(created) && created <= Date.now() && Date.now() - created < NEW_MS;
422
+ const badge = isNew ? `<span class="badge new">${esc(newLabel)}</span>` : "";
423
+ const name = a.name || a.handle;
424
+ const cat = a.category || "";
425
+ // Must stay in step with AppCard.astro — this is the same card, built twice. The category span is
426
+ // gone from both: it lives in the filter row above the grid now.
427
+ return `<a class="card" href="${esc(url)}" data-name="${esc(name.toLowerCase())}" data-category="${esc(cat)}" data-id="${esc((a.handle || "").toLowerCase())}" data-added="${esc(a.createdAt || "")}">${icon}<div class="meta"><div class="top"><h2>${esc(name)}</h2>${badge}</div><p>${esc(tagMap[a.home] ?? a.bio ?? "")}</p></div><svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></a>`;
428
+ };
429
+
430
+ fetch(`https://api.justai.pro/api/catalog?locale=${encodeURIComponent(locale)}&kind=${encodeURIComponent(apexKind)}`)
431
+ .then((r) => (r.ok ? r.json() : null))
432
+ .then((data) => {
433
+ if (!data || !data.personas) return;
434
+ const newcomers = (data.personas as Entry[]).filter(
435
+ // The registry already returned only this apex's kind, so all that is left to exclude is the
436
+ // apex itself — a household head does not stand on its own shelf.
437
+ //
438
+ // queries: auth appeared on app.zone · a persona from another apex is listed · why both
439
+ // kind and home · residency
440
+ //
441
+ // TWO QUESTIONS, AND THE OLD LINE ANSWERED BOTH AT ONCE. `home.endsWith(".app.zone")` read
442
+ // as "is it an app" and was also, silently, "does it live here" — so replacing it with
443
+ // `kind` alone put `auth.justai.pro` and `justai.pro` on app.zone's shelf, both being kind
444
+ // `app`. Caught by comparing against the live page before deploying: fifteen cards where
445
+ // there had been thirteen, and a category nobody had ever seen.
446
+ //
447
+ // Both are asked now. KIND is what a persona IS — the registry's own property, and the
448
+ // reason a second apex can exist. RESIDENCY is where it LIVES, which is still its address.
449
+ (p) => p.home
450
+ && p.home.endsWith("." + apexHost)
451
+ && p.home !== apexHost && p.handle !== apexHost
452
+ && !have.has(p.home),
453
+ );
454
+ for (const a of newcomers) {
455
+ grid.insertAdjacentHTML("beforeend", cardHTML(a));
456
+ const el = grid.lastElementChild as HTMLAnchorElement | null;
457
+ if (!el) continue;
458
+ if (scopeAttr) {
459
+ el.setAttribute(scopeAttr, "");
460
+ el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
461
+ }
462
+ try { const u = new URL(el.href); u.searchParams.set("ref", apexHost); u.searchParams.set("lang", locale); el.href = u.toString(); } catch {}
463
+ }
464
+
465
+ // The filter panel is rendered from the build-time seed, so a persona that registered afterwards
466
+ // can arrive carrying a category no row exists for — and it would then be unreachable by filter
467
+ // and invisible in the counts. Reconcile the panel against what is actually in the grid: every
468
+ // category present gets a row, and every count is recomputed rather than incremented, so this is
469
+ // correct however many times it runs.
470
+ const fpanel = document.getElementById("fpanel");
471
+ if (fpanel) {
472
+ // CLONE a row the server rendered instead of building HTML. Astro scopes per COMPONENT, so a
473
+ // hand-built node needs Zone.astro's own data-astro-cid-* copied onto it AND every child —
474
+ // forget it and nothing errors, the row just renders naked. A clone carries the cid, the tick
475
+ // and the inner spans with it, so that whole class of bug is gone rather than commented.
476
+ const proto = fpanel.querySelector<HTMLElement>('.opt[data-cat=""]');
477
+ const counts = new Map<string, number>();
478
+ for (const c of grid.querySelectorAll<HTMLAnchorElement>("a.card")) {
479
+ const k = c.dataset.category || "";
480
+ if (k) counts.set(k, (counts.get(k) || 0) + 1);
481
+ }
482
+ const allN = fpanel.querySelector<HTMLElement>('.opt[data-cat=""] .n');
483
+ if (allN) allN.textContent = String(grid.querySelectorAll("a.card").length);
484
+ for (const [cat, n] of counts) {
485
+ let opt = fpanel.querySelector<HTMLElement>(`.opt[data-cat="${CSS.escape(cat)}"]`);
486
+ if (!opt && proto) {
487
+ opt = proto.cloneNode(true) as HTMLElement;
488
+ // the prototype is the ticked "All" row — a clone must arrive unticked
489
+ opt.classList.remove("active");
490
+ opt.setAttribute("aria-pressed", "false");
491
+ opt.dataset.cat = cat;
492
+ const lb = opt.querySelector<HTMLElement>(".lb");
493
+ if (lb) lb.textContent = catLabel(cat);
494
+ fpanel.appendChild(opt);
495
+ }
496
+ if (!opt) continue;
497
+ const num = opt.querySelector<HTMLElement>(".n");
498
+ if (num) num.textContent = String(n);
499
+ }
500
+
501
+ // Re-sort now, because the SSR order was decided from a seed that holds a fraction of the
502
+ // catalog — most personas arrive here, at runtime. "All" is pinned first; the rest go
503
+ // biggest-first, then alphabetical, so the fullest shelf is the first one read.
504
+ const ordered = Array.from(fpanel.querySelectorAll<HTMLElement>(".opt")).sort((x, y) => {
505
+ const cx = x.dataset.cat || "", cy = y.dataset.cat || "";
506
+ if (!cx) return -1;
507
+ if (!cy) return 1;
508
+ const nx = counts.get(cx) || 0, ny = counts.get(cy) || 0;
509
+ return ny - nx || cx.localeCompare(cy);
510
+ });
511
+ for (const c of ordered) fpanel.appendChild(c);
512
+ }
513
+ // One order for the whole grid, and it is CURATED now rather than chronological: src/data/order.ts
514
+ // lists hosts top to bottom, and that list is the grid. Anything NOT on the list goes ABOVE it, so
515
+ // a persona that registers with the platform announces itself at the top with no change to this
516
+ // repo — the list curates what is known, it does not gate what arrives. Several unlisted arrivals
517
+ // sort newest-first among themselves, which is the old rule, still doing the one job it is good at.
518
+ //
519
+ // Keyed by host on purpose. The same app reaches this grid by two routes with two different
520
+ // identifiers — Password Generator is id "password" in the seed and handle "password-generator" in
521
+ // the registry — and an identifier that only matches one route fails silently on the other. A card
522
+ // that fails to match reads as unlisted and jumps to the top, which is the worst possible failure
523
+ // for an ordering bug: it looks deliberate. The host is what both routes agree on.
524
+ const orderList: string[] = (() => {
525
+ try { return JSON.parse(grid.dataset.order || "[]"); } catch { return []; }
526
+ })();
527
+ const rank = new Map(orderList.map((h, i) => [h, i]));
528
+ const UNLISTED = -1;
529
+ Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card"))
530
+ .map((el) => {
531
+ let host = "";
532
+ try { host = new URL(el.href).host; } catch {}
533
+ return { el, r: rank.has(host) ? (rank.get(host) as number) : UNLISTED, added: el.getAttribute("data-added") || "" };
534
+ })
535
+ .sort((x, y) => {
536
+ if (x.r !== y.r) return x.r - y.r; // listed follow the list; unlisted (-1) sort above all
537
+ if (x.r !== UNLISTED) return 0; // two listed cards cannot share a rank
538
+ if (x.added === y.added) return 0; // both unlisted → newest first, undated last
539
+ if (!x.added) return 1;
540
+ if (!y.added) return -1;
541
+ return x.added < y.added ? 1 : -1;
542
+ })
543
+ .forEach(({ el }) => grid.appendChild(el));
544
+ document.dispatchEvent(new Event("zone:cards")); // newcomers in — let the URL filter catch them
545
+ })
546
+ .catch(() => {});
547
+ })();
548
+ </script>
549
+
550
+ <style>
551
+ /* The three gaps down this column are one rhythm: 26 to the search, 18 from it to the grid, 14
552
+ between cards. Each step is about 1.4x the next, so they read as one thing narrowing — section,
553
+ then control-to-list, then item-to-item.
554
+ THE TOP ONE USED TO BE `clamp(20px, 5vh, 44px)`, which is not a chosen number: it is whatever the
555
+ viewport gives. Measured, the same page had 37px of it at 700px tall and 46px at 900px, against a
556
+ fixed 18px below — so the search sat 1.8x from the bar on a laptop and 2.2x on a desktop, and at
557
+ that distance it stops reading as the head of the grid and starts looking as though it drifted
558
+ down toward it. A gap that changes with the window is a rhythm nobody designed. */
559
+ .kbody { flex: 1 0 auto; display: flex; flex-direction: column; padding-block: 26px 16px; }
560
+
561
+ .controls { margin-block-end: 18px; }
562
+
563
+ /* The field carries the CARD's material, and the reason is a measurement rather than a preference.
564
+ On this page "raised" means whiter plus a shadow: the cards are `--card-bg` (#fff in light) on an
565
+ off-white ground. The field was `--surface-2` — 4% BLACK over that same ground — with no shadow, so
566
+ it was not lifting, it was recessed, and a recessed input in light reads as disabled. Giving it the
567
+ card recipe means the page has ONE raised surface instead of two treatments arguing.
568
+ Dark loses nothing: there `--card-bg` and `--surface` are the same value, so the fill stays the 6%
569
+ white overlay that was already lifting correctly, and only the shadow is added. That asymmetry is
570
+ why the complaint could only come from the light side. */
571
+ .search {
572
+ display: flex; align-items: center; gap: 10px;
573
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-md);
574
+ box-shadow: var(--card-shadow);
575
+ /* the end padding is the button's, not the text's — 16px would park the icon away from the edge it
576
+ belongs to, and the button brings its own 30px of hit area */
577
+ padding: 12px 8px 12px 16px;
578
+ transition: border-color 0.2s var(--ease-out), box-shadow 0.25s var(--ease-out);
579
+ }
580
+ /* focus lives on the whole box as a soft OUTER breeze — not a thick inner ring. The card shadow is
581
+ re-stated after the ring rather than replaced by it: `box-shadow` is one property, so naming only
582
+ the ring here would drop the lift the moment somebody clicked into the field. */
583
+ .search:focus-within {
584
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
585
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent), var(--card-shadow);
586
+ }
587
+ .search svg { width: 18px; height: 18px; color: var(--text-dim); flex: none; }
588
+ .search input { flex: 1; min-width: 0; border: none; background: none; color: var(--text); font: inherit; font-size: var(--fs-base); outline: none; }
589
+ .search input:focus-visible { box-shadow: none; } /* the box carries the breeze; the input has no ring of its own */
590
+
591
+ /* ---- the filter: a button at the field's own end, and the shelves in a sheet under it ---- */
592
+ .fwrap { position: relative; } /* the panel hangs off this, not off .search, so focus styles stay put */
593
+
594
+ /* A 30px hit area inside a box whose height is set by the input's line box: the negative block margin
595
+ pulls its outer height back under that line, so adding this button does NOT make the field taller.
596
+ Measured before and after — the field is the same height it was with the chip row. */
597
+ .fbtn {
598
+ flex: none; display: grid; place-items: center; width: 30px; height: 30px; margin-block: -6px;
599
+ border: none; background: none; padding: 0; border-radius: var(--radius-sm); cursor: pointer;
600
+ color: var(--text-dim);
601
+ transition: color 0.25s var(--ease-out), background 0.25s var(--ease-out);
602
+ }
603
+ /* `.search svg` sets colour on the ELEMENT, and a direct declaration beats an inherited one — so
604
+ without this the button's own colour (and the accent below) would never reach its icon. Equal
605
+ specificity, later in the sheet, so this wins. */
606
+ .fbtn svg { width: 18px; height: 18px; color: inherit; }
607
+ .fbtn:hover, .fbtn:focus-visible { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
608
+ /* the row that used to announce the active shelf is gone, so the button carries it */
609
+ .fbtn.on { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
610
+ .fpanel {
611
+ position: absolute; inset-inline: 0; top: calc(100% + 8px); z-index: 5;
612
+ display: flex; flex-direction: column; gap: 2px; padding: 6px;
613
+ /* the column's own material — a translucent ground would let the cards read through the labels */
614
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md);
615
+ box-shadow: 0 18px 40px -20px rgba(0, 0, 0, 0.45);
616
+ backdrop-filter: blur(30px) saturate(1.4);
617
+ -webkit-backdrop-filter: blur(30px) saturate(1.4);
618
+ }
619
+ .opt {
620
+ display: flex; align-items: center; gap: 9px; padding: 9px 10px;
621
+ border: none; background: none; border-radius: var(--radius-sm); cursor: pointer;
622
+ color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: start;
623
+ transition: background 0.2s var(--ease-out);
624
+ }
625
+ .opt:hover, .opt:focus-visible { background: color-mix(in srgb, var(--text) 8%, transparent); }
626
+ .opt .lb { flex: 1; min-width: 0; }
627
+ /* the count is the shelf's size, so a person knows what is behind a word before spending a click */
628
+ .opt .n { flex: none; font-variant-numeric: tabular-nums; font-size: var(--fs-2xs); color: var(--text-dim); }
629
+ /* hidden, not absent: the tick keeps its column either way, so the labels do not jump sideways when
630
+ the selection moves from one row to another */
631
+ .opt .tick { flex: none; width: 15px; height: 15px; color: var(--accent); visibility: hidden; }
632
+ .opt.active .tick { visibility: visible; }
633
+ .opt.active .lb { font-weight: 600; }
634
+
635
+ .grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
636
+ /* no entrance animation — when you walk into the room, everyone is already in their place.
637
+ the cards are simply there on load; the only motion is a whisper on hover. */
638
+ .empty { text-align: center; color: var(--text-dim); padding: 40px 0; }
639
+ </style>
@@ -0,0 +1,120 @@
1
+ ---
2
+ // queries: catalogue card · app.zone card · pin.games card · the storefront card
3
+ //
4
+ // One card, for any apex's storefront. It was `AppCard` in app.zone and moved here whole when
5
+ // pin.games arrived: a card is machinery, and the words inside it are the apex's.
6
+ export interface CatalogueEntry {
7
+ /** stable key — the apex's own i18n is keyed by it, and it is never localized */
8
+ id: string;
9
+ name: string;
10
+ description: string;
11
+ url: string;
12
+ category: string;
13
+ /** ISO date the persona joined. The NEW badge is computed from it, never stored. */
14
+ added: string;
15
+ }
16
+ // No category on the card any more: it moved to a filter row above the grid. It was the same handful of
17
+ // words repeated down the page — "Fitness" four times — carrying almost no information per card, while
18
+ // costing every card a block-level line. As a filter it earns its space instead, and it surfaces the
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;
22
+ };
23
+ // The persona's canonical icon — every persona serves /icon.svg on its own host. One face everywhere.
24
+ const icon = `${app.url}/icon.svg`;
25
+ ---
26
+
27
+ <a
28
+ class="card"
29
+ href={app.url}
30
+ data-name={`${name} ${app.name}`.toLowerCase()}
31
+ data-category={app.category}
32
+ data-id={app.id.toLowerCase()}
33
+ data-added={app.added}
34
+ >
35
+ <img class="icon" src={icon} alt="" width="60" height="60" loading="lazy" decoding="async" />
36
+ <div class="meta">
37
+ <div class="top">
38
+ <h2>{name}</h2>
39
+ {isNew && <span class="badge new">{newLabel}</span>}
40
+ </div>
41
+ <p>{desc}</p>
42
+ </div>
43
+ <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
44
+ <path d="M9 18l6-6-6-6" />
45
+ </svg>
46
+ </a>
47
+
48
+ <style>
49
+ .card {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 16px;
53
+ /* 16, down from 18, to pay for the larger icon: the icon is now what sets the card's height, and the
54
+ two px back on each side keep the row pitch where the shorter card put it. */
55
+ padding: 16px;
56
+ /* The card's own material, not the page column's — see --card-* in tokens.css. In light mode this is
57
+ solid white with a real shadow; in dark it is the glass, which there actually refracts. */
58
+ background: var(--card-bg);
59
+ border: 1px solid var(--border);
60
+ border-radius: var(--radius-lg);
61
+ backdrop-filter: var(--card-blur);
62
+ -webkit-backdrop-filter: var(--card-blur);
63
+ box-shadow: var(--card-shadow);
64
+ text-decoration: none;
65
+ color: var(--text);
66
+ transition: transform 0.3s var(--ease-spring), border-color 0.3s, background 0.3s,
67
+ box-shadow 0.3s var(--ease-out);
68
+ will-change: transform;
69
+ }
70
+ /* the lift and the shadow move together — a card that rises without its shadow growing reads as a
71
+ sticker sliding, not an object leaving the page */
72
+ .card:hover {
73
+ transform: translateY(-2px);
74
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
75
+ box-shadow: var(--card-shadow-hover);
76
+ }
77
+ .card:active { transform: translateY(-1px) scale(0.99); }
78
+
79
+ /* the persona's canonical icon — a self-contained squircle, one face everywhere */
80
+ .icon {
81
+ flex: none;
82
+ /* 60. It was 72 for a day, on the reasoning that the icons are the only colour on this page — everything
83
+ else is ink on paper — so more of them should read as livelier. It read as too big instead, and that
84
+ turns out to be measurable rather than a matter of taste: the text beside the icon is a fixed 66px (a
85
+ 16px title on a 1.5 line box, 3px, then two clamped bio lines at 13.44/1.45), so at 72 the icon
86
+ overshot the text by six pixels and the icon, not the content, was setting the card's height.
87
+ 64 is the first size that fits inside the text block. 60 gives the same card height as 64 — past the
88
+ crossover the text sets it either way — so the last four pixels cost no vertical space and buy a
89
+ quieter mark. */
90
+ width: 60px;
91
+ height: 60px;
92
+ border-radius: var(--radius-md);
93
+ object-fit: cover;
94
+ box-shadow: 0 8px 20px -12px rgba(0, 0, 0, 0.35);
95
+ }
96
+
97
+ .meta { flex: 1; min-width: 0; }
98
+ .top { display: flex; align-items: center; gap: 8px; }
99
+ h2 { font-size: var(--fs-base); font-weight: 600; letter-spacing: -0.01em; }
100
+ /* Clamped to two lines. The bios run from one line to four, which made every card a different height
101
+ and the column read ragged; the full text is on the app's own page anyway. */
102
+ p {
103
+ color: var(--text-dim); font-size: var(--fs-xs); line-height: 1.45; margin: 3px 0 0;
104
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
105
+ }
106
+ .badge {
107
+ font-size: 0.66rem;
108
+ font-weight: 700;
109
+ text-transform: uppercase;
110
+ letter-spacing: 0.04em;
111
+ padding: 2px 7px;
112
+ border-radius: var(--radius-pill);
113
+ }
114
+ /* Only NEW is shown now (for a persona's first week). LIVE/beta/soon badges were dropped — every card
115
+ in the catalog is live by definition, so a "live" badge said nothing. */
116
+ .badge.new { color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
117
+
118
+ .chev { flex: none; width: 20px; height: 20px; color: var(--text-dim); transition: transform 0.3s var(--ease-out); }
119
+ .card:hover .chev { transform: translateX(3px); color: var(--accent); }
120
+ </style>
package/src/Posts.astro CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { tagline } from "./tagline.js";
2
3
  // ░░ POSTS cut — a persona's feed, revealed by scrolling past the Kernel. ░░
3
4
  //
4
5
  // The feed's markup, its styling, AND its runtime loader were byte-near-identical across every persona
@@ -50,7 +51,14 @@
50
51
  interface Props {
51
52
  handle: string; // the persona KEY — the feed query (?persona=) and the "is this reply mine?" test
52
53
  name: string; // the persona's display name; rendered RAW, so a name may carry markup (justai's ".pro")
53
- footer: string; // the localized footer tagline after "justai.pro ·"
54
+ // queries: footer prop · made to matter · the tagline is not in my locale files
55
+ //
56
+ // THE HOUSE LINE IS THE HOUSE'S, and it moved here on 2026-08-04. It was thirteen strings in every
57
+ // persona's locale files — ~195 copies of one sentence nobody would want to differ, and it had
58
+ // already drifted in one of them without anything being able to notice. Pass `lang` and this cut
59
+ // says it; pass `footer` only to say something else on purpose. See `tagline.js`.
60
+ lang?: string; // BCP-47 code of the page; picks the house tagline
61
+ footer?: string; // an override — the persona saying something other than the house line
54
62
  avatarBg?: string; // post-avatar tile background (default #fff)
55
63
  avatarBgLight?: string; // light-scheme override for that tile (a dark gradient must invert, not stay dark)
56
64
  // Which file is THIS persona's face in the feed. Defaults to /icon.svg, which every persona serves at its
@@ -73,7 +81,11 @@ interface Props {
73
81
  // the ground. The page's own media query is reliable, so the page picks the file and the file stays dumb.
74
82
  avatarSrcDark?: string;
75
83
  }
76
- const { handle, name, footer, avatarBg, avatarBgLight, avatarSrc, avatarBare, avatarSrcDark } = Astro.props;
84
+ const { handle, name, lang, footer, avatarBg, avatarBgLight, avatarSrc, avatarBare, avatarSrcDark } = Astro.props;
85
+ // The override wins where it is given; otherwise the family's own line, in this page's language.
86
+ // `Astro.currentLocale` is the fallback for a caller that passes neither, so an older persona that
87
+ // has not been bumped yet still closes its feed in the right language rather than in English.
88
+ const foot = footer ?? tagline(lang ?? Astro.currentLocale ?? "en");
77
89
  const vars = [
78
90
  avatarBg && `--feed-av-bg:${avatarBg}`,
79
91
  avatarBgLight && `--feed-av-bg-light:${avatarBgLight}`,
@@ -93,7 +105,7 @@ const vars = [
93
105
  <!-- filled at runtime by the loader below (lazy, on Posts-cut intersection) — single source: the console -->
94
106
  <div id="feed"></div>
95
107
  <footer class="z-foot">
96
- <a href="https://justai.pro">justai.pro</a> · {footer}
108
+ <a href="https://justai.pro">justai.pro</a> · {foot}
97
109
  {/* the change-your-mind door: hidden until the shell's consent script labels + reveals it
98
110
  (Base owns the behavior and the localized label; this cut only owns the placement). On a
99
111
  page with no analytics — and so no consent banner — it simply never appears. */}
package/src/ground.css CHANGED
@@ -60,6 +60,18 @@
60
60
  scheme-aware: Apple lightens red on dark backgrounds (#FF453A) and deepens it on light
61
61
  (#FF3B30), and a single value would read wrong on one of them. */
62
62
  --danger: #ff453a;
63
+
64
+ /* THE CARD — a storefront tile's own surface, and the reason it is here rather than in the apex
65
+ that used to own it. The Catalogue moved into this package the day pin.games became real, and
66
+ it arrived consuming four tokens that had stayed behind in app.zone's stylesheet. Nothing broke
67
+ on app.zone, which still grounds them; the harness caught it anyway, and the founder put the
68
+ stake in the ground in one line: *why would pin.games have shadowless cards — whatever I see on
69
+ app.zone, that is it.* These are app.zone's own values, read rather than redesigned, so the
70
+ second storefront is the first one. */
71
+ --card-bg: rgba(28, 28, 34, 0.55);
72
+ --card-blur: blur(30px) saturate(1.6);
73
+ --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.32);
74
+ --card-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 28px -14px rgba(0, 0, 0, 0.55);
63
75
  }
64
76
 
65
77
  @media (prefers-color-scheme: light) {
@@ -71,6 +83,13 @@
71
83
  --surface: rgba(255, 255, 255, 0.7);
72
84
  --surface-2: rgba(0, 0, 0, 0.05);
73
85
  --danger: #ff3b30;
86
+
87
+ /* Light is not the dark card with a different fill: the blur goes away entirely. A frosted
88
+ panel over a pale page reads as a smudge, so app.zone drops it and pays the shadow instead. */
89
+ --card-bg: #fff;
90
+ --card-blur: none;
91
+ --card-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 4px 12px -4px rgba(16, 24, 40, 0.1);
92
+ --card-shadow-hover: 0 2px 4px rgba(16, 24, 40, 0.06), 0 12px 26px -8px rgba(16, 24, 40, 0.15);
74
93
  }
75
94
  }
76
95
 
package/src/tagline.js ADDED
@@ -0,0 +1,58 @@
1
+ // THE HOUSE LINE — one sentence, thirteen languages, one copy.
2
+ //
3
+ // queries: made to matter · footer tagline · the line under justai.pro · footer string · why is the
4
+ // tagline not in my locale files · anlam katmak için · tagline translations · footer prop
5
+ //
6
+ // ── 195 COPIES OF ONE SENTENCE ──────────────────────────────────────────────────────────────────
7
+ //
8
+ // "made to matter" closes every persona's feed, and until 2026-08-04 it lived in each persona's own
9
+ // thirteen locale files: thirteen strings × fifteen personas, ~195 copies of a line nobody would ever
10
+ // want to differ. It had already drifted — one persona's footer read "Speech to Text · app.zone",
11
+ // which is not the house line at all, and nothing anywhere could have noticed.
12
+ //
13
+ // The founder named it while a fourteenth copy was being written: *"neden su footer de justai web
14
+ // framework componenti olarak girmez ki? su tekrar eden code lardan kurtulmamiz lazim."* A line the
15
+ // whole family says together belongs to the family.
16
+ //
17
+ // ── WHY A TABLE HERE RATHER THAN A PROP FROM THE PERSONA ────────────────────────────────────────
18
+ //
19
+ // The rule this repo already keeps: a component brings its own ground. A cut that needs a sentence
20
+ // and asks its host for it has pushed thirteen translations into every host, which is the same
21
+ // duplication wearing an interface. So the sentence lives with the component that renders it, and the
22
+ // persona hands over only the one thing it alone knows — which language this page is in.
23
+ //
24
+ // A persona may still override it. `stt` is why: whether its divergence is deliberate or an old
25
+ // accident, taking the escape hatch away would decide that question by deleting it.
26
+
27
+ /** The tagline, by BCP-47 code. `en` is the fallback for anything unlisted. */
28
+ export const TAGLINE = {
29
+ en: "made to matter",
30
+ tr: "anlam katmak için",
31
+ de: "made to matter",
32
+ es: "hecha para importar",
33
+ fr: "créé pour compter",
34
+ it: "fatto per contare",
35
+ nl: "gemaakt om ertoe te doen",
36
+ pt: "feita para importar",
37
+ ru: "создан, чтобы быть нужным",
38
+ ja: "意味あるものへ",
39
+ zh: "为打动人心而生",
40
+ ar: "صُنعت لتترك أثرًا",
41
+ hi: "जो मायने रखने के लिए बना है",
42
+ };
43
+
44
+ /**
45
+ * The line for a language, however it arrives.
46
+ *
47
+ * `de` genuinely answers "made to matter" — that is the translation the fleet shipped, not a hole in
48
+ * this table, and it is written out rather than left to fall through so nobody "fixes" it by adding a
49
+ * German line that no persona ever displayed.
50
+ *
51
+ * A region tag is taken off before the lookup (`pt-BR` → `pt`): a visitor whose browser says
52
+ * `en-GB` should not drop to the fallback that happens to be the same string, because the day the
53
+ * fallback changes that coincidence stops holding.
54
+ */
55
+ export function tagline(lang) {
56
+ const key = String(lang || "en").toLowerCase().split("-")[0];
57
+ return TAGLINE[key] ?? TAGLINE.en;
58
+ }