@justai/cuts 0.132.0 → 0.138.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 +6 -3
- package/src/ActionList.astro +31 -27
- package/src/ActionRow.astro +68 -64
- package/src/BackToZone.astro +39 -35
- package/src/Base.astro +21 -3
- package/src/Catalogue.astro +95 -319
- package/src/CatalogueCard.astro +72 -68
- package/src/ChildPersona.astro +55 -51
- package/src/ConfirmSheet.astro +69 -65
- package/src/FilePicker.astro +21 -378
- package/src/LangPicker.astro +49 -104
- package/src/Posts.astro +208 -397
- package/src/Preroll.astro +204 -487
- package/src/Profile.astro +39 -35
- package/src/Segmented.astro +62 -111
- package/src/Sheet.astro +247 -507
- package/src/Toolbar.astro +288 -362
- package/src/behaviour/catalogue.ts +239 -0
- package/src/behaviour/filePicker.ts +372 -0
- package/src/behaviour/langPicker.ts +70 -0
- package/src/behaviour/posts.ts +234 -0
- package/src/behaviour/preroll.ts +298 -0
- package/src/behaviour/segmented.ts +64 -0
- package/src/behaviour/sheet.ts +300 -0
- package/src/behaviour/toolbar.ts +89 -0
- package/src/ground.css +124 -120
package/src/Catalogue.astro
CHANGED
|
@@ -383,326 +383,102 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
383
383
|
apply();
|
|
384
384
|
</script>
|
|
385
385
|
|
|
386
|
-
<script>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
-
/*
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
392
|
+
/* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
|
|
393
|
+
out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
|
|
394
|
+
@layer justai.components {
|
|
395
|
+
/* The three gaps down this column are one rhythm: 26 to the search, 18 from it to the grid, 14
|
|
396
|
+
between cards. Each step is about 1.4x the next, so they read as one thing narrowing — section,
|
|
397
|
+
then control-to-list, then item-to-item.
|
|
398
|
+
THE TOP ONE USED TO BE `clamp(20px, 5vh, 44px)`, which is not a chosen number: it is whatever the
|
|
399
|
+
viewport gives. Measured, the same page had 37px of it at 700px tall and 46px at 900px, against a
|
|
400
|
+
fixed 18px below — so the search sat 1.8x from the bar on a laptop and 2.2x on a desktop, and at
|
|
401
|
+
that distance it stops reading as the head of the grid and starts looking as though it drifted
|
|
402
|
+
down toward it. A gap that changes with the window is a rhythm nobody designed. */
|
|
403
|
+
.kbody { flex: 1 0 auto; display: flex; flex-direction: column; padding-block: 26px 16px; }
|
|
404
|
+
|
|
405
|
+
.controls { margin-block-end: 18px; }
|
|
406
|
+
|
|
407
|
+
/* The field carries the CARD's material, and the reason is a measurement rather than a preference.
|
|
408
|
+
On this page "raised" means whiter plus a shadow: the cards are `--card-bg` (#fff in light) on an
|
|
409
|
+
off-white ground. The field was `--surface-2` — 4% BLACK over that same ground — with no shadow, so
|
|
410
|
+
it was not lifting, it was recessed, and a recessed input in light reads as disabled. Giving it the
|
|
411
|
+
card recipe means the page has ONE raised surface instead of two treatments arguing.
|
|
412
|
+
Dark loses nothing: there `--card-bg` and `--surface` are the same value, so the fill stays the 6%
|
|
413
|
+
white overlay that was already lifting correctly, and only the shadow is added. That asymmetry is
|
|
414
|
+
why the complaint could only come from the light side. */
|
|
415
|
+
.search {
|
|
416
|
+
display: flex; align-items: center; gap: 10px;
|
|
417
|
+
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-md);
|
|
418
|
+
box-shadow: var(--card-shadow);
|
|
419
|
+
/* the end padding is the button's, not the text's — 16px would park the icon away from the edge it
|
|
420
|
+
belongs to, and the button brings its own 30px of hit area */
|
|
421
|
+
padding: 12px 8px 12px 16px;
|
|
422
|
+
transition: border-color 0.2s var(--ease-out), box-shadow 0.25s var(--ease-out);
|
|
423
|
+
}
|
|
424
|
+
/* focus lives on the whole box as a soft OUTER breeze — not a thick inner ring. The card shadow is
|
|
425
|
+
re-stated after the ring rather than replaced by it: `box-shadow` is one property, so naming only
|
|
426
|
+
the ring here would drop the lift the moment somebody clicked into the field. */
|
|
427
|
+
.search:focus-within {
|
|
428
|
+
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
|
429
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent), var(--card-shadow);
|
|
430
|
+
}
|
|
431
|
+
.search svg { width: 18px; height: 18px; color: var(--text-dim); flex: none; }
|
|
432
|
+
.search input { flex: 1; min-width: 0; border: none; background: none; color: var(--text); font: inherit; font-size: var(--fs-base); outline: none; }
|
|
433
|
+
.search input:focus-visible { box-shadow: none; } /* the box carries the breeze; the input has no ring of its own */
|
|
434
|
+
|
|
435
|
+
/* ---- the filter: a button at the field's own end, and the shelves in a sheet under it ---- */
|
|
436
|
+
.fwrap { position: relative; } /* the panel hangs off this, not off .search, so focus styles stay put */
|
|
437
|
+
|
|
438
|
+
/* A 30px hit area inside a box whose height is set by the input's line box: the negative block margin
|
|
439
|
+
pulls its outer height back under that line, so adding this button does NOT make the field taller.
|
|
440
|
+
Measured before and after — the field is the same height it was with the chip row. */
|
|
441
|
+
.fbtn {
|
|
442
|
+
flex: none; display: grid; place-items: center; width: 30px; height: 30px; margin-block: -6px;
|
|
443
|
+
border: none; background: none; padding: 0; border-radius: var(--radius-sm); cursor: pointer;
|
|
444
|
+
color: var(--text-dim);
|
|
445
|
+
transition: color 0.25s var(--ease-out), background 0.25s var(--ease-out);
|
|
446
|
+
}
|
|
447
|
+
/* `.search svg` sets colour on the ELEMENT, and a direct declaration beats an inherited one — so
|
|
448
|
+
without this the button's own colour (and the accent below) would never reach its icon. Equal
|
|
449
|
+
specificity, later in the sheet, so this wins. */
|
|
450
|
+
.fbtn svg { width: 18px; height: 18px; color: inherit; }
|
|
451
|
+
.fbtn:hover, .fbtn:focus-visible { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
|
|
452
|
+
/* the row that used to announce the active shelf is gone, so the button carries it */
|
|
453
|
+
.fbtn.on { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
|
454
|
+
.fpanel {
|
|
455
|
+
position: absolute; inset-inline: 0; top: calc(100% + 8px); z-index: 5;
|
|
456
|
+
display: flex; flex-direction: column; gap: 2px; padding: 6px;
|
|
457
|
+
/* the column's own material — a translucent ground would let the cards read through the labels */
|
|
458
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md);
|
|
459
|
+
box-shadow: 0 18px 40px -20px rgba(0, 0, 0, 0.45);
|
|
460
|
+
backdrop-filter: blur(30px) saturate(1.4);
|
|
461
|
+
-webkit-backdrop-filter: blur(30px) saturate(1.4);
|
|
462
|
+
}
|
|
463
|
+
.opt {
|
|
464
|
+
display: flex; align-items: center; gap: 9px; padding: 9px 10px;
|
|
465
|
+
border: none; background: none; border-radius: var(--radius-sm); cursor: pointer;
|
|
466
|
+
color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: start;
|
|
467
|
+
transition: background 0.2s var(--ease-out);
|
|
468
|
+
}
|
|
469
|
+
.opt:hover, .opt:focus-visible { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
|
470
|
+
.opt .lb { flex: 1; min-width: 0; }
|
|
471
|
+
/* the count is the shelf's size, so a person knows what is behind a word before spending a click */
|
|
472
|
+
.opt .n { flex: none; font-variant-numeric: tabular-nums; font-size: var(--fs-2xs); color: var(--text-dim); }
|
|
473
|
+
/* hidden, not absent: the tick keeps its column either way, so the labels do not jump sideways when
|
|
474
|
+
the selection moves from one row to another */
|
|
475
|
+
.opt .tick { flex: none; width: 15px; height: 15px; color: var(--accent); visibility: hidden; }
|
|
476
|
+
.opt.active .tick { visibility: visible; }
|
|
477
|
+
.opt.active .lb { font-weight: 600; }
|
|
478
|
+
|
|
479
|
+
.grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
|
480
|
+
/* no entrance animation — when you walk into the room, everyone is already in their place.
|
|
481
|
+
the cards are simply there on load; the only motion is a whisper on hover. */
|
|
482
|
+
.empty { text-align: center; color: var(--text-dim); padding: 40px 0; }
|
|
693
483
|
}
|
|
694
|
-
.opt:hover, .opt:focus-visible { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
|
695
|
-
.opt .lb { flex: 1; min-width: 0; }
|
|
696
|
-
/* the count is the shelf's size, so a person knows what is behind a word before spending a click */
|
|
697
|
-
.opt .n { flex: none; font-variant-numeric: tabular-nums; font-size: var(--fs-2xs); color: var(--text-dim); }
|
|
698
|
-
/* hidden, not absent: the tick keeps its column either way, so the labels do not jump sideways when
|
|
699
|
-
the selection moves from one row to another */
|
|
700
|
-
.opt .tick { flex: none; width: 15px; height: 15px; color: var(--accent); visibility: hidden; }
|
|
701
|
-
.opt.active .tick { visibility: visible; }
|
|
702
|
-
.opt.active .lb { font-weight: 600; }
|
|
703
|
-
|
|
704
|
-
.grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
|
705
|
-
/* no entrance animation — when you walk into the room, everyone is already in their place.
|
|
706
|
-
the cards are simply there on load; the only motion is a whisper on hover. */
|
|
707
|
-
.empty { text-align: center; color: var(--text-dim); padding: 40px 0; }
|
|
708
484
|
</style>
|
package/src/CatalogueCard.astro
CHANGED
|
@@ -46,75 +46,79 @@ const icon = `${app.url}/icon.svg`;
|
|
|
46
46
|
</a>
|
|
47
47
|
|
|
48
48
|
<style>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
49
|
+
/* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
|
|
50
|
+
out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
|
|
51
|
+
@layer justai.components {
|
|
52
|
+
.card {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 16px;
|
|
56
|
+
/* 16, down from 18, to pay for the larger icon: the icon is now what sets the card's height, and the
|
|
57
|
+
two px back on each side keep the row pitch where the shorter card put it. */
|
|
58
|
+
padding: 16px;
|
|
59
|
+
/* The card's own material, not the page column's — see --card-* in tokens.css. In light mode this is
|
|
60
|
+
solid white with a real shadow; in dark it is the glass, which there actually refracts. */
|
|
61
|
+
background: var(--card-bg);
|
|
62
|
+
border: 1px solid var(--border);
|
|
63
|
+
border-radius: var(--radius-lg);
|
|
64
|
+
backdrop-filter: var(--card-blur);
|
|
65
|
+
-webkit-backdrop-filter: var(--card-blur);
|
|
66
|
+
box-shadow: var(--card-shadow);
|
|
67
|
+
text-decoration: none;
|
|
68
|
+
color: var(--text);
|
|
69
|
+
transition: transform 0.3s var(--ease-spring), border-color 0.3s, background 0.3s,
|
|
70
|
+
box-shadow 0.3s var(--ease-out);
|
|
71
|
+
will-change: transform;
|
|
72
|
+
}
|
|
73
|
+
/* the lift and the shadow move together — a card that rises without its shadow growing reads as a
|
|
74
|
+
sticker sliding, not an object leaving the page */
|
|
75
|
+
.card:hover {
|
|
76
|
+
transform: translateY(-2px);
|
|
77
|
+
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
|
78
|
+
box-shadow: var(--card-shadow-hover);
|
|
79
|
+
}
|
|
80
|
+
.card:active { transform: translateY(-1px) scale(0.99); }
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
82
|
+
/* the persona's canonical icon — a self-contained squircle, one face everywhere */
|
|
83
|
+
.icon {
|
|
84
|
+
flex: none;
|
|
85
|
+
/* 60. It was 72 for a day, on the reasoning that the icons are the only colour on this page — everything
|
|
86
|
+
else is ink on paper — so more of them should read as livelier. It read as too big instead, and that
|
|
87
|
+
turns out to be measurable rather than a matter of taste: the text beside the icon is a fixed 66px (a
|
|
88
|
+
16px title on a 1.5 line box, 3px, then two clamped bio lines at 13.44/1.45), so at 72 the icon
|
|
89
|
+
overshot the text by six pixels and the icon, not the content, was setting the card's height.
|
|
90
|
+
64 is the first size that fits inside the text block. 60 gives the same card height as 64 — past the
|
|
91
|
+
crossover the text sets it either way — so the last four pixels cost no vertical space and buy a
|
|
92
|
+
quieter mark. */
|
|
93
|
+
width: 60px;
|
|
94
|
+
height: 60px;
|
|
95
|
+
border-radius: var(--radius-md);
|
|
96
|
+
object-fit: cover;
|
|
97
|
+
box-shadow: 0 8px 20px -12px rgba(0, 0, 0, 0.35);
|
|
98
|
+
}
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
100
|
+
.meta { flex: 1; min-width: 0; }
|
|
101
|
+
.top { display: flex; align-items: center; gap: 8px; }
|
|
102
|
+
h2 { font-size: var(--fs-base); font-weight: 600; letter-spacing: -0.01em; }
|
|
103
|
+
/* Clamped to two lines. The bios run from one line to four, which made every card a different height
|
|
104
|
+
and the column read ragged; the full text is on the app's own page anyway. */
|
|
105
|
+
p {
|
|
106
|
+
color: var(--text-dim); font-size: var(--fs-xs); line-height: 1.45; margin: 3px 0 0;
|
|
107
|
+
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
|
108
|
+
}
|
|
109
|
+
.badge {
|
|
110
|
+
font-size: 0.66rem;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
letter-spacing: 0.04em;
|
|
114
|
+
padding: 2px 7px;
|
|
115
|
+
border-radius: var(--radius-pill);
|
|
116
|
+
}
|
|
117
|
+
/* Only NEW is shown now (for a persona's first week). LIVE/beta/soon badges were dropped — every card
|
|
118
|
+
in the catalog is live by definition, so a "live" badge said nothing. */
|
|
119
|
+
.badge.new { color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
.chev { flex: none; width: 20px; height: 20px; color: var(--text-dim); transition: transform 0.3s var(--ease-out); }
|
|
122
|
+
.card:hover .chev { transform: translateX(3px); color: var(--accent); }
|
|
123
|
+
}
|
|
120
124
|
</style>
|