@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/Posts.astro
CHANGED
|
@@ -345,230 +345,7 @@ const vars = [
|
|
|
345
345
|
.z-foot a { color: var(--confirm-accent, var(--accent)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
|
|
346
346
|
</style>
|
|
347
347
|
|
|
348
|
-
<script>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
(function () {
|
|
353
|
-
const section = document.getElementById("posts");
|
|
354
|
-
const feed = document.getElementById("feed");
|
|
355
|
-
if (!section || !feed) return;
|
|
356
|
-
// the persona identity travels on the section as data-attributes (the static cut set them from props).
|
|
357
|
-
// PERSONA is the KEY (feed query + the "is this reply mine?" test); HANDLE is what is rendered after
|
|
358
|
-
// the @ (they differ for the two apexes: app.zone → @appzone, justai → @justai.pro). NAME is the
|
|
359
|
-
// persona's OWN name and is injected RAW — it is authored in the persona's source, never user input,
|
|
360
|
-
// so it may carry markup (justai's ".pro" span). Every name that comes from the API is escaped.
|
|
361
|
-
const PERSONA = section.dataset.persona || "";
|
|
362
|
-
const NAME = section.dataset.name || "";
|
|
363
|
-
// THE PAGE'S LANGUAGE. `data-lang` is present only when the persona STATED it; otherwise the
|
|
364
|
-
// document's own `lang` is read, which is the one place every persona in this fleet agrees —
|
|
365
|
-
// `Astro.currentLocale` is undefined under `[lang]` routing and cannot be the fallback it looks
|
|
366
|
-
// like. Not localStorage either: on a localized home the language is in the URL, settled before
|
|
367
|
-
// this ran, and a link shared in Turkish must open in Turkish for whoever follows it.
|
|
368
|
-
const LANG = (section.dataset.lang || document.documentElement.lang || "en").toLowerCase().split("-")[0];
|
|
369
|
-
if (!PERSONA) return;
|
|
370
|
-
// Astro scopes this component's CSS by a data-astro-cid-* attribute; runtime nodes lack it, so copy it.
|
|
371
|
-
const scopeAttr = section.getAttributeNames().find((n) => n.startsWith("data-astro-cid-")) || null;
|
|
372
|
-
|
|
373
|
-
// Twitter-style: clicking anywhere on a post (except a real link) opens its feed page.
|
|
374
|
-
// The "show original" link is handled first and swallowed — it is a control, not a destination.
|
|
375
|
-
feed.addEventListener("click", (e) => {
|
|
376
|
-
const t = e.target as HTMLElement;
|
|
377
|
-
const tog = t.closest("[data-toggle-original]") as HTMLElement | null;
|
|
378
|
-
if (tog) {
|
|
379
|
-
const art = tog.closest(".post");
|
|
380
|
-
const body = art?.querySelector(".pbd") as HTMLElement | null;
|
|
381
|
-
const state = art?.querySelector("[data-tstate]") as HTMLElement | null;
|
|
382
|
-
// Only take over the click once everything it needs is in hand. If the markup ever changes
|
|
383
|
-
// shape the link stays a link and still reaches the original — a control that degrades to
|
|
384
|
-
// working beats one that silently does nothing.
|
|
385
|
-
if (!body || !state) return;
|
|
386
|
-
e.preventDefault();
|
|
387
|
-
const showing = tog.textContent === tog.dataset.showTranslation;
|
|
388
|
-
body.textContent = showing ? tog.dataset.translation! : tog.dataset.original!;
|
|
389
|
-
body.setAttribute("lang", (showing ? tog.dataset.lang : tog.dataset.originalLang) || "");
|
|
390
|
-
tog.textContent = showing ? tog.dataset.showOriginal! : tog.dataset.showTranslation!;
|
|
391
|
-
state.textContent = showing ? tog.dataset.translated! : tog.dataset.originalLabel!;
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
if (t.closest("a")) return;
|
|
395
|
-
const art = t.closest(".post[data-href]") as HTMLElement | null;
|
|
396
|
-
if (art?.dataset.href) location.assign(art.dataset.href);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
const esc = (s: unknown): string =>
|
|
400
|
-
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
401
|
-
// Every persona signs its posts with its own face, /icon.svg by default (each app serves one at its
|
|
402
|
-
// root). The path is a data attribute rather than a literal because a persona may sign with a mark that
|
|
403
|
-
// has no tile — see avatarSrc / avatarBare above. `bare` rides on the IMAGE, not on the container:
|
|
404
|
-
// .rav wraps both a self-reply and ANOTHER persona's reply, so a container rule would strip the tile
|
|
405
|
-
// from siblings who legitimately have one.
|
|
406
|
-
const bare = section.hasAttribute("data-av-bare") ? " bare" : "";
|
|
407
|
-
const avLight = esc(section.dataset.av || "/icon.svg");
|
|
408
|
-
// When a dark twin is declared, BOTH are in the markup and CSS shows one. The alternative — one <img>
|
|
409
|
-
// whose src is chosen in JS off matchMedia — would be a second source of truth for the same decision and
|
|
410
|
-
// would not follow a scheme change without a listener.
|
|
411
|
-
const AV = section.dataset.avDark
|
|
412
|
-
? `<img class="mk${bare} av-light" src="${avLight}" alt=""><img class="mk${bare} av-dark" src="${esc(section.dataset.avDark)}" alt="">`
|
|
413
|
-
: `<img class="mk${bare}" src="${avLight}" alt="">`;
|
|
414
|
-
const ck = `<svg class="ck" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 1.8 3 .2.9 2.8 2.3 1.9-1 2.8 1 2.8-2.3 1.9-.9 2.8-3 .2L12 22l-2.4-1.8-3-.2-.9-2.8L3.4 14l1-2.8-1-2.8 2.3-1.9.9-2.8 3-.2z" fill="var(--accent)"/><path d="M9 12.5l2 2 4-4.5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
415
|
-
const pact = `<div class="pact"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8.5a5.5 5.5 0 0 0-9-4.2A5.5 5.5 0 0 0 3 8.5c0 5 9 11 9 11s9-6 9-11z"/></svg></span><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.3 9.6 9.6 0 0 1-4-.9L3 21l1.1-5A8.4 8.4 0 1 1 21 11.5z"/></svg></span></div>`;
|
|
416
|
-
|
|
417
|
-
type C = { body: string; createdAt: string | null; authorHandle: string; authorName: string; authorColor: string | null; isPersona: boolean; authorAvatar: string | null; authorGlyph: string | null; personaUrl: string | null };
|
|
418
|
-
// `lang` is what `body` IS; `translated` says a machine made it. Both are optional so this cut
|
|
419
|
-
// still renders against a console that predates them — the fleet installs its packages on its own
|
|
420
|
-
// schedule, and a persona that has not been bumped must not lose its feed.
|
|
421
|
-
type P = { id: string; body: string; lang?: string; originalLang?: string; originalBody?: string | null; translated?: boolean; createdAt: string | null; comments: C[] };
|
|
422
|
-
|
|
423
|
-
// queries: translated automatically · show original · machine translation notice on the home feed
|
|
424
|
-
//
|
|
425
|
-
// THE SAME LINE THE PERMALINK PAGE SHOWS, because they render one conversation and must not
|
|
426
|
-
// disagree about it. The original is canonical, marked, and one tap away — that pairing IS the
|
|
427
|
-
// doctrine (dna/entities/localization.md), not a courtesy.
|
|
428
|
-
//
|
|
429
|
-
// Thirteen strings rather than a lookup through the persona's own dictionary: this cut ships to
|
|
430
|
-
// every persona and cannot reach into any of their locale files. ~400 bytes, said once, for the
|
|
431
|
-
// whole fleet — the alternative is the same sentence copied into thirteen files times thirty
|
|
432
|
-
// repos, which is the shape `tagline.js` already exists to prevent.
|
|
433
|
-
const TRANSLATED: Record<string, string> = {
|
|
434
|
-
en: "Translated automatically", tr: "Otomatik olarak çevrildi", es: "Traducido automáticamente",
|
|
435
|
-
de: "Automatisch übersetzt", fr: "Traduit automatiquement", pt: "Traduzido automaticamente",
|
|
436
|
-
it: "Tradotto automaticamente", nl: "Automatisch vertaald", ru: "Переведено автоматически",
|
|
437
|
-
ja: "自動翻訳", zh: "自动翻译", ar: "تُرجم آليًا", hi: "स्वचालित रूप से अनूदित",
|
|
438
|
-
};
|
|
439
|
-
const SHOW_ORIGINAL: Record<string, string> = {
|
|
440
|
-
en: "Show original", tr: "Orijinali göster", es: "Ver original", de: "Original anzeigen",
|
|
441
|
-
fr: "Afficher l'original", pt: "Ver original", it: "Mostra originale", nl: "Origineel weergeven",
|
|
442
|
-
ru: "Показать оригинал", ja: "原文を表示", zh: "显示原文", ar: "عرض النص الأصلي", hi: "मूल दिखाएँ",
|
|
443
|
-
};
|
|
444
|
-
// The way BACK. It is a toggle, not a door: swapping in place keeps the reader on the feed they
|
|
445
|
-
// were reading, in the language they set — the alternative walks them to the permalink and hands
|
|
446
|
-
// them the whole page in English for the sake of one paragraph.
|
|
447
|
-
const SHOW_TRANSLATION: Record<string, string> = {
|
|
448
|
-
en: "Show translation", tr: "Çeviriyi göster", es: "Ver traducción", de: "Übersetzung anzeigen",
|
|
449
|
-
fr: "Afficher la traduction", pt: "Ver tradução", it: "Mostra traduzione", nl: "Vertaling weergeven",
|
|
450
|
-
ru: "Показать перевод", ja: "翻訳を表示", zh: "显示翻译", ar: "عرض الترجمة", hi: "अनुवाद दिखाएँ",
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
// Relative timestamps are DERIVED from each post/comment's real createdAt (never a stored string),
|
|
454
|
-
// localized to the visitor's language and recomputed at read time — so they age honestly.
|
|
455
|
-
const agoLang = (() => { try { return localStorage.getItem("lang") || document.documentElement.lang || "en"; } catch { return "en"; } })();
|
|
456
|
-
const agoRtf = (() => { try { return new Intl.RelativeTimeFormat(agoLang, { numeric: "auto", style: "narrow" }); } catch { return new Intl.RelativeTimeFormat("en", { numeric: "auto", style: "narrow" }); } })();
|
|
457
|
-
const timeAgo = (iso: string | null): string => {
|
|
458
|
-
if (!iso) return "";
|
|
459
|
-
const t = Date.parse(iso);
|
|
460
|
-
if (Number.isNaN(t)) return "";
|
|
461
|
-
let s = Math.round((Date.now() - t) / 1000);
|
|
462
|
-
if (s < 0) s = 0;
|
|
463
|
-
if (s < 60) return agoRtf.format(-s, "second");
|
|
464
|
-
const m = Math.floor(s / 60); if (m < 60) return agoRtf.format(-m, "minute");
|
|
465
|
-
const h = Math.floor(m / 60); if (h < 24) return agoRtf.format(-h, "hour");
|
|
466
|
-
const d = Math.floor(h / 24); if (d < 7) return agoRtf.format(-d, "day");
|
|
467
|
-
const w = Math.floor(d / 7); if (w < 5) return agoRtf.format(-w, "week");
|
|
468
|
-
const mo = Math.floor(d / 30); if (mo < 12) return agoRtf.format(-mo, "month");
|
|
469
|
-
return agoRtf.format(-Math.floor(d / 365), "year");
|
|
470
|
-
};
|
|
471
|
-
// queries: a persona's name links to the wrong place · a comment links to a host that does not
|
|
472
|
-
// exist · pin.games persona linked to app.zone · where does a commenter's name go
|
|
473
|
-
//
|
|
474
|
-
// WHERE A PERSONA LIVES IS A FACT WE ARE TOLD, NOT ONE WE DERIVE. The API sends `personaUrl`,
|
|
475
|
-
// built from the registry's own `home`, for every persona that has one — and `home` is required
|
|
476
|
-
// by the manifest, so in practice that is all of them.
|
|
477
|
-
//
|
|
478
|
-
// There used to be a fallback here that guessed: justai → justai.pro, app.zone → app.zone, and
|
|
479
|
-
// everything else → `${handle}.app.zone`. It was correct for exactly as long as one apex existed.
|
|
480
|
-
// The day a game persona comments on a feed, that guess sends the reader to a host that has never
|
|
481
|
-
// existed — and it does it silently, in a link, on somebody else's page.
|
|
482
|
-
//
|
|
483
|
-
// So there is no fallback now. If we were not told where a persona lives, its name is rendered
|
|
484
|
-
// WITHOUT a link: a name that goes nowhere is a small disappointment, and a name that goes
|
|
485
|
-
// somewhere wrong is a broken ecosystem seen by a stranger.
|
|
486
|
-
const comment = (c: C, last: boolean): string => {
|
|
487
|
-
const ln = last ? "" : `<span class="ln"></span>`;
|
|
488
|
-
if (c.isPersona) {
|
|
489
|
-
// THE SOCIAL LAYER: personas reply on each other's feeds. A self-reply wears this persona's own
|
|
490
|
-
// face and name; ANOTHER persona wears ITS canonical icon (glyph fallback), its own name, its
|
|
491
|
-
// @handle, and its name links to its own profile. The verified check stays for both.
|
|
492
|
-
const own = c.authorHandle === PERSONA;
|
|
493
|
-
const av = own
|
|
494
|
-
? AV
|
|
495
|
-
: c.authorAvatar
|
|
496
|
-
? `<img class="mk" src="${esc(c.authorAvatar)}" alt="">`
|
|
497
|
-
: `<span style="font-size:16px">${esc(c.authorGlyph || "✦")}</span>`;
|
|
498
|
-
const nm = own ? NAME : esc(c.authorName); // own name is authored markup; an API name is escaped
|
|
499
|
-
const purl = own ? "#profile" : c.personaUrl ? `${c.personaUrl}/#profile` : null;
|
|
500
|
-
const at = own ? PERSONA : c.authorHandle;
|
|
501
|
-
const meta = `${at ? `@${esc(at)} ` : ""}· ${timeAgo(c.createdAt)}`;
|
|
502
|
-
const nameEl = purl ? `<a class="pn" href="${esc(purl)}"><b>${nm}</b></a>` : `<span class="pn"><b>${nm}</b></span>`;
|
|
503
|
-
return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="rav">${av}</span>${ln}</div><div class="cc"><div class="ch">${nameEl}${ck}<span class="muted">${meta}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
|
|
504
|
-
}
|
|
505
|
-
const initial = esc(String(c.authorName || "?").slice(0, 1));
|
|
506
|
-
// A person needs the handle MORE than a persona does — display names collide constantly among
|
|
507
|
-
// people. Guarded on the value: a principal with none shows a gap, never a bare "@".
|
|
508
|
-
const uat = c.authorHandle ? `@${esc(c.authorHandle)} ` : "";
|
|
509
|
-
return `<div class="trow${last ? " last" : ""}"><div class="avc"><span class="uav" style="--c:${esc(c.authorColor || "#888")}">${initial}</span>${ln}</div><div class="cc"><div class="ch"><b>${esc(c.authorName)}</b><span class="muted">${uat}· ${timeAgo(c.createdAt)}</span></div><p class="cb">${esc(c.body)}</p></div></div>`;
|
|
510
|
-
};
|
|
511
|
-
const post = (p: P): string => {
|
|
512
|
-
const cs = p.comments || [];
|
|
513
|
-
const inner = cs.map((c, i) => comment(c, i === cs.length - 1)).join("");
|
|
514
|
-
// Twitter-style permalink: the timestamp links to the post's feed page, and the whole post is
|
|
515
|
-
// clickable (the delegated handler above). n = the post id's last segment ("post:qr:7" → 7).
|
|
516
|
-
const n = String(p.id || "").split(":").pop() || "";
|
|
517
|
-
// queries: clicking a post loses my language · the permalink is English · navigation language
|
|
518
|
-
//
|
|
519
|
-
// EVERY NAVIGATION STAYS IN THE READER'S LANGUAGE — the founder's rule, and it is stronger than
|
|
520
|
-
// "link to the translation when there is one". Set a persona to Italian and EVERY page of it is
|
|
521
|
-
// Italian; a post with no Italian gives the original words with Italian chrome around them, and
|
|
522
|
-
// `/feed/{n}/it` renders exactly that rather than 404ing.
|
|
523
|
-
//
|
|
524
|
-
// The one URL that carries no segment is the post read in the language it was written in: there
|
|
525
|
-
// is no "translation into the original", so the permalink is already that page.
|
|
526
|
-
const orig = p.originalLang || "en";
|
|
527
|
-
const tr = !!p.translated && !!p.originalBody;
|
|
528
|
-
const base = n ? `/feed/${n}` : "";
|
|
529
|
-
const perma = base && LANG !== orig ? `${base}/${LANG}` : base;
|
|
530
|
-
const pt = perma ? `<a class="pt" href="${perma}">${timeAgo(p.createdAt)}</a>` : `<span class="pt">${timeAgo(p.createdAt)}</span>`;
|
|
531
|
-
// The way back, in the language of the person reading it. A real <a> so the delegated
|
|
532
|
-
// click-anywhere handler above lets it through — it returns early on any link.
|
|
533
|
-
// Both texts ride in the markup so the swap is instant and needs no second request. The link
|
|
534
|
-
// keeps a real href — without scripts it still reaches the original's own page.
|
|
535
|
-
const note = tr
|
|
536
|
-
? `<p class="tnote" dir="auto"><span data-tstate>${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}</span><span class="dot">·</span>` +
|
|
537
|
-
`<a href="${base}" data-toggle-original data-original="${esc(p.originalBody)}" data-translation="${esc(p.body)}"` +
|
|
538
|
-
` data-show-original="${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}" data-show-translation="${esc(SHOW_TRANSLATION[LANG] ?? SHOW_TRANSLATION.en)}"` +
|
|
539
|
-
` data-translated="${esc(TRANSLATED[LANG] ?? TRANSLATED.en)}" data-original-label="${esc(orig.toUpperCase())} · ${esc(NAME).replace(/<[^>]*>/g, "")}"` +
|
|
540
|
-
` data-original-lang="${esc(orig)}" data-lang="${esc(p.lang || LANG)}">${esc(SHOW_ORIGINAL[LANG] ?? SHOW_ORIGINAL.en)}</a></p>`
|
|
541
|
-
: "";
|
|
542
|
-
// `dir="auto"` on the body: an Arabic post inside an LTR page — and the reverse — must lay
|
|
543
|
-
// itself out from its own first strong character, not from the page around it.
|
|
544
|
-
return `<article class="post"${perma ? ` data-href="${perma}"` : ""}><div class="trow"><div class="avc"><span class="pa">${AV}</span>${cs.length ? '<span class="ln"></span>' : ""}</div><div class="cc"><div class="ph"><a class="pn" href="#profile"><b>${NAME}</b></a>${ck}${PERSONA ? `<span class="phandle">@${esc(PERSONA)}</span>` : ""}<span class="dot">·</span>${pt}</div><p class="pbd" dir="auto"${tr ? ` lang="${esc(p.lang)}"` : ""}>${esc(p.body)}</p>${note}${pact}</div></div>${inner}</article>`;
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
let done = false;
|
|
548
|
-
const load = async (): Promise<void> => {
|
|
549
|
-
if (done) return;
|
|
550
|
-
done = true;
|
|
551
|
-
try {
|
|
552
|
-
// `lang` rides in the query because it IS the content here — api.justai.pro is CDN'd with the
|
|
553
|
-
// query string in the cache key, so this is one more entry rather than one more origin hit.
|
|
554
|
-
const res = await fetch(
|
|
555
|
-
"https://api.justai.pro/api/feed?persona=" + encodeURIComponent(PERSONA) + "&lang=" + encodeURIComponent(LANG));
|
|
556
|
-
if (!res.ok) return;
|
|
557
|
-
const data = await res.json();
|
|
558
|
-
feed.innerHTML = ((data.posts as P[]) || []).map(post).join("");
|
|
559
|
-
if (scopeAttr) feed.querySelectorAll("*").forEach((el) => el.setAttribute(scopeAttr, ""));
|
|
560
|
-
} catch {
|
|
561
|
-
/* offline → the feed simply stays empty; the rest of the page is unaffected */
|
|
562
|
-
}
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
if ("IntersectionObserver" in window) {
|
|
566
|
-
const io = new IntersectionObserver((es, obs) => {
|
|
567
|
-
if (es.some((e) => e.isIntersecting)) { obs.disconnect(); load(); }
|
|
568
|
-
}, { rootMargin: "400px" });
|
|
569
|
-
io.observe(section);
|
|
570
|
-
} else {
|
|
571
|
-
load();
|
|
572
|
-
}
|
|
573
|
-
})();
|
|
574
|
-
</script>
|
|
348
|
+
<script>
|
|
349
|
+
// The behaviour lives in ./behaviour/posts.ts — see that file for why it is not in here.
|
|
350
|
+
import "./behaviour/posts";
|
|
351
|
+
</script>
|
package/src/Preroll.astro
CHANGED
|
@@ -382,294 +382,7 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
382
382
|
* back the moment they pause. */
|
|
383
383
|
</style>
|
|
384
384
|
|
|
385
|
-
<script>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// discovers UPWARD, so the room has to be standing before anybody can walk into it. `mount` names
|
|
390
|
-
// the guest by its WINDOW — never by what it calls itself, because a guest that announces its own
|
|
391
|
-
// name lets any frame on the page claim to be the game.
|
|
392
|
-
import atrium from "@justai/atrium";
|
|
393
|
-
|
|
394
|
-
const root = document.getElementById("pr");
|
|
395
|
-
if (root) {
|
|
396
|
-
const door = document.getElementById("prDoor") as HTMLElement;
|
|
397
|
-
const stage = document.getElementById("prStage") as HTMLElement;
|
|
398
|
-
const fill = document.getElementById("prFill") as HTMLElement;
|
|
399
|
-
const playBtn = document.getElementById("prPlay") as HTMLButtonElement;
|
|
400
|
-
const label = document.getElementById("prLabel") as HTMLElement;
|
|
401
|
-
|
|
402
|
-
const src = root.dataset.src as string;
|
|
403
|
-
const atriumId = root.dataset.atrium as string;
|
|
404
|
-
|
|
405
|
-
let started = false;
|
|
406
|
-
|
|
407
|
-
// The stage is never switched — it is underneath the whole time, and the door is a cover over
|
|
408
|
-
// it. `open()` is the handover: the cover leaves and what was already drawing there is visible.
|
|
409
|
-
const open = () => { door.hidden = true; };
|
|
410
|
-
|
|
411
|
-
// ── PATIENCE IS MEASURED IN SILENCE, NOT IN SECONDS ──────────────────────────────────────
|
|
412
|
-
//
|
|
413
|
-
// queries: the game was cut off while it was still loading · the preroll gave up too early ·
|
|
414
|
-
// how long does the shell wait · slow connection · the bar vanished mid-load
|
|
415
|
-
//
|
|
416
|
-
// A total time limit is the wrong instrument and it fails in both directions: 20s abandons a
|
|
417
|
-
// real game on a real 3G connection, and 120s leaves a player staring at a dead frame. What
|
|
418
|
-
// separates the two cases is not how long it has taken — it is whether the game is still
|
|
419
|
-
// TALKING. So the clock is reset by every progress report, and only silence runs it down.
|
|
420
|
-
//
|
|
421
|
-
// A game that never speaks at all gets one window's worth from the moment the frame is created,
|
|
422
|
-
// which is the same promise made to a player whose game is simply broken.
|
|
423
|
-
const PATIENCE = 20000;
|
|
424
|
-
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
425
|
-
// Assigned where the promise it resolves is created — a no-op until then, because a progress
|
|
426
|
-
// report cannot arrive before the frame exists and a `bump` that throws would take the bar
|
|
427
|
-
// down with it.
|
|
428
|
-
let bump = () => {};
|
|
429
|
-
|
|
430
|
-
// THE BUTTON IS THE BAR. Its fill layer grows and its word changes; nothing moves and nothing is
|
|
431
|
-
// replaced. A player who pressed something and watches that same thing work never has to ask
|
|
432
|
-
// where their press went.
|
|
433
|
-
const setProgress = (v: unknown) => {
|
|
434
|
-
bump();
|
|
435
|
-
const n = typeof v === "number" ? v : (v as any)?.value;
|
|
436
|
-
if (typeof n !== "number" || !isFinite(n)) return;
|
|
437
|
-
playBtn.removeAttribute("data-indeterminate");
|
|
438
|
-
fill.style.width = Math.max(0, Math.min(1, n)) * 100 + "%";
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const start = async () => {
|
|
442
|
-
if (started) return;
|
|
443
|
-
started = true;
|
|
444
|
-
// Indeterminate until the game says otherwise, and `disabled` is deliberately NOT used: a
|
|
445
|
-
// disabled button greys out and reads as broken, when what is true is that it is working.
|
|
446
|
-
playBtn.setAttribute("data-busy", "");
|
|
447
|
-
playBtn.setAttribute("data-indeterminate", "");
|
|
448
|
-
playBtn.setAttribute("aria-busy", "true");
|
|
449
|
-
label.textContent = playBtn.dataset.loading || label.textContent;
|
|
450
|
-
|
|
451
|
-
const frame = document.createElement("iframe");
|
|
452
|
-
frame.title = stage.dataset.title || "";
|
|
453
|
-
// `allow` carries the gesture forward: the tap that opened this frame is what lets the game
|
|
454
|
-
// play sound at all, and a frame without it starts muted for reasons nobody can see.
|
|
455
|
-
frame.setAttribute("allow", "autoplay; fullscreen");
|
|
456
|
-
frame.setAttribute("referrerpolicy", "same-origin");
|
|
457
|
-
|
|
458
|
-
let bus: any = null;
|
|
459
|
-
try {
|
|
460
|
-
bus = await atrium({
|
|
461
|
-
id: atriumId,
|
|
462
|
-
router: true,
|
|
463
|
-
mount: ({ source }: any) => (source === frame.contentWindow ? "game" : null),
|
|
464
|
-
});
|
|
465
|
-
} catch {
|
|
466
|
-
// atrium failing must not cost the player the game: mount the frame and hand over blind.
|
|
467
|
-
// A game that cannot speak still runs; it simply gets no progress and no early curtain.
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── THE STAGE — v2 of the protocol: who owns attention, and who owns the chrome ─────────
|
|
471
|
-
//
|
|
472
|
-
// queries: stage playing · pause the game from the shell · mood accent · theater mode ·
|
|
473
|
-
// the cuts stay reachable while playing · focus protocol · oyun basladi
|
|
474
|
-
//
|
|
475
|
-
// The game declares STATE and the shell decides what it means — a game never asks for a
|
|
476
|
-
// toolbar to be hidden, it says what the player is doing. Three messages: `stage` and `mood`
|
|
477
|
-
// travel up (the same direction every progress report already travels), `pause` comes back.
|
|
478
|
-
//
|
|
479
|
-
// THE RETURN DIRECTION IS THE SAFETY OF THE WHOLE DESIGN. Whoever grants exclusive attention
|
|
480
|
-
// must be able to take it back — the player reached for the way out, the tab went to the
|
|
481
|
-
// background. A focus handover with no way back does not trap the shell, it traps the PLAYER,
|
|
482
|
-
// and on a phone the only escape from that is closing the tab. So: a game that has not
|
|
483
|
-
// declared protocol 2 in its boot answer is never given the stage. The declaration IS the
|
|
484
|
-
// promise that `pause` is registered, and the shell holds it to that.
|
|
485
|
-
// The declaration is the boot ANSWER, and a game may announce `playing` in the same breath
|
|
486
|
-
// as resolving boot — airdrop does, and its announcement beats its own answer onto the wire.
|
|
487
|
-
// So the stage handler never reads a variable; it AWAITS the declaration, and a message that
|
|
488
|
-
// arrives early is simply held until the shell knows who it is talking to. The watchdog path
|
|
489
|
-
// declares protocol 1, so a silent game's early announcement resolves to a refusal, not a hang.
|
|
490
|
-
let declareProtocol: (v: number) => void = () => {};
|
|
491
|
-
const declared: Promise<number> = new Promise((r) => { declareProtocol = r; });
|
|
492
|
-
if (bus) {
|
|
493
|
-
// Whatever theme the page carried before the stage took it — restored on pause, so a manual
|
|
494
|
-
// data-theme set by anything else survives a round trip through a game.
|
|
495
|
-
let priorTheme: string | undefined;
|
|
496
|
-
|
|
497
|
-
const setStage = (mode: string) => {
|
|
498
|
-
const playing = mode === "playing";
|
|
499
|
-
if (playing) {
|
|
500
|
-
document.documentElement.dataset.stage = "playing";
|
|
501
|
-
// The theater: the persona's own dark theme, switched on. See the style block above.
|
|
502
|
-
if (document.documentElement.dataset.theme !== "dark") {
|
|
503
|
-
priorTheme = document.documentElement.dataset.theme;
|
|
504
|
-
document.documentElement.dataset.theme = "dark";
|
|
505
|
-
}
|
|
506
|
-
} else {
|
|
507
|
-
delete document.documentElement.dataset.stage;
|
|
508
|
-
if (priorTheme === undefined) delete document.documentElement.dataset.theme;
|
|
509
|
-
else document.documentElement.dataset.theme = priorTheme;
|
|
510
|
-
priorTheme = undefined;
|
|
511
|
-
}
|
|
512
|
-
// The other cuts go INERT rather than display:none — the page must not reflow, and the
|
|
513
|
-
// player's scroll position has to survive a pause. inert takes them out of tab order and
|
|
514
|
-
// the accessibility tree both; a screen reader must not keep walking a page the player
|
|
515
|
-
// has left.
|
|
516
|
-
for (const id of ["profile", "posts"]) {
|
|
517
|
-
const el = document.getElementById(id);
|
|
518
|
-
if (el) el.toggleAttribute("inert", playing);
|
|
519
|
-
}
|
|
520
|
-
// The page is locked, so make sure what it is locked ON is the stage.
|
|
521
|
-
if (playing) stage.scrollIntoView({ block: "nearest" });
|
|
522
|
-
// queries: space does nothing · keyboard not reaching the game · input bind · focus
|
|
523
|
-
//
|
|
524
|
-
// AND THE KEYBOARD GOES WITH THE STAGE. The play press happened in THIS document, so this
|
|
525
|
-
// document holds the focus — and every key the player presses lands in the shell while
|
|
526
|
-
// the game hears nothing. The founder, on the space bar: *"bu haldeyken klavyeden space
|
|
527
|
-
// tusunu bile almiyor. tum input game frame'e bind olmali."* Granting the stage IS the
|
|
528
|
-
// input handover, so the frame is focused with it — and again on pause's reversal, so a
|
|
529
|
-
// resumed game does not come back deaf.
|
|
530
|
-
if (playing) frame.focus();
|
|
531
|
-
// The toolbar swaps its icons for the handle on this event — it owns its own row.
|
|
532
|
-
document.dispatchEvent(new CustomEvent("justai:stage", { detail: { mode } }));
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
// ── the mood: meaning, not pixels ─────────────────────────────────────────────────────
|
|
536
|
-
// A few calls a second at most, eased here over ~600ms — never sampled from a framebuffer.
|
|
537
|
-
// It leans on the ONE variable the whole ground is already built from, so the persona's
|
|
538
|
-
// page re-tints without anything being painted: the door's washes, the buttons, the links
|
|
539
|
-
// all read --accent already.
|
|
540
|
-
const moodHost = (document.getElementById("infinite") ?? document.documentElement) as HTMLElement;
|
|
541
|
-
const hex = (c: string) => {
|
|
542
|
-
const m = /^#([0-9a-f]{6})$/i.exec(c.trim());
|
|
543
|
-
return m ? [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)] : null;
|
|
544
|
-
};
|
|
545
|
-
let moodRaf = 0;
|
|
546
|
-
const moodTo = (args: any) => {
|
|
547
|
-
if (typeof args?.energy === "number")
|
|
548
|
-
moodHost.style.setProperty("--stage-energy", String(Math.max(0, Math.min(1, args.energy))));
|
|
549
|
-
const to = typeof args?.accent === "string" ? hex(args.accent) : null;
|
|
550
|
-
if (!to) return;
|
|
551
|
-
const from = hex(getComputedStyle(moodHost).getPropertyValue("--accent")) ?? to;
|
|
552
|
-
cancelAnimationFrame(moodRaf);
|
|
553
|
-
const t0 = performance.now();
|
|
554
|
-
const step = (now: number) => {
|
|
555
|
-
const k = Math.min(1, (now - t0) / 600);
|
|
556
|
-
const e = k * k * (3 - 2 * k);
|
|
557
|
-
const c = from.map((f, i) => Math.round(f + (to[i] - f) * e));
|
|
558
|
-
moodHost.style.setProperty("--accent", `rgb(${c[0]}, ${c[1]}, ${c[2]})`);
|
|
559
|
-
if (k < 1) moodRaf = requestAnimationFrame(step);
|
|
560
|
-
};
|
|
561
|
-
moodRaf = requestAnimationFrame(step);
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
bus.register({
|
|
565
|
-
method: "stage",
|
|
566
|
-
handler: async (a: any) => {
|
|
567
|
-
// No protocol 2, no stage — the rule, not a courtesy. Awaited, not read: see above.
|
|
568
|
-
if ((await declared) < 2) return { granted: false };
|
|
569
|
-
setStage(a?.mode === "playing" ? "playing" : "paused");
|
|
570
|
-
return { granted: true };
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
bus.register({ method: "mood", handler: (a: any) => { moodTo(a); return {}; } });
|
|
574
|
-
|
|
575
|
-
// The shell's asks, all funnelled through one line so every reason travels the same path.
|
|
576
|
-
const askPause = (reason: string) =>
|
|
577
|
-
bus.proxy({ method: "pause", service: "game", args: { reason }, options: { timeout: 3000 } })
|
|
578
|
-
.catch(() => {});
|
|
579
|
-
// The pause handle and the fullscreen control live in the toolbar's own row; it dispatches,
|
|
580
|
-
// this listens. The shell restores its chrome only when the GAME announces `paused` — one
|
|
581
|
-
// source of truth for state, which is the game, always.
|
|
582
|
-
document.addEventListener("justai:pause-request", () => askPause("chrome"));
|
|
583
|
-
document.addEventListener("justai:fullscreen-request", () =>
|
|
584
|
-
(stage as any).requestFullscreen?.().catch(() => {}));
|
|
585
|
-
document.addEventListener("visibilitychange", () => {
|
|
586
|
-
if (document.hidden && document.documentElement.dataset.stage === "playing") askPause("hidden");
|
|
587
|
-
});
|
|
588
|
-
document.addEventListener("keydown", (e) => {
|
|
589
|
-
if (e.key === "Escape" && document.documentElement.dataset.stage === "playing") askPause("chrome");
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const arrived = bus
|
|
594
|
-
? new Promise<void>((resolve) => bus.on("connected", (e: any) => { if (e?.name === "game") resolve(); }))
|
|
595
|
-
: Promise.resolve();
|
|
596
|
-
|
|
597
|
-
stage.appendChild(frame);
|
|
598
|
-
// ── WHAT CROSSES INTO THE FRAME: AN ALLOWLIST, AND A SHORT ONE ──────────────────────────
|
|
599
|
-
//
|
|
600
|
-
// Forwarding the persona's whole query string would be the obvious convenience and it is a
|
|
601
|
-
// privacy hole: a `?data=` envelope, a campaign's utm tags and anything else a person arrived
|
|
602
|
-
// carrying would be handed to a frame that will one day be a third party's deployment. So the
|
|
603
|
-
// shell passes exactly two flags, both of them DEBUG instruments that belong to whoever is
|
|
604
|
-
// looking at the page rather than to whoever built the game: `xray`, the framework's design
|
|
605
|
-
// grid, and `slow`, which asks a game to spread its own loading out far enough to watch.
|
|
606
|
-
// A game that does not understand either simply ignores them.
|
|
607
|
-
const here = new URLSearchParams(location.search);
|
|
608
|
-
const url = new URL(src, location.href);
|
|
609
|
-
for (const flag of ["xray", "slow"]) if (here.has(flag)) url.searchParams.set(flag, here.get(flag) ?? "");
|
|
610
|
-
frame.src = url.pathname + url.search;
|
|
611
|
-
|
|
612
|
-
// ── THE HANDOVER ─────────────────────────────────────────────────────────────────────────
|
|
613
|
-
//
|
|
614
|
-
// ONE CALL CARRIES BOTH. atrium's `proxy` takes a progress callback and resolves when the
|
|
615
|
-
// method returns, so `boot` is the whole protocol: the game reports as it loads and answers
|
|
616
|
-
// when it can draw. No second channel, no polling, and nothing to keep in sync.
|
|
617
|
-
//
|
|
618
|
-
// ** THE EXPLICIT `timeout` IS LOAD-BEARING. ** atrium's default transaction timeout is ONE
|
|
619
|
-
// SECOND — right for a `pick` or an `echo`, and catastrophic here: every game on earth takes
|
|
620
|
-
// longer than a second to load, so `boot` rejected at 1.000s, the catch below swallowed it,
|
|
621
|
-
// and the shell handed over an empty frame while the game was still fetching. It failed the
|
|
622
|
-
// same way every time and said nothing, which is the whole silent-failure genre in one line.
|
|
623
|
-
// Caught by the spec in harness/tests/behavior.spec.mjs, which asked the bar what it read.
|
|
624
|
-
//
|
|
625
|
-
// The number is deliberately far beyond any real load: the POLICY is the watchdog above, and
|
|
626
|
-
// two clocks racing to enforce one promise means the promise is whichever one you forgot.
|
|
627
|
-
const boot = (async () => {
|
|
628
|
-
if (!bus) return {};
|
|
629
|
-
await arrived;
|
|
630
|
-
return await bus.proxy({
|
|
631
|
-
method: "boot",
|
|
632
|
-
service: "game",
|
|
633
|
-
options: { timeout: 600000, callback: (e: any) => setProgress(e?.progress ?? e?.value ?? e) },
|
|
634
|
-
});
|
|
635
|
-
})();
|
|
636
|
-
|
|
637
|
-
// The watchdog is the other half of the promise made to the player: whatever is wrong in
|
|
638
|
-
// there, they are better off seeing it than seeing us wait for it.
|
|
639
|
-
const giveUp = new Promise<any>((resolve) => {
|
|
640
|
-
bump = () => {
|
|
641
|
-
if (watchdog) clearTimeout(watchdog);
|
|
642
|
-
watchdog = setTimeout(() => resolve({ lateBy: "silence" }), PATIENCE);
|
|
643
|
-
};
|
|
644
|
-
bump();
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
// A SWALLOWED REASON IS THE FAILURE ITSELF. The frame is handed over either way — a player must
|
|
648
|
-
// not lose the game to a broken handshake — but the catch SAYS what went wrong, because the
|
|
649
|
-
// symptom on the other side of it is "the wait ended early" and nothing else. This cut's own
|
|
650
|
-
// one-second bug lived inside an empty catch and was invisible until a spec read the bar.
|
|
651
|
-
const answer: any = await Promise.race([
|
|
652
|
-
boot.catch((e: any) => {
|
|
653
|
-
console.warn("[preroll] the game's boot did not complete:", e?.message || e);
|
|
654
|
-
return {};
|
|
655
|
-
}),
|
|
656
|
-
giveUp,
|
|
657
|
-
]);
|
|
658
|
-
if (watchdog) clearTimeout(watchdog);
|
|
659
|
-
|
|
660
|
-
// The boot answer carries the game's protocol version, and version 2 is the declaration
|
|
661
|
-
// that `pause` is registered and will be honoured. Resolved before open(), so a game
|
|
662
|
-
// announcing `playing` in its very first frame — or a breath before its own answer — is
|
|
663
|
-
// already trusted by the time the door goes up.
|
|
664
|
-
declareProtocol(typeof answer?.protocol === "number" ? answer.protocol : 1);
|
|
665
|
-
|
|
666
|
-
open();
|
|
667
|
-
// `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
|
|
668
|
-
// is what a console does, where the logo animation IS the loading screen. Nothing else here
|
|
669
|
-
// changes; the difference is only how long this cut keeps the door shut.
|
|
670
|
-
if (answer?.curtain) root.dataset.curtain = "";
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
playBtn.addEventListener("click", start, { once: true });
|
|
674
|
-
}
|
|
675
|
-
</script>
|
|
385
|
+
<script>
|
|
386
|
+
// The behaviour lives in ./behaviour/preroll.ts — see that file for why it is not in here.
|
|
387
|
+
import "./behaviour/preroll";
|
|
388
|
+
</script>
|
package/src/Segmented.astro
CHANGED
|
@@ -165,60 +165,7 @@ const on = value && options.some((o) => o.value === value) ? value : options[0]?
|
|
|
165
165
|
}
|
|
166
166
|
</style>
|
|
167
167
|
|
|
168
|
-
<script>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// chosen option and the arrows move within the group. Leaving every option at `tabIndex 0` is the
|
|
173
|
-
// common mistake and it is invisible to anyone using a mouse — a person on a keyboard suddenly has
|
|
174
|
-
// to press Tab three times to get past a control they were not using.
|
|
175
|
-
//
|
|
176
|
-
// Lifted from qr, which had it right and had it alone.
|
|
177
|
-
const wire = (root: HTMLElement) => {
|
|
178
|
-
const items = () => Array.from(root.querySelectorAll<HTMLElement>('[role="radio"]'));
|
|
179
|
-
|
|
180
|
-
const set = (el: HTMLElement, focus?: boolean, quiet?: boolean) => {
|
|
181
|
-
if (el.getAttribute("aria-disabled") === "true") return;
|
|
182
|
-
const next = el.dataset.value!;
|
|
183
|
-
if (root.dataset.value === next) { if (focus) el.focus(); return; }
|
|
184
|
-
for (const r of items()) {
|
|
185
|
-
const chosen = r === el;
|
|
186
|
-
r.setAttribute("aria-checked", String(chosen));
|
|
187
|
-
r.tabIndex = chosen ? 0 : -1;
|
|
188
|
-
}
|
|
189
|
-
root.dataset.value = next;
|
|
190
|
-
if (focus) el.focus();
|
|
191
|
-
// Setting `.value` from code does NOT re-announce — a caller that just said what it wants does
|
|
192
|
-
// not need to be told, and a listener that reacts by setting the value would loop.
|
|
193
|
-
if (!quiet) root.dispatchEvent(new CustomEvent("change", { detail: { value: next }, bubbles: true }));
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
for (const el of items()) el.addEventListener("click", () => set(el, true));
|
|
197
|
-
|
|
198
|
-
root.addEventListener("keydown", (e) => {
|
|
199
|
-
const key = (e as KeyboardEvent).key;
|
|
200
|
-
const all = items().filter((r) => r.getAttribute("aria-disabled") !== "true");
|
|
201
|
-
const at = all.findIndex((r) => r.getAttribute("aria-checked") === "true");
|
|
202
|
-
let to = -1;
|
|
203
|
-
if (key === "ArrowRight" || key === "ArrowDown") to = (at + 1) % all.length;
|
|
204
|
-
else if (key === "ArrowLeft" || key === "ArrowUp") to = (at - 1 + all.length) % all.length;
|
|
205
|
-
else if (key === "Home") to = 0;
|
|
206
|
-
else if (key === "End") to = all.length - 1;
|
|
207
|
-
if (to < 0 || !all[to]) return;
|
|
208
|
-
e.preventDefault();
|
|
209
|
-
set(all[to], true);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// The one thing a caller needs beyond listening: saying what it wants.
|
|
213
|
-
Object.defineProperty(root, "value", {
|
|
214
|
-
get: () => root.dataset.value,
|
|
215
|
-
set: (v: string) => {
|
|
216
|
-
const el = items().find((r) => r.dataset.value === v);
|
|
217
|
-
if (el) set(el, false, true);
|
|
218
|
-
},
|
|
219
|
-
configurable: true,
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
for (const root of document.querySelectorAll<HTMLElement>(".jseg")) wire(root);
|
|
224
|
-
</script>
|
|
168
|
+
<script>
|
|
169
|
+
// The behaviour lives in ./behaviour/segmented.ts — see that file for why it is not in here.
|
|
170
|
+
import "./behaviour/segmented";
|
|
171
|
+
</script>
|