@justai/cuts 0.104.0 → 0.106.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.104.0",
3
+ "version": "0.106.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": {
@@ -49,7 +49,15 @@ interface Props {
49
49
  /** Layer one. Empty is valid and correct for an apex with nothing seeded yet. */
50
50
  seed?: CatalogueEntry[];
51
51
  /** The curated sequence, keyed by host — read by the runtime sort. */
52
- order?: Record<string, number>;
52
+ /** queries: order prop · curated order · data-order · the grid is not sorted · order type
53
+ *
54
+ * THE CURATED SEQUENCE, AS A LIST OF HOSTS — which is what the runtime sort reads, and what this
55
+ * type said until 2026-08-05. It was declared `Record<string, number>` while the script parsed it
56
+ * as `string[]`; app.zone happened to pass an array so nothing ever disagreed out loud, and
57
+ * pin.games passed `{}` for "no order" and took the whole runtime append down with it. A type that
58
+ * describes something other than what the consumer reads is worse than no type: it invites the one
59
+ * value that will break. */
60
+ order?: readonly string[];
53
61
  /** A shelf label per host: a card has one line to earn a tap, and a bio clamps. Keyed by host. */
54
62
  taglines?: Record<string, string>;
55
63
  /** Localized category names. The English key stays the stable filter value. */
@@ -78,7 +86,7 @@ interface Props {
78
86
  }
79
87
  const {
80
88
  lang, kind, apexHost,
81
- seed = [], order = {}, taglines = {}, catLabels = {}, entries = {},
89
+ seed = [], order = [], taglines = {}, catLabels = {}, entries = {},
82
90
  locales, strings,
83
91
  } = Astro.props;
84
92
 
@@ -202,6 +210,17 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
202
210
  <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)}>
203
211
  {seed.map((app) => <CatalogueCard app={app} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
204
212
  </section>
213
+ {/* queries: runtime cards are unstyled · the card has no CSS · an apex with no seed · scope cid ·
214
+ data-astro-cid · pin.games cards render as blue links
215
+ THE SCOPE, CARRIED IN AN INERT CARD. Cards appended at runtime are built as plain strings, so
216
+ they sit outside CatalogueCard's scoped CSS and have to be stamped with its `data-astro-cid-*`
217
+ by hand. That attribute was read off a SEEDED card — which works on any apex that ships a seed,
218
+ and fails completely on one that does not: pin.games opened with its only game rendered as bare
219
+ blue underlined links, because there was no card to read the scope from.
220
+ A <template> is the right home for it. Its content is parsed but never rendered, never matched
221
+ by a document query and never painted, while Astro still stamps the cid on it at build. So the
222
+ scope is available from the first paint whether or not this apex has ever seeded a card. */}
223
+ <template id="cardscope"><CatalogueCard app={{ id: "scope", url: "https://example.invalid", category: "", added: "" } as any} name="" desc="" isNew={false} newLabel="" /></template>
205
224
  {/* Both sentences ride data-attributes because the loader below is a BUNDLED script and
206
225
  cannot see props — the same seam Posts uses. */}
207
226
  <p class="empty" id="empty" hidden
@@ -413,8 +432,14 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
413
432
  try { return new URL(a.href).host; } catch { return ""; }
414
433
  }),
415
434
  );
416
- // the appended cards must inherit AppCard's scoped styles → copy the cid attribute off a real card
417
- const sample = grid.querySelector<HTMLElement>("a.card");
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;
418
443
  const scopeAttr = sample ? sample.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null : null;
419
444
 
420
445
  const esc = (s: unknown): string =>
@@ -539,8 +564,15 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
539
564
  // the registry — and an identifier that only matches one route fails silently on the other. A card
540
565
  // that fails to match reads as unlisted and jumps to the top, which is the worst possible failure
541
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.
542
571
  const orderList: string[] = (() => {
543
- try { return JSON.parse(grid.dataset.order || "[]"); } catch { return []; }
572
+ try {
573
+ const parsed = JSON.parse(grid.dataset.order || "[]");
574
+ return Array.isArray(parsed) ? parsed : [];
575
+ } catch { return []; }
544
576
  })();
545
577
  const rank = new Map(orderList.map((h, i) => [h, i]));
546
578
  const UNLISTED = -1;
@@ -559,9 +591,28 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
559
591
  return x.added < y.added ? 1 : -1;
560
592
  })
561
593
  .forEach(({ el }) => grid.appendChild(el));
562
- document.dispatchEvent(new Event("zone:cards")); // newcomers in — let the URL filter catch them
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);
563
612
  })
564
- .catch(() => {});
613
+ .finally(() => {
614
+ document.dispatchEvent(new Event("zone:cards")); // cards are in — let the filter catch them
615
+ });
565
616
  })();
566
617
  </script>
567
618