@justai/cuts 0.133.0 → 0.139.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.133.0",
3
+ "version": "0.139.0",
4
4
  "description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -41,8 +41,11 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@justai/core": "^0.35.0",
45
- "@justai/ui": "^0.22.0",
44
+ "@justai/core": "^0.36.0",
45
+ "@justai/ui": "^0.26.0",
46
46
  "@justai/atrium": "^0.1.9"
47
+ },
48
+ "scripts": {
49
+ "check": "bunx tsc -p tsconfig.json"
47
50
  }
48
51
  }
package/src/Base.astro CHANGED
@@ -33,7 +33,7 @@ import { dir as dirOf } from "@justai/ui/locale";
33
33
  import "./ground.css";
34
34
  // The load-bearing behaviour is framework-free and lives in @justai/core, so `feed` (Next) injects the
35
35
  // SAME strings instead of hand-copying them. This binding just decides where they go in an Astro head.
36
- import { AUTH_CAPTURE_JS, CONSENT_JS, JS_CLASS_JS, REVEAL_CSS, XRAY_JS, analyticsJs, consentCopy } from "@justai/core";
36
+ import { AUTH_CAPTURE_JS, CONSENT_JS, JS_CLASS_JS, REVEAL_CSS, analyticsJs, consentCopy } from "@justai/core";
37
37
  // The consent ask IS a confirmation — a moment of trust — so it rides the fleet's ONE dialog
38
38
  // rather than keeping a private dialect. One anatomy for "are you sure?" and "may I count you?".
39
39
  import ConfirmSheet from "./ConfirmSheet.astro";
@@ -180,8 +180,26 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
180
180
  repo is one nobody has when they need it.
181
181
  It is glasses, not a renovation: nothing is added to the page's own elements, the overlay is
182
182
  fixed and reads `#kernel`'s rect, and `pointer-events` is none throughout. And it costs
