@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 +6 -3
- package/src/Base.astro +21 -3
- package/src/Catalogue.astro +4 -232
- package/src/FilePicker.astro +4 -365
- package/src/LangPicker.astro +4 -63
- package/src/Posts.astro +4 -227
- package/src/Preroll.astro +4 -291
- package/src/Segmented.astro +4 -57
- package/src/Sheet.astro +5 -290
- package/src/Toolbar.astro +4 -82
- 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/Sheet.astro
CHANGED
|
@@ -418,293 +418,8 @@ const doneIsOurs = doneDefault || done === true;
|
|
|
418
418
|
}
|
|
419
419
|
</style>
|
|
420
420
|
|
|
421
|
-
<script>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
// Note what is NOT here any more: a matchMedia guard. The drag worked only below 768px while this
|
|
428
|
-
// component still had a desktop branch; now that there is one behaviour, there is one code path,
|
|
429
|
-
// and a mouse can pull the grabber exactly as a finger does.
|
|
430
|
-
(function () {
|
|
431
|
-
// queries: sheet says Cancel in English · the picker's cancel is not translated · Done stays
|
|
432
|
-
// English · translate the sheet bar · why does the cut translate itself
|
|
433
|
-
//
|
|
434
|
-
// THE BAR SAYS ITS OWN WORDS, IN THE PAGE'S LANGUAGE.
|
|
435
|
-
//
|
|
436
|
-
// Found on an Arabic page: every word in the picker in Arabic, and `Cancel` in English in the
|
|
437
|
-
// sheet's own bar — because the cut rendered the literal and left translating it to whoever
|
|
438
|
-
// mounted it.
|
|
439
|
-
//
|
|
440
|
-
// WHY AT RUNTIME rather than at build. There is no locale to read at build. The fleet routes
|
|
441
|
-
// languages through a `[lang]` PARAMETER, not Astro's i18n config, so `Astro.currentLocale` is
|
|
442
|
-
// undefined in every persona that has one — and the two surfaces that need this most (`files`
|
|
443
|
-
// and `/me`) are ONE bundle serving thirteen languages, so no build-time answer could be right
|
|
444
|
-
// for them anyway. `<html lang>` is the one place every one of them agrees.
|
|
445
|
-
//
|
|
446
|
-
// A CONSUMER'S WORD ALWAYS WINS. `<Sheet cancel="Not now">` is a deliberate choice and this must
|
|
447
|
-
// not overwrite it — so only the DEFAULTS carry `data-chrome`, and only those are rewritten.
|
|
448
|
-
// That is also what keeps the runtime-translating surfaces working: they re-label by id from
|
|
449
|
-
// their own dictionary, which is the older contract and still the more specific one.
|
|
450
|
-
const say = () => {
|
|
451
|
-
const w = chromeCopy(document.documentElement.lang);
|
|
452
|
-
for (const el of document.querySelectorAll<HTMLElement>("[data-chrome]")) {
|
|
453
|
-
const word = w[el.dataset.chrome as "cancel" | "done"];
|
|
454
|
-
if (word) el.textContent = word;
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
say();
|
|
458
|
-
// The child personas set `<html lang>` from their own storage AFTER this module runs — a sheet
|
|
459
|
-
// translated only at load would be correct on a host and English inside a guest. Cheaper than a
|
|
460
|
-
// MutationObserver and exact: the attribute cannot change without the element being replaced.
|
|
461
|
-
new MutationObserver(say).observe(document.documentElement, { attributeFilter: ["lang"] });
|
|
462
|
-
const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
|
|
463
|
-
const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
|
|
464
|
-
const wired = new WeakSet<HTMLDialogElement>();
|
|
465
|
-
|
|
466
|
-
// queries: background scroll lock · html data-sheet-locked · nested sheets unlock too early ·
|
|
467
|
-
// how does the sheet know it opened
|
|
468
|
-
//
|
|
469
|
-
// COUNTED, NOT TOGGLED. Sheets nest — qr's share sheet opens the file door on top of itself —
|
|
470
|
-
// so unlocking on the first `close` would free the page while a second sheet is still up. The
|
|
471
|
-
// count goes 0→1 to lock and 1→0 to unlock, and nothing in between touches it.
|
|
472
|
-
//
|
|
473
|
-
// WATCHED WITH A MutationObserver, because `<dialog>` has no "opened" event and consumers call
|
|
474
|
-
// `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
|
|
475
|
-
// would lock for sheets opened one way and not the other — which is worse than not locking at
|
|
476
|
-
// all, because it would work in testing and fail in the persona.
|
|
477
|
-
const locked = new WeakSet<HTMLDialogElement>();
|
|
478
|
-
let openCount = 0;
|
|
479
|
-
const relock = (delta: number) => {
|
|
480
|
-
openCount = Math.max(0, openCount + delta);
|
|
481
|
-
if (openCount > 0) document.documentElement.setAttribute("data-sheet-locked", "");
|
|
482
|
-
else document.documentElement.removeAttribute("data-sheet-locked");
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
// Measure the tallest sheet ALREADY open and use it as this one's floor. Measured rather than
|
|
486
|
-
// derived from the detent, because a `medium` sheet whose content is short is shorter than its
|
|
487
|
-
// detent allows — the floor has to be what is actually on the screen, not what was permitted.
|
|
488
|
-
function floorOnto(sheet: HTMLDialogElement) {
|
|
489
|
-
if (!sheet.hasAttribute("data-floor")) return;
|
|
490
|
-
const panel = sheet.querySelector<HTMLElement>("[data-panel]");
|
|
491
|
-
if (!panel) return;
|
|
492
|
-
let floor = 0;
|
|
493
|
-
for (const other of document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]")) {
|
|
494
|
-
if (other === sheet) continue;
|
|
495
|
-
const p = other.querySelector<HTMLElement>("[data-panel]");
|
|
496
|
-
if (p) floor = Math.max(floor, p.getBoundingClientRect().height);
|
|
497
|
-
}
|
|
498
|
-
// Never taller than the screen, and never a floor of zero — this is the first sheet then.
|
|
499
|
-
if (floor > 0) panel.style.minHeight = Math.min(floor, window.innerHeight) + "px";
|
|
500
|
-
else panel.style.removeProperty("min-height");
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// ── in-sheet navigation ────────────────────────────────────────────────────────────────────
|
|
504
|
-
// queries: sheet push pop · back button in a sheet · data-sheet-push · escape pops instead of
|
|
505
|
-
// closing · in-sheet navigation stack
|
|
506
|
-
//
|
|
507
|
-
// A STACK PER SHEET, held on the element rather than in a module variable, because two sheets
|
|
508
|
-
// can be open at once and each has its own history. Empty means "at the root", which is also the
|
|
509
|
-
// state every sheet that never declares a page is permanently in — that is what makes this
|
|
510
|
-
// addition free for the consumers who do not use it.
|
|
511
|
-
const stacks = new WeakMap<HTMLDialogElement, string[]>();
|
|
512
|
-
|
|
513
|
-
const pageOf = (sheet: HTMLDialogElement, key: string) =>
|
|
514
|
-
sheet.querySelector<HTMLElement>(`[data-sheet-page="${CSS.escape(key)}"]`);
|
|
515
|
-
|
|
516
|
-
// The name a person reads on the way back. The page's own `data-sheet-page-title` if it has one,
|
|
517
|
-
// otherwise the sheet's heading — which is the honest answer at the root, since that IS where
|
|
518
|
-
// back goes.
|
|
519
|
-
const titleAt = (sheet: HTMLDialogElement, key: string | undefined) =>
|
|
520
|
-
(key ? pageOf(sheet, key)?.dataset.sheetPageTitle : null)
|
|
521
|
-
?? sheet.querySelector<HTMLElement>(".sheet-title")?.textContent?.trim()
|
|
522
|
-
?? "";
|
|
523
|
-
|
|
524
|
-
function show(sheet: HTMLDialogElement, popping: boolean) {
|
|
525
|
-
const stack = stacks.get(sheet) ?? [];
|
|
526
|
-
const pages = sheet.querySelectorAll<HTMLElement>("[data-sheet-page]");
|
|
527
|
-
if (!pages.length) return;
|
|
528
|
-
const current = stack[stack.length - 1];
|
|
529
|
-
for (const el of pages) {
|
|
530
|
-
const on = current ? el.dataset.sheetPage === current : el.hasAttribute("data-sheet-root");
|
|
531
|
-
el.classList.toggle("is-current", on && !popping);
|
|
532
|
-
el.classList.toggle("is-popping", on && popping);
|
|
533
|
-
}
|
|
534
|
-
const back = sheet.querySelector<HTMLElement>("[data-sheet-back]");
|
|
535
|
-
const label = sheet.querySelector<HTMLElement>("[data-sheet-back-label]");
|
|
536
|
-
if (back) back.hidden = stack.length === 0;
|
|
537
|
-
// The label names WHERE BACK GOES — the level beneath the current one — which is the whole
|
|
538
|
-
// convention iOS set. Naming the current page instead is the mistake that makes a back button
|
|
539
|
-
// read as a title.
|
|
540
|
-
if (label) label.textContent = stack.length ? titleAt(sheet, stack[stack.length - 2]) : "";
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function push(sheet: HTMLDialogElement, key: string) {
|
|
544
|
-
if (!pageOf(sheet, key)) return;
|
|
545
|
-
const stack = stacks.get(sheet) ?? [];
|
|
546
|
-
stacks.set(sheet, [...stack, key]);
|
|
547
|
-
show(sheet, false);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function pop(sheet: HTMLDialogElement) {
|
|
551
|
-
const stack = stacks.get(sheet) ?? [];
|
|
552
|
-
if (!stack.length) return false;
|
|
553
|
-
stacks.set(sheet, stack.slice(0, -1));
|
|
554
|
-
show(sheet, true);
|
|
555
|
-
return true;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
document.addEventListener("click", (e) => {
|
|
559
|
-
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-push]");
|
|
560
|
-
if (t) {
|
|
561
|
-
const sheet = t.closest("dialog.sheet") as HTMLDialogElement | null;
|
|
562
|
-
if (sheet) { e.preventDefault(); push(sheet, t.getAttribute("data-sheet-push") || ""); }
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
const b = (e.target as HTMLElement)?.closest?.("[data-sheet-back]");
|
|
566
|
-
if (b) { e.preventDefault(); pop(b.closest("dialog.sheet") as HTMLDialogElement); }
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
// ESCAPE POPS BEFORE IT CLOSES, and the backdrop does the same. Anywhere else in a browser, Back
|
|
570
|
-
// at depth means "up one level"; a sheet that jumps straight out from three levels down throws
|
|
571
|
-
// away everything the person navigated through, and they cannot tell the two gestures apart
|
|
572
|
-
// beforehand. `preventDefault` on `cancel` is what keeps <dialog> from closing underneath us.
|
|
573
|
-
document.addEventListener("cancel", (e) => {
|
|
574
|
-
const sheet = e.target as HTMLDialogElement;
|
|
575
|
-
if (sheet?.classList?.contains("sheet") && pop(sheet)) e.preventDefault();
|
|
576
|
-
}, true);
|
|
577
|
-
|
|
578
|
-
// A sheet always REOPENS AT ITS ROOT. Coming back to a surface and finding it three levels deep
|
|
579
|
-
// where you left it is the behaviour of a window, not of a sheet — and nobody expects a sheet to
|
|
580
|
-
// remember.
|
|
581
|
-
document.addEventListener("click", (e) => {
|
|
582
|
-
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
|
|
583
|
-
if (!t) return;
|
|
584
|
-
const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
|
|
585
|
-
if (sheet) { stacks.set(sheet, []); show(sheet, false); }
|
|
586
|
-
}, true);
|
|
587
|
-
|
|
588
|
-
function wire(sheet: HTMLDialogElement) {
|
|
589
|
-
if (wired.has(sheet) || !sheet.showModal) return;
|
|
590
|
-
wired.add(sheet);
|
|
591
|
-
let wasOpen = sheet.open;
|
|
592
|
-
if (wasOpen) relock(1);
|
|
593
|
-
new MutationObserver(() => {
|
|
594
|
-
if (sheet.open === wasOpen) return;
|
|
595
|
-
wasOpen = sheet.open;
|
|
596
|
-
relock(wasOpen ? 1 : -1);
|
|
597
|
-
if (wasOpen) floorOnto(sheet);
|
|
598
|
-
else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
|
|
599
|
-
}).observe(sheet, { attributes: true, attributeFilter: ["open"] });
|
|
600
|
-
const panel = sheet.querySelector<HTMLElement>("[data-panel]");
|
|
601
|
-
const grip = sheet.querySelector<HTMLElement>("[data-grip]");
|
|
602
|
-
if (!panel) return;
|
|
603
|
-
|
|
604
|
-
// A tap on the stage (outside the panel) dismisses — the one behaviour <dialog> lacks.
|
|
605
|
-
// A tap on the stage pops one level first, for the same reason Escape does.
|
|
606
|
-
sheet.addEventListener("click", (e) => { if (e.target === sheet && !pop(sheet)) sheet.close(); });
|
|
607
|
-
sheet.addEventListener("close", () => {
|
|
608
|
-
sheet.removeAttribute("data-expanded");
|
|
609
|
-
panel.style.transform = "";
|
|
610
|
-
// Back to the root on the way out, whichever way it was dismissed — see the reopen note.
|
|
611
|
-
if (stacks.get(sheet)?.length) { stacks.set(sheet, []); show(sheet, false); }
|
|
612
|
-
});
|
|
613
|
-
// A sheet that ships pages must show one from the very first frame, or its body is empty until
|
|
614
|
-
// somebody navigates. The root is whichever page carries `data-sheet-root`.
|
|
615
|
-
show(sheet, false);
|
|
616
|
-
|
|
617
|
-
// ── drag: the grabber follows the pointer, and the release decides ──
|
|
618
|
-
let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
|
|
619
|
-
grip?.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
620
|
-
id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
|
|
621
|
-
grip.setPointerCapture(id);
|
|
622
|
-
sheet.setAttribute("data-dragging", "");
|
|
623
|
-
});
|
|
624
|
-
grip?.addEventListener("pointermove", (e: PointerEvent) => {
|
|
625
|
-
if (id !== e.pointerId) return;
|
|
626
|
-
dy = e.clientY - y0;
|
|
627
|
-
// Downward drag travels 1:1. Upward is RUBBER-BANDED rather than free: pulling up past the
|
|
628
|
-
// detent must feel like resistance, not like a second scroll surface.
|
|
629
|
-
panel.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy / 4}px)`;
|
|
630
|
-
});
|
|
631
|
-
const release = (e: PointerEvent) => {
|
|
632
|
-
if (id !== e.pointerId) return;
|
|
633
|
-
const dt = Math.max(1, e.timeStamp - t0);
|
|
634
|
-
const v = dy / dt;
|
|
635
|
-
sheet.removeAttribute("data-dragging");
|
|
636
|
-
panel.style.transform = "";
|
|
637
|
-
id = null;
|
|
638
|
-
if (dy > panel.offsetHeight * DRAG_CLOSE || v > FLICK) sheet.close();
|
|
639
|
-
// A decisive pull upwards promotes the medium detent to large — iOS's two-detent sheet.
|
|
640
|
-
else if (dy < -40) sheet.setAttribute("data-expanded", "");
|
|
641
|
-
};
|
|
642
|
-
grip?.addEventListener("pointerup", release);
|
|
643
|
-
grip?.addEventListener("pointercancel", release);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// WIRED ON LOAD, not on the first trigger click.
|
|
647
|
-
//
|
|
648
|
-
// It used to be lazy, and the cost of that surfaced the moment a sheet was first opened by
|
|
649
|
-
// something other than a `data-sheet-open` trigger — <FilePicker> calls showModal() itself,
|
|
650
|
-
// because its sheet opens when a persona ASKS for a file rather than when a person presses a
|
|
651
|
-
// button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
|
|
652
|
-
// of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
|
|
653
|
-
// queries: does the confirm dialog lock the page too · scroll behind ConfirmSheet
|
|
654
|
-
//
|
|
655
|
-
// ConfirmSheet is its own `<dialog class="confirm">` rather than a `<Sheet>` — deliberately, it
|
|
656
|
-
// is an alert and not a surface. But the page scrolling behind it is the same defect on the
|
|
657
|
-
// same person's thumb, so the LOCK covers it while the floor and the drag do not. A rule about
|
|
658
|
-
// how a modal feels belongs to every modal, not to one component.
|
|
659
|
-
function lockOnly(d: HTMLDialogElement) {
|
|
660
|
-
if (locked.has(d)) return;
|
|
661
|
-
locked.add(d);
|
|
662
|
-
let was = d.open;
|
|
663
|
-
if (was) relock(1);
|
|
664
|
-
new MutationObserver(() => {
|
|
665
|
-
if (d.open === was) return;
|
|
666
|
-
was = d.open;
|
|
667
|
-
relock(was ? 1 : -1);
|
|
668
|
-
}).observe(d, { attributes: true, attributeFilter: ["open"] });
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const wireAll = () => {
|
|
672
|
-
document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
|
|
673
|
-
document.querySelectorAll<HTMLDialogElement>("dialog.confirm").forEach(lockOnly);
|
|
674
|
-
};
|
|
675
|
-
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
|
|
676
|
-
else wireAll();
|
|
677
|
-
|
|
678
|
-
// Done closes, and NOTHING ELSE. It is not a commit — a sheet whose changes are live has
|
|
679
|
-
// nothing to commit — so it must not become a hook consumers hang saving off, or the word
|
|
680
|
-
// starts meaning two things in two personas.
|
|
681
|
-
document.addEventListener("click", (e) => {
|
|
682
|
-
const d = (e.target as HTMLElement)?.closest?.("[data-sheet-done]");
|
|
683
|
-
if (!d) return;
|
|
684
|
-
(d.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
// Cancel closes too, and the difference is not in what it DOES — it is in what the caller may
|
|
688
|
-
// conclude. A sheet that resolves a promise reads its own `close` event and settles null there,
|
|
689
|
-
// so a cancel, a backdrop tap and a drag down are one path with one answer. Two words, one exit,
|
|
690
|
-
// and no second place for a caller to be told something different.
|
|
691
|
-
document.addEventListener("click", (e) => {
|
|
692
|
-
const c = (e.target as HTMLElement)?.closest?.("[data-sheet-cancel]");
|
|
693
|
-
if (!c) return;
|
|
694
|
-
(c.closest("dialog.sheet") as HTMLDialogElement | null)?.close();
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
document.addEventListener("click", (e) => {
|
|
698
|
-
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
|
|
699
|
-
if (!t) return;
|
|
700
|
-
const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
|
|
701
|
-
if (!sheet) return;
|
|
702
|
-
e.preventDefault();
|
|
703
|
-
wire(sheet);
|
|
704
|
-
if (!sheet.open) sheet.showModal();
|
|
705
|
-
// Land the focus on the PANEL, not on whatever control happens to be first inside it — the
|
|
706
|
-
// keyboard-over-the-list failure described on the tabindex attribute above.
|
|
707
|
-
sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
|
|
708
|
-
});
|
|
709
|
-
})();
|
|
710
|
-
</script>
|
|
421
|
+
<script>
|
|
422
|
+
// The behaviour lives in ./behaviour/sheet.ts — a module a compiler can read and a test can call.
|
|
423
|
+
// What stays here is the markup and the CSS, which is what a component is.
|
|
424
|
+
import "./behaviour/sheet";
|
|
425
|
+
</script>
|
package/src/Toolbar.astro
CHANGED
|
@@ -583,85 +583,7 @@ import AuthMount from "./AuthMount.astro";
|
|
|
583
583
|
#navPosts :global(svg) { transform: translateY(-1px); }
|
|
584
584
|
</style>
|
|
585
585
|
|
|
586
|
-
<script>
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
// The BEHAVIOUR moved to @justai/core (`personaScroll`); what stays here is the markup and the CSS.
|
|
591
|
-
//
|
|
592
|
-
// It moved because this cut can only serve pages that adopt its markup, and three surfaces cannot:
|
|
593
|
-
// `/me` and `/files` carry extra navigation of their own and the feed is a different build. All
|
|
594
|
-
// three hand-wrote the same eighty lines, and by the third copy they had drifted — `/files` opened
|
|
595
|
-
// on the profile with an empty title strip, and its hash writer clobbered the query string where
|
|
596
|
-
// this one preserved it. One implementation, four consumers.
|
|
597
|
-
//
|
|
598
|
-
// The defaults ARE this cut's contract — the three cut ids, `nav<Cut>`, `#tbTitle`/`#tbTitleText`,
|
|
599
|
-
// `.toolbar`, `#below` — so a persona using the shell passes nothing at all.
|
|
600
|
-
import { personaScroll, adoptMark } from "@justai/core";
|
|
601
|
-
|
|
602
|
-
// queries: the parent icon in a child toolbar has a background · navParent tile · the child's row
|
|
603
|
-
// does not match the persona's · who removes the ground for a child persona
|
|
604
|
-
//
|
|
605
|
-
// THE ROW TAKES CARE OF ITS OWN MARK. Every other icon here is an ink; `parent` renders the host's
|
|
606
|
-
// avatar file, which is a TILE, so a child's row is the one place in the fleet where a background
|
|
607
|
-
// still shows. The persona's own mark is derived at build time from a file the repo imports — a
|
|
608
|
-
// child has only a URL, so `adoptMark` fetches it and runs the same derivation in the browser,
|
|
609
|
-
// measuring the box from the drawing instead of being told it.
|
|
610
|
-
//
|
|
611
|
-
// Here rather than in the three children because this cut is what rendered `#navParent`: three
|
|
612
|
-
// consumers each remembering a line is O(N), and the third one always forgets.
|
|
613
|
-
if (document.getElementById("navParent")) adoptMark({ id: "navParent" });
|
|
614
|
-
|
|
615
|
-
// queries: the persona mark is an image · the golden rule does not apply · currentColor img ·
|
|
616
|
-
// the mark keeps its colours when I did not pass profileIcon
|
|
617
|
-
//
|
|
618
|
-
// THE FALLBACK HAS TO OBEY THE RULE TOO. A persona that passes `profileIcon` hands over a mark
|
|
619
|
-
// derived at build time — ink, ground-less, sized. A persona that passes nothing gets the `<img>`
|
|
620
|
-
// above, and an image obeys nothing: it cannot take `currentColor`, so it wears its own colours
|
|
621
|
-
// while another cut is current, which is exactly what the accent must never do.
|
|
622
|
-
//
|
|
623
|
-
// It is the child persona's problem in a different costume — a mark known only as a URL — so it
|
|
624
|
-
// takes the child's answer: fetch it and derive it here. Same function, same three edits, and the
|
|
625
|
-
// row's own `.tb-nav` / `.tb-nav.active` rules reach it afterwards like any glyph.
|
|
626
|
-
//
|
|
627
|
-
// The build-time path stays the better one and stays the default advice: it costs no request and
|
|
628
|
-
// no frame of the wrong picture. This is the floor under a persona that has not got there yet.
|
|
629
|
-
const ownMark = document.querySelector("#navProfile img");
|
|
630
|
-
if (ownMark) adoptMark({ id: "navProfile" });
|
|
631
|
-
|
|
632
|
-
// Both or neither — see the props above. When both are set the strip names the SECTIONS; core's
|
|
633
|
-
// default, which names the persona and its handle, is left entirely alone otherwise. Nothing here
|
|
634
|
-
// reproduces that default, which is the point: `.pname` and `.handle` stay core's business.
|
|
635
|
-
// ── the stage: the row stays LIVE, and using it pauses the game ───────────────────────────
|
|
636
|
-
// queries: toolbar during a game · tapping posts while playing · auto pause · data-stage-silent
|
|
637
|
-
//
|
|
638
|
-
// The row went inert for one version and the founder unlocked it: every control stays enabled,
|
|
639
|
-
// and touching ANY of them while a game is playing asks the game to pause first — so a player who
|
|
640
|
-
// reaches for the posts mid-flight gets a paused gull and the feed, in one tap. The event is not
|
|
641
|
-
// blocked: the action proceeds naturally while the pause lands beside it (a locked page still
|
|
642
|
-
// scrolls programmatically, so even the hash navigation works before the unlock).
|
|
643
|
-
//
|
|
644
|
-
// Declarative seam, as asked: an element that must NOT pause the game opts out by carrying
|
|
645
|
-
// `data-stage-silent` — on itself or any ancestor. Everything else pauses by default, because a
|
|
646
|
-
// rule with a default and an opt-out survives a fleet; a per-element opt-in is the third copy
|
|
647
|
-
// that forgets.
|
|
648
|
-
document.querySelector(".toolbar")?.addEventListener(
|
|
649
|
-
"click",
|
|
650
|
-
(e) => {
|
|
651
|
-
if (document.documentElement.dataset.stage !== "playing") return;
|
|
652
|
-
const hit = (e.target as Element | null)?.closest?.("a, button, select, [role=button]");
|
|
653
|
-
if (!hit || hit.closest("[data-stage-silent]")) return;
|
|
654
|
-
document.dispatchEvent(new CustomEvent("justai:pause-request"));
|
|
655
|
-
},
|
|
656
|
-
true,
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
const bar = document.querySelector(".toolbar") as HTMLElement | null;
|
|
660
|
-
const kt = bar?.dataset.kernelTitle;
|
|
661
|
-
const pt = bar?.dataset.postsTitle;
|
|
662
|
-
personaScroll(
|
|
663
|
-
kt && pt
|
|
664
|
-
? { titleFor: (id: string) => (id === "kernel" ? kt : id === "posts" ? pt : "") }
|
|
665
|
-
: undefined,
|
|
666
|
-
);
|
|
667
|
-
</script>
|
|
586
|
+
<script>
|
|
587
|
+
// The behaviour lives in ./behaviour/toolbar.ts — see that file for why it is not in here.
|
|
588
|
+
import "./behaviour/toolbar";
|
|
589
|
+
</script>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// queries: catalogue behaviour · catalogue script · where did Catalogue.astro's script go ·
|
|
2
|
+
// testing a component's behaviour · component script is not type checked
|
|
3
|
+
//
|
|
4
|
+
// The catalogue — live search, the URL filter, and the deep links a campaign lands on.
|
|
5
|
+
//
|
|
6
|
+
// Lifted out of `Catalogue.astro`, where it was TypeScript that nothing read: a hoisted Astro script
|
|
7
|
+
// goes to esbuild, which strips annotations WITHOUT checking them, and this package had no tsconfig.
|
|
8
|
+
// Out here a compiler can open it and a test can call it. What stays in the component is the markup
|
|
9
|
+
// and the CSS, which is what a component is.
|
|
10
|
+
// queries: how does a new app appear on app.zone · my app is not on app.zone · new app missing
|
|
11
|
+
// from app.zone · runtime append registry · app.zone card unstyled · data-astro-cid
|
|
12
|
+
// insertAdjacentHTML · app.zone grid not sorted · where does a runtime card's category
|
|
13
|
+
// come from · why is an app's icon fetched from its own host
|
|
14
|
+
//
|
|
15
|
+
// app.zone's catalog: SSR renders the curated apps (for SEO + design); at RUNTIME we fetch the live
|
|
16
|
+
// registry (api.justai.pro/api/catalog) and APPEND any persona registered since the build — a
|
|
17
|
+
// *.app.zone citizen not already on the page — so a new app appears with NO app.zone redeploy.
|
|
18
|
+
//
|
|
19
|
+
// Two traps live in here. (1) Nodes built with insertAdjacentHTML sit OUTSIDE Astro's scoped CSS,
|
|
20
|
+
// so the scope attribute has to be copied off a real card onto every injected node — forget it and
|
|
21
|
+
// nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
|
|
22
|
+
// the append, seeds and newcomers together, so ordering is a property of the merged list, not of
|
|
23
|
+
// either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
|
|
24
|
+
(function () {
|
|
25
|
+
const grid = document.getElementById("grid");
|
|
26
|
+
if (!grid) return;
|
|
27
|
+
const locale = document.documentElement.lang || "en";
|
|
28
|
+
const NEW_MS = 7 * 864e5; // a persona wears NEW only for its first week (honest — from its createdAt)
|
|
29
|
+
const newLabel = grid.dataset.newLabel || "New";
|
|
30
|
+
// queries: which personas appear on this apex · runtime append kind · why is my game not listed
|
|
31
|
+
//
|
|
32
|
+
// WHICH CITIZENS THIS APEX LISTS — where the hostname test used to be. A bundled script cannot
|
|
33
|
+
// reach this component's props, so the two facts it needs ride the same data-attribute channel as
|
|
34
|
+
// newLabel and catMap. And `kind` is asked of the registry rather than inferred from a suffix: a
|
|
35
|
+
// persona's apex is a property of the persona, not a shape of its address.
|
|
36
|
+
const apexKind = grid.dataset.kind || "app";
|
|
37
|
+
const apexHost = grid.dataset.apex || "app.zone";
|
|
38
|
+
// The category label must be localized like the SSR path (catLabel), but the API returns the
|
|
39
|
+
// category in English regardless of ?locale=. So carry this page's cat dictionary through a data
|
|
40
|
+
// attribute (same channel as newLabel) and map through it — the English key stays the stable
|
|
41
|
+
// filter value in data-category, only the visible label is translated.
|
|
42
|
+
let catMap: Record<string, string> = {};
|
|
43
|
+
try { catMap = JSON.parse(grid.dataset.catMap || "{}"); } catch (e) {}
|
|
44
|
+
const catLabel = (c: string): string => catMap[c] ?? c;
|
|
45
|
+
// The card's line, in the storefront's voice rather than the persona's — same map the SSR path
|
|
46
|
+
// reads, arriving through the same channel as the two above. Keyed by host, which the API calls
|
|
47
|
+
// `home`. Missing entry falls through to the bio, so a locale with no taglines yet, or a persona
|
|
48
|
+
// that registered after they were written, simply keeps what it had.
|
|
49
|
+
let tagMap: Record<string, string> = {};
|
|
50
|
+
try { tagMap = JSON.parse(grid.dataset.taglines || "{}"); } catch (e) {}
|
|
51
|
+
|
|
52
|
+
// hosts already on the page (don't duplicate the SSR cards)
|
|
53
|
+
const have = new Set(
|
|
54
|
+
Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card")).map((a) => {
|
|
55
|
+
try { return new URL(a.href).host; } catch { return ""; }
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
// The appended cards must inherit CatalogueCard's scoped styles → copy the cid off a real card, and
|
|
59
|
+
// fall back to the inert one in #cardscope when this apex ships no seed. Without that fallback an
|
|
60
|
+
// empty-seed storefront renders every runtime card as unstyled markup — measured on pin.games' first
|
|
61
|
+
// night, where the shelf's only game arrived as blue underlined links.
|
|
62
|
+
const scopeTpl = document.getElementById("cardscope") as HTMLTemplateElement | null;
|
|
63
|
+
const sample = grid.querySelector<HTMLElement>("a.card")
|
|
64
|
+
?? scopeTpl?.content.querySelector<HTMLElement>("a.card")
|
|
65
|
+
?? null;
|
|
66
|
+
const scopeAttr = sample ? sample.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null : null;
|
|
67
|
+
|
|
68
|
+
const esc = (s: unknown): string =>
|
|
69
|
+
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
70
|
+
|
|
71
|
+
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 };
|
|
72
|
+
const cardHTML = (a: Entry): string => {
|
|
73
|
+
const url = `https://${a.home}`;
|
|
74
|
+
// the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
|
|
75
|
+
// NO inline style. It used to carry the whole .icon rule inline, on the grounds that a node inserted with
|
|
76
|
+
// insertAdjacentHTML sits outside Astro's scoped CSS — but the append copies the cid onto every injected
|
|
77
|
+
// node, including this img, so the class does apply. What the inline copy actually did was OVERRIDE it:
|
|
78
|
+
// when the icon grew from 60px to 72px in AppCard.astro, this stayed at 60 and won on specificity, so the
|
|
79
|
+
// three seeded cards rendered a fifth larger than the seven that arrive from the registry. Nobody saw it
|
|
80
|
+
// until a reorder put them next to each other. (72 did not last — the size is back to 60, see
|
|
81
|
+
// AppCard.astro — but the trap is the same at any value: these attributes and that rule move together.)
|
|
82
|
+
// It protected nothing either: if the cid copy ever failed, the whole card would be unstyled, not just
|
|
83
|
+
// this image. One rule, in AppCard.astro.
|
|
84
|
+
const icon = `<img class="icon" src="${esc(a.avatar || `${url}/icon.svg`)}" alt="" width="60" height="60" loading="lazy" decoding="async">`;
|
|
85
|
+
// NEW only for the first week from the persona's own createdAt — never the hardcoded a.badge / a.status.
|
|
86
|
+
const created = a.createdAt ? Date.parse(a.createdAt) : NaN;
|
|
87
|
+
const isNew = Number.isFinite(created) && created <= Date.now() && Date.now() - created < NEW_MS;
|
|
88
|
+
const badge = isNew ? `<span class="badge new">${esc(newLabel)}</span>` : "";
|
|
89
|
+
const name = a.name || a.handle;
|
|
90
|
+
const cat = a.category || "";
|
|
91
|
+
// Must stay in step with AppCard.astro — this is the same card, built twice. The category span is
|
|
92
|
+
// gone from both: it lives in the filter row above the grid now.
|
|
93
|
+
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>`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
fetch(`https://api.justai.pro/api/catalog?locale=${encodeURIComponent(locale)}&kind=${encodeURIComponent(apexKind)}`)
|
|
97
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
98
|
+
.then((data) => {
|
|
99
|
+
if (!data || !data.personas) return;
|
|
100
|
+
const newcomers = (data.personas as Entry[]).filter(
|
|
101
|
+
// The registry already returned only this apex's kind, so all that is left to exclude is the
|
|
102
|
+
// apex itself — a household head does not stand on its own shelf.
|
|
103
|
+
//
|
|
104
|
+
// queries: auth appeared on app.zone · a persona from another apex is listed · why both
|
|
105
|
+
// kind and home · residency
|
|
106
|
+
//
|
|
107
|
+
// TWO QUESTIONS, AND THE OLD LINE ANSWERED BOTH AT ONCE. `home.endsWith(".app.zone")` read
|
|
108
|
+
// as "is it an app" and was also, silently, "does it live here" — so replacing it with
|
|
109
|
+
// `kind` alone put `auth.justai.pro` and `justai.pro` on app.zone's shelf, both being kind
|
|
110
|
+
// `app`. Caught by comparing against the live page before deploying: fifteen cards where
|
|
111
|
+
// there had been thirteen, and a category nobody had ever seen.
|
|
112
|
+
//
|
|
113
|
+
// Both are asked now. KIND is what a persona IS — the registry's own property, and the
|
|
114
|
+
// reason a second apex can exist. RESIDENCY is where it LIVES, which is still its address.
|
|
115
|
+
(p) => p.home
|
|
116
|
+
&& p.home.endsWith("." + apexHost)
|
|
117
|
+
&& p.home !== apexHost && p.handle !== apexHost
|
|
118
|
+
&& !have.has(p.home),
|
|
119
|
+
);
|
|
120
|
+
for (const a of newcomers) {
|
|
121
|
+
grid.insertAdjacentHTML("beforeend", cardHTML(a));
|
|
122
|
+
const el = grid.lastElementChild as HTMLAnchorElement | null;
|
|
123
|
+
if (!el) continue;
|
|
124
|
+
if (scopeAttr) {
|
|
125
|
+
el.setAttribute(scopeAttr, "");
|
|
126
|
+
el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
|
|
127
|
+
}
|
|
128
|
+
try { const u = new URL(el.href); u.searchParams.set("ref", apexHost); u.searchParams.set("lang", locale); el.href = u.toString(); } catch {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The filter panel is rendered from the build-time seed, so a persona that registered afterwards
|
|
132
|
+
// can arrive carrying a category no row exists for — and it would then be unreachable by filter
|
|
133
|
+
// and invisible in the counts. Reconcile the panel against what is actually in the grid: every
|
|
134
|
+
// category present gets a row, and every count is recomputed rather than incremented, so this is
|
|
135
|
+
// correct however many times it runs.
|
|
136
|
+
const fpanel = document.getElementById("fpanel");
|
|
137
|
+
if (fpanel) {
|
|
138
|
+
// CLONE a row the server rendered instead of building HTML. Astro scopes per COMPONENT, so a
|
|
139
|
+
// hand-built node needs Zone.astro's own data-astro-cid-* copied onto it AND every child —
|
|
140
|
+
// forget it and nothing errors, the row just renders naked. A clone carries the cid, the tick
|
|
141
|
+
// and the inner spans with it, so that whole class of bug is gone rather than commented.
|
|
142
|
+
const proto = fpanel.querySelector<HTMLElement>('.opt[data-cat=""]');
|
|
143
|
+
const counts = new Map<string, number>();
|
|
144
|
+
for (const c of grid.querySelectorAll<HTMLAnchorElement>("a.card")) {
|
|
145
|
+
const k = c.dataset.category || "";
|
|
146
|
+
if (k) counts.set(k, (counts.get(k) || 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const allN = fpanel.querySelector<HTMLElement>('.opt[data-cat=""] .n');
|
|
149
|
+
if (allN) allN.textContent = String(grid.querySelectorAll("a.card").length);
|
|
150
|
+
for (const [cat, n] of counts) {
|
|
151
|
+
let opt = fpanel.querySelector<HTMLElement>(`.opt[data-cat="${CSS.escape(cat)}"]`);
|
|
152
|
+
if (!opt && proto) {
|
|
153
|
+
opt = proto.cloneNode(true) as HTMLElement;
|
|
154
|
+
// the prototype is the ticked "All" row — a clone must arrive unticked
|
|
155
|
+
opt.classList.remove("active");
|
|
156
|
+
opt.setAttribute("aria-pressed", "false");
|
|
157
|
+
opt.dataset.cat = cat;
|
|
158
|
+
const lb = opt.querySelector<HTMLElement>(".lb");
|
|
159
|
+
if (lb) lb.textContent = catLabel(cat);
|
|
160
|
+
fpanel.appendChild(opt);
|
|
161
|
+
}
|
|
162
|
+
if (!opt) continue;
|
|
163
|
+
const num = opt.querySelector<HTMLElement>(".n");
|
|
164
|
+
if (num) num.textContent = String(n);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Re-sort now, because the SSR order was decided from a seed that holds a fraction of the
|
|
168
|
+
// catalog — most personas arrive here, at runtime. "All" is pinned first; the rest go
|
|
169
|
+
// biggest-first, then alphabetical, so the fullest shelf is the first one read.
|
|
170
|
+
const ordered = Array.from(fpanel.querySelectorAll<HTMLElement>(".opt")).sort((x, y) => {
|
|
171
|
+
const cx = x.dataset.cat || "", cy = y.dataset.cat || "";
|
|
172
|
+
if (!cx) return -1;
|
|
173
|
+
if (!cy) return 1;
|
|
174
|
+
const nx = counts.get(cx) || 0, ny = counts.get(cy) || 0;
|
|
175
|
+
return ny - nx || cx.localeCompare(cy);
|
|
176
|
+
});
|
|
177
|
+
for (const c of ordered) fpanel.appendChild(c);
|
|
178
|
+
}
|
|
179
|
+
// One order for the whole grid, and it is CURATED now rather than chronological: src/data/order.ts
|
|
180
|
+
// lists hosts top to bottom, and that list is the grid. Anything NOT on the list goes ABOVE it, so
|
|
181
|
+
// a persona that registers with the platform announces itself at the top with no change to this
|
|
182
|
+
// repo — the list curates what is known, it does not gate what arrives. Several unlisted arrivals
|
|
183
|
+
// sort newest-first among themselves, which is the old rule, still doing the one job it is good at.
|
|
184
|
+
//
|
|
185
|
+
// Keyed by host on purpose. The same app reaches this grid by two routes with two different
|
|
186
|
+
// identifiers — Password Generator is id "password" in the seed and handle "password-generator" in
|
|
187
|
+
// the registry — and an identifier that only matches one route fails silently on the other. A card
|
|
188
|
+
// that fails to match reads as unlisted and jumps to the top, which is the worst possible failure
|
|
189
|
+
// for an ordering bug: it looks deliberate. The host is what both routes agree on.
|
|
190
|
+
// Anything that is not a list means "no curated order" — never an exception. This parsed a
|
|
191
|
+
// `{}` once and `{}.map is not a function` killed everything after it, including the event
|
|
192
|
+
// that tells the filter new cards exist: the shelf showed a card AND "the first games are on
|
|
193
|
+
// their way" underneath it.
|
|
194
|
+
const orderList: string[] = (() => {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(grid.dataset.order || "[]");
|
|
197
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
198
|
+
} catch { return []; }
|
|
199
|
+
})();
|
|
200
|
+
const rank = new Map(orderList.map((h, i) => [h, i]));
|
|
201
|
+
const UNLISTED = -1;
|
|
202
|
+
Array.from(grid.querySelectorAll<HTMLAnchorElement>("a.card"))
|
|
203
|
+
.map((el) => {
|
|
204
|
+
let host = "";
|
|
205
|
+
try { host = new URL(el.href).host; } catch {}
|
|
206
|
+
return { el, r: rank.has(host) ? (rank.get(host) as number) : UNLISTED, added: el.getAttribute("data-added") || "" };
|
|
207
|
+
})
|
|
208
|
+
.sort((x, y) => {
|
|
209
|
+
if (x.r !== y.r) return x.r - y.r; // listed follow the list; unlisted (-1) sort above all
|
|
210
|
+
if (x.r !== UNLISTED) return 0; // two listed cards cannot share a rank
|
|
211
|
+
if (x.added === y.added) return 0; // both unlisted → newest first, undated last
|
|
212
|
+
if (!x.added) return 1;
|
|
213
|
+
if (!y.added) return -1;
|
|
214
|
+
return x.added < y.added ? 1 : -1;
|
|
215
|
+
})
|
|
216
|
+
.forEach(({ el }) => grid.appendChild(el));
|
|
217
|
+
// queries: the empty message shows under a card · zone:cards never fired · the filter did not
|
|
218
|
+
// see the new cards · silent catch
|
|
219
|
+
//
|
|
220
|
+
// IN A `finally`, BECAUSE EVERYTHING ABOVE IT IS ORNAMENT AND THIS IS NOT. Once cards are in
|
|
221
|
+
// the DOM, the filter has to be told — it owns which of them are visible and whether the
|
|
222
|
+
// "nothing here" line is showing. Sorting them is a nicety; leaving the page claiming to be
|
|
223
|
+
// empty while a card sits above the claim is a defect a visitor sees.
|
|
224
|
+
//
|
|
225
|
+
// It sat at the end of a promise chain ending in `.catch(() => {})`, so one TypeError in the
|
|
226
|
+
// sort silenced it with nothing logged anywhere. That combination — a throw between the work
|
|
227
|
+
// and its announcement, and a catch that says nothing — is how a page ends up confidently
|
|
228
|
+
// wrong. The `finally` fixes the ordering; the catch below now says what it swallowed.
|
|
229
|
+
})
|
|
230
|
+
.catch((err) => {
|
|
231
|
+
// Never fatal — a storefront must render without the registry. But it must not be SILENT:
|
|
232
|
+
// the seed and the build-time layer are already on the page, and a swallowed error here is
|
|
233
|
+
// the difference between "no new personas today" and "the append is broken".
|
|
234
|
+
console.warn("[catalogue] runtime append failed", err);
|
|
235
|
+
})
|
|
236
|
+
.finally(() => {
|
|
237
|
+
document.dispatchEvent(new Event("zone:cards")); // cards are in — let the filter catch them
|
|
238
|
+
});
|
|
239
|
+
})();
|