183
- nothing when off — the script returns on its second line unless the flag is there. */}
184
- <script is:inline set:html={XRAY_JS} />
183
+ nothing when off — MEASURED, and it did not. */}
184
+ {/* queries: xray page weight · why is the x-ray a dynamic import · debug tool on every page ·
185
+ 5381 bytes of xray · lazy load a debug overlay
186
+
187
+ IT COST 5,381 BYTES ON EVERY PAGE OF EVERY PERSONA, TO EVERY VISITOR — 10 to 16% of the
188
+ document, measured on four live hosts. "It costs nothing when off" was true of the RUNTIME
189
+ (one `if` and a return) and false of the TRANSFER, and only one of those is what a visitor
190
+ pays. The whole overlay was inlined into the head so that a branch could decline to run it.
191
+
192
+ So the flag check stays inline and the instrument is fetched only by whoever asked for it.
193
+ The person debugging pays one request; everybody else pays the bytes below. Astro splits the
194
+ dynamic import into its own chunk, so nothing about it reaches a page that does not ask.
195
+
196
+ It is a real module now, not a string — see @justai/core/xray. */}
197
+ <script>
198
+ if (new URLSearchParams(location.search).has("xray")) {
199
+ const { xray } = await import("@justai/core/xray");
200
+ xray();
201
+ }
202
+ </script>
185
203
  {/* Reveal only after a page marks itself ready — gated behind .js so the page is always visible
186
204
  if JS is off, and no scroll flash before we position at the Kernel. This style is a TRAP as
187
205
  much as a feature, and the warning must stay beside it: ANY page using this layout without a
@@ -383,238 +383,10 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
383
383
  apply();
384
384
  </script>
385
385
 
386
- <script>
387
- // queries: how does a new app appear on app.zone · my app is not on app.zone · new app missing
388
- // from app.zone · runtime append registry · app.zone card unstyled · data-astro-cid
389
- // insertAdjacentHTML · app.zone grid not sorted · where does a runtime card's category
390
- // come from · why is an app's icon fetched from its own host
391
- //
392
- // app.zone's catalog: SSR renders the curated apps (for SEO + design); at RUNTIME we fetch the live
393
- // registry (api.justai.pro/api/catalog) and APPEND any persona registered since the build — a
394
- // *.app.zone citizen not already on the page — so a new app appears with NO app.zone redeploy.
395
- //
396
- // Two traps live in here. (1) Nodes built with insertAdjacentHTML sit OUTSIDE Astro's scoped CSS,
397
- // so the scope attribute has to be copied off a real card onto every injected node — forget it and
398
- // nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
399
- // the append, seeds and newcomers together, so ordering is a property of the merged list, not of
400
- // either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
401
- (function () {
402
- const grid = document.getElementById("grid");
403
- if (!grid) return;
404
- const locale = document.documentElement.lang || "en";
405
- const NEW_MS = 7 * 864e5; // a persona wears NEW only for its first week (honest — from its createdAt)
406
- const newLabel = grid.dataset.newLabel || "New";
407
- // queries: which personas appear on this apex · runtime append kind · why is my game not listed
408
- //
409
- // WHICH CITIZENS THIS APEX LISTS — where the hostname test used to be. A bundled script cannot
410
- // reach this component's props, so the two facts it needs ride the same data-attribute channel as
411
- // newLabel and catMap. And `kind` is asked of the registry rather than inferred from a suffix: a
412
- // persona's apex is a property of the persona, not a shape of its address.
413
- const apexKind = grid.dataset.kind || "app";
414
- const apexHost = grid.dataset.apex || "app.zone";
415
- // The category label must be localized like the SSR path (catLabel), but the API returns the
416
- // category in English regardless of ?locale=. So carry this page's cat dictionary through a data
417
- // attribute (same channel as newLabel) and map through it — the English key stays the stable
418
- // filter value in data-category, only the visible label is translated.
419
- let catMap: Record<string, string> = {};
420
- try { catMap = JSON.parse(grid.dataset.catMap || "{}"); } catch (e) {}
421
- const catLabel = (c: string): string => catMap[c] ?? c;
422
- // The card's line, in the storefront's voice rather than the persona's — same map the SSR path
423
- // reads, arriving through the same channel as the two above. Keyed by host, which the API calls
424
- // `home`. Missing entry falls through to the bio, so a locale with no taglines yet, or a persona
425
- // that registered after they were written, simply keeps what it had.
426
- let tagMap: Record<string, string> = {};
427
- try { tagMap = JSON.parse(grid.dataset.taglines || "{}"); } catch (e) {}
428
-
429
- // hosts already on the page (don't duplicate the SSR cards)
430
- const have = new Set(
431
- Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card")).map((a) => {
432
- try { return new URL(a.href).host; } catch { return ""; }
433
- }),
434
- );
435
- // The appended cards must inherit CatalogueCard's scoped styles → copy the cid off a real card, and
436
- // fall back to the inert one in #cardscope when this apex ships no seed. Without that fallback an
437
- // empty-seed storefront renders every runtime card as unstyled markup — measured on pin.games' first
438
- // night, where the shelf's only game arrived as blue underlined links.
439
- const scopeTpl = document.getElementById("cardscope") as HTMLTemplateElement | null;
440
- const sample = grid.querySelector<HTMLElement>("a.card")
441
- ?? scopeTpl?.content.querySelector<HTMLElement>("a.card")
442
- ?? null;
443
- const scopeAttr = sample ? sample.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null : null;
444
-
445
- const esc = (s: unknown): string =>
446
- String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
447
-
448
- 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 };
449
- const cardHTML = (a: Entry): string => {
450
- const url = `https://${a.home}`;
451
- // the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
452
- // NO inline style. It used to carry the whole .icon rule inline, on the grounds that a node inserted with
453
- // insertAdjacentHTML sits outside Astro's scoped CSS — but the append copies the cid onto every injected
454
- // node, including this img, so the class does apply. What the inline copy actually did was OVERRIDE it:
455
- // when the icon grew from 60px to 72px in AppCard.astro, this stayed at 60 and won on specificity, so the
456
- // three seeded cards rendered a fifth larger than the seven that arrive from the registry. Nobody saw it
457
- // until a reorder put them next to each other. (72 did not last — the size is back to 60, see
458
- // AppCard.astro — but the trap is the same at any value: these attributes and that rule move together.)
459
- // It protected nothing either: if the cid copy ever failed, the whole card would be unstyled, not just
460
- // this image. One rule, in AppCard.astro.
461
- const icon = `<img class="icon" src="${esc(a.avatar || `${url}/icon.svg`)}" alt="" width="60" height="60" loading="lazy" decoding="async">`;
462
- // NEW only for the first week from the persona's own createdAt — never the hardcoded a.badge / a.status.
463
- const created = a.createdAt ? Date.parse(a.createdAt) : NaN;
464
- const isNew = Number.isFinite(created) && created <= Date.now() && Date.now() - created < NEW_MS;
465
- const badge = isNew ? `<span class="badge new">${esc(newLabel)}</span>` : "";
466
- const name = a.name || a.handle;
467
- const cat = a.category || "";
468
- // Must stay in step with AppCard.astro — this is the same card, built twice. The category span is
469
- // gone from both: it lives in the filter row above the grid now.
470
- 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>`;
471
- };
472
-
473
- fetch(`https://api.justai.pro/api/catalog?locale=${encodeURIComponent(locale)}&kind=${encodeURIComponent(apexKind)}`)
474
- .then((r) => (r.ok ? r.json() : null))
475
- .then((data) => {
476
- if (!data || !data.personas) return;
477
- const newcomers = (data.personas as Entry[]).filter(
478
- // The registry already returned only this apex's kind, so all that is left to exclude is the
479
- // apex itself — a household head does not stand on its own shelf.
480
- //
481
- // queries: auth appeared on app.zone · a persona from another apex is listed · why both
482
- // kind and home · residency
483
- //
484
- // TWO QUESTIONS, AND THE OLD LINE ANSWERED BOTH AT ONCE. `home.endsWith(".app.zone")` read
485
- // as "is it an app" and was also, silently, "does it live here" — so replacing it with
486
- // `kind` alone put `auth.justai.pro` and `justai.pro` on app.zone's shelf, both being kind
487
- // `app`. Caught by comparing against the live page before deploying: fifteen cards where
488
- // there had been thirteen, and a category nobody had ever seen.
489
- //
490
- // Both are asked now. KIND is what a persona IS — the registry's own property, and the
491
- // reason a second apex can exist. RESIDENCY is where it LIVES, which is still its address.
492
- (p) => p.home
493
- && p.home.endsWith("." + apexHost)
494
- && p.home !== apexHost && p.handle !== apexHost
495
- && !have.has(p.home),
496
- );
497
- for (const a of newcomers) {
498
- grid.insertAdjacentHTML("beforeend", cardHTML(a));
499
- const el = grid.lastElementChild as HTMLAnchorElement | null;
500
- if (!el) continue;
501
- if (scopeAttr) {
502
- el.setAttribute(scopeAttr, "");
503
- el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
504
- }
505
- try { const u = new URL(el.href); u.searchParams.set("ref", apexHost); u.searchParams.set("lang", locale); el.href = u.toString(); } catch {}
506
- }
507
-
508
- // The filter panel is rendered from the build-time seed, so a persona that registered afterwards
509
- // can arrive carrying a category no row exists for — and it would then be unreachable by filter
510
- // and invisible in the counts. Reconcile the panel against what is actually in the grid: every
511
- // category present gets a row, and every count is recomputed rather than incremented, so this is
512
- // correct however many times it runs.
513
- const fpanel = document.getElementById("fpanel");
514
- if (fpanel) {
515
- // CLONE a row the server rendered instead of building HTML. Astro scopes per COMPONENT, so a
516
- // hand-built node needs Zone.astro's own data-astro-cid-* copied onto it AND every child —
517
- // forget it and nothing errors, the row just renders naked. A clone carries the cid, the tick
518
- // and the inner spans with it, so that whole class of bug is gone rather than commented.
519
- const proto = fpanel.querySelector<HTMLElement>('.opt[data-cat=""]');
520
- const counts = new Map<string, number>();
521
- for (const c of grid.querySelectorAll<HTMLAnchorElement>("a.card")) {
522
- const k = c.dataset.category || "";
523
- if (k) counts.set(k, (counts.get(k) || 0) + 1);
524
- }
525
- const allN = fpanel.querySelector<HTMLElement>('.opt[data-cat=""] .n');
526
- if (allN) allN.textContent = String(grid.querySelectorAll("a.card").length);
527
- for (const [cat, n] of counts) {
528
- let opt = fpanel.querySelector<HTMLElement>(`.opt[data-cat="${CSS.escape(cat)}"]`);
529
- if (!opt && proto) {
530
- opt = proto.cloneNode(true) as HTMLElement;
531
- // the prototype is the ticked "All" row — a clone must arrive unticked
532
- opt.classList.remove("active");
533
- opt.setAttribute("aria-pressed", "false");
534
- opt.dataset.cat = cat;
535
- const lb = opt.querySelector<HTMLElement>(".lb");
536
- if (lb) lb.textContent = catLabel(cat);
537
- fpanel.appendChild(opt);
538
- }
539
- if (!opt) continue;
540
- const num = opt.querySelector<HTMLElement>(".n");
541
- if (num) num.textContent = String(n);
542
- }
543
-
544
- // Re-sort now, because the SSR order was decided from a seed that holds a fraction of the
545
- // catalog — most personas arrive here, at runtime. "All" is pinned first; the rest go
546
- // biggest-first, then alphabetical, so the fullest shelf is the first one read.
547
- const ordered = Array.from(fpanel.querySelectorAll<HTMLElement>(".opt")).sort((x, y) => {
548
- const cx = x.dataset.cat || "", cy = y.dataset.cat || "";
549
- if (!cx) return -1;
550
- if (!cy) return 1;
551
- const nx = counts.get(cx) || 0, ny = counts.get(cy) || 0;
552
- return ny - nx || cx.localeCompare(cy);
553
- });
554
- for (const c of ordered) fpanel.appendChild(c);
555
- }
556
- // One order for the whole grid, and it is CURATED now rather than chronological: src/data/order.ts
557
- // lists hosts top to bottom, and that list is the grid. Anything NOT on the list goes ABOVE it, so
558
- // a persona that registers with the platform announces itself at the top with no change to this
559
- // repo — the list curates what is known, it does not gate what arrives. Several unlisted arrivals
560
- // sort newest-first among themselves, which is the old rule, still doing the one job it is good at.
561
- //
562
- // Keyed by host on purpose. The same app reaches this grid by two routes with two different
563
- // identifiers — Password Generator is id "password" in the seed and handle "password-generator" in
564
- // the registry — and an identifier that only matches one route fails silently on the other. A card
565
- // that fails to match reads as unlisted and jumps to the top, which is the worst possible failure
566
- // for an ordering bug: it looks deliberate. The host is what both routes agree on.
567
- // Anything that is not a list means "no curated order" — never an exception. This parsed a
568
- // `{}` once and `{}.map is not a function` killed everything after it, including the event
569
- // that tells the filter new cards exist: the shelf showed a card AND "the first games are on
570
- // their way" underneath it.
571
- const orderList: string[] = (() => {
572
- try {
573
- const parsed = JSON.parse(grid.dataset.order || "[]");
574
- return Array.isArray(parsed) ? parsed : [];
575
- } catch { return []; }
576
- })();
577
- const rank = new Map(orderList.map((h, i) => [h, i]));
578
- const UNLISTED = -1;
579
- Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card"))
580
- .map((el) => {
581
- let host = "";
582
- try { host = new URL(el.href).host; } catch {}
583
- return { el, r: rank.has(host) ? (rank.get(host) as number) : UNLISTED, added: el.getAttribute("data-added") || "" };
584
- })
585
- .sort((x, y) => {
586
- if (x.r !== y.r) return x.r - y.r; // listed follow the list; unlisted (-1) sort above all
587
- if (x.r !== UNLISTED) return 0; // two listed cards cannot share a rank
588
- if (x.added === y.added) return 0; // both unlisted → newest first, undated last
589
- if (!x.added) return 1;
590
- if (!y.added) return -1;
591
- return x.added < y.added ? 1 : -1;
592
- })
593
- .forEach(({ el }) => grid.appendChild(el));
594
- // queries: the empty message shows under a card · zone:cards never fired · the filter did not
595
- // see the new cards · silent catch
596
- //
597
- // IN A `finally`, BECAUSE EVERYTHING ABOVE IT IS ORNAMENT AND THIS IS NOT. Once cards are in
598
- // the DOM, the filter has to be told — it owns which of them are visible and whether the
599
- // "nothing here" line is showing. Sorting them is a nicety; leaving the page claiming to be
600
- // empty while a card sits above the claim is a defect a visitor sees.
601
- //
602
- // It sat at the end of a promise chain ending in `.catch(() => {})`, so one TypeError in the
603
- // sort silenced it with nothing logged anywhere. That combination — a throw between the work
604
- // and its announcement, and a catch that says nothing — is how a page ends up confidently
605
- // wrong. The `finally` fixes the ordering; the catch below now says what it swallowed.
606
- })
607
- .catch((err) => {
608
- // Never fatal — a storefront must render without the registry. But it must not be SILENT:
609
- // the seed and the build-time layer are already on the page, and a swallowed error here is
610
- // the difference between "no new personas today" and "the append is broken".
611
- console.warn("[catalogue] runtime append failed", err);
612
- })
613
- .finally(() => {
614
- document.dispatchEvent(new Event("zone:cards")); // cards are in — let the filter catch them
615
- });
616
- })();
617
- </script>
386
+ <script>
387
+ // The behaviour lives in ./behaviour/catalogue.ts — see that file for why it is not in here.
388
+ import "./behaviour/catalogue";
389
+ </script>
618
390
 
619
391
  <style>
620
392
  /* The three gaps down this column are one rhythm: 26 to the search, 18 from it to the grid, 14