@justai/cuts 0.128.0 → 0.129.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 +3 -3
- package/src/Preroll.astro +133 -0
- package/src/Toolbar.astro +59 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A persona's named parts
|
|
3
|
+
"version": "0.129.0",
|
|
4
|
+
"description": "A persona's named parts \u2014 the page shell that holds them, and the cuts themselves.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -45,4 +45,4 @@
|
|
|
45
45
|
"@justai/ui": "^0.21.1",
|
|
46
46
|
"@justai/atrium": "^0.1.9"
|
|
47
47
|
}
|
|
48
|
-
}
|
|
48
|
+
}
|
package/src/Preroll.astro
CHANGED
|
@@ -344,6 +344,35 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
344
344
|
}
|
|
345
345
|
</style>
|
|
346
346
|
|
|
347
|
+
<style is:global>
|
|
348
|
+
/* ── THE STAGE, v2 of the game–shell protocol: what the page does while a game is PLAYING ──────
|
|
349
|
+
* queries: theater mode · the room dims · cuts inert while playing · the page scrolls under the
|
|
350
|
+
* game · oyun basladiginda kararma · stage protocol css
|
|
351
|
+
*
|
|
352
|
+
* Global on purpose: everything here styles elements OUTSIDE this component — the page, the other
|
|
353
|
+
* cuts, the dim the script hangs on <body>. The attribute lives on <html> so one selector reaches
|
|
354
|
+
* all of it, and so a persona (or a spec) can read the whole state in one place.
|
|
355
|
+
*
|
|
356
|
+
* THE DIM IS A LAYER, NOT A THEME. The visitor chose their theme; a cinema does not repaint its
|
|
357
|
+
* walls, it lowers the lights. The layer covers the room and the room is everything EXCEPT the
|
|
358
|
+
* stage: the kernel rises above it, the toolbar's slot (z 40) stays above everything, and the
|
|
359
|
+
* profile and posts cuts stay put underneath it. A dim that covered the frame would dim the GAME —
|
|
360
|
+
* the exact opposite of what it is for, and invisible in review because it reads as a washed-out
|
|
361
|
+
* renderer rather than as a z-order mistake.
|
|
362
|
+
*
|
|
363
|
+
* Why the dim hangs on <body> rather than living in this component's markup: #pr is a size
|
|
364
|
+
* container, and layout containment makes it the containing block for FIXED descendants too — a
|
|
365
|
+
* fixed overlay written inside it would be clipped to the frame it is meant to surround. */
|
|
366
|
+
html[data-stage="playing"] { overflow: hidden; }
|
|
367
|
+
html[data-stage="playing"] .cut.kernel { position: relative; z-index: 35; }
|
|
368
|
+
.pr-room {
|
|
369
|
+
position: fixed; inset: 0; z-index: 30;
|
|
370
|
+
background: #05070d; opacity: 0; pointer-events: none;
|
|
371
|
+
transition: opacity 0.5s var(--ease-out, ease-out);
|
|
372
|
+
}
|
|
373
|
+
html[data-stage="playing"] .pr-room { opacity: 0.55; }
|
|
374
|
+
</style>
|
|
375
|
+
|
|
347
376
|
<script>
|
|
348
377
|
// queries: preroll script · the game never connects · atrium router game · boot · curtain
|
|
349
378
|
//
|
|
@@ -429,6 +458,105 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
429
458
|
// A game that cannot speak still runs; it simply gets no progress and no early curtain.
|
|
430
459
|
}
|
|
431
460
|
|
|
461
|
+
// ── THE STAGE — v2 of the protocol: who owns attention, and who owns the chrome ─────────
|
|
462
|
+
//
|
|
463
|
+
// queries: stage playing · pause the game from the shell · mood accent · theater mode ·
|
|
464
|
+
// the cuts stay reachable while playing · focus protocol · oyun basladi
|
|
465
|
+
//
|
|
466
|
+
// The game declares STATE and the shell decides what it means — a game never asks for a
|
|
467
|
+
// toolbar to be hidden, it says what the player is doing. Three messages: `stage` and `mood`
|
|
468
|
+
// travel up (the same direction every progress report already travels), `pause` comes back.
|
|
469
|
+
//
|
|
470
|
+
// THE RETURN DIRECTION IS THE SAFETY OF THE WHOLE DESIGN. Whoever grants exclusive attention
|
|
471
|
+
// must be able to take it back — the player reached for the way out, the tab went to the
|
|
472
|
+
// background. A focus handover with no way back does not trap the shell, it traps the PLAYER,
|
|
473
|
+
// and on a phone the only escape from that is closing the tab. So: a game that has not
|
|
474
|
+
// declared protocol 2 in its boot answer is never given the stage. The declaration IS the
|
|
475
|
+
// promise that `pause` is registered, and the shell holds it to that.
|
|
476
|
+
let protocol = 1;
|
|
477
|
+
if (bus) {
|
|
478
|
+
// The room's lights, hung on <body> — see the global style block for why not in here.
|
|
479
|
+
const room = document.createElement("div");
|
|
480
|
+
room.className = "pr-room";
|
|
481
|
+
room.setAttribute("aria-hidden", "true");
|
|
482
|
+
document.body.appendChild(room);
|
|
483
|
+
|
|
484
|
+
const setStage = (mode: string) => {
|
|
485
|
+
const playing = mode === "playing";
|
|
486
|
+
if (playing) document.documentElement.dataset.stage = "playing";
|
|
487
|
+
else delete document.documentElement.dataset.stage;
|
|
488
|
+
// The other cuts go INERT rather than display:none — the page must not reflow, and the
|
|
489
|
+
// player's scroll position has to survive a pause. inert takes them out of tab order and
|
|
490
|
+
// the accessibility tree both; a screen reader must not keep walking a page the player
|
|
491
|
+
// has left.
|
|
492
|
+
for (const id of ["profile", "posts"]) {
|
|
493
|
+
const el = document.getElementById(id);
|
|
494
|
+
if (el) el.toggleAttribute("inert", playing);
|
|
495
|
+
}
|
|
496
|
+
// The page is locked, so make sure what it is locked ON is the stage.
|
|
497
|
+
if (playing) stage.scrollIntoView({ block: "nearest" });
|
|
498
|
+
// The toolbar swaps its icons for the handle on this event — it owns its own row.
|
|
499
|
+
document.dispatchEvent(new CustomEvent("justai:stage", { detail: { mode } }));
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// ── the mood: meaning, not pixels ─────────────────────────────────────────────────────
|
|
503
|
+
// A few calls a second at most, eased here over ~600ms — never sampled from a framebuffer.
|
|
504
|
+
// It leans on the ONE variable the whole ground is already built from, so the persona's
|
|
505
|
+
// page re-tints without anything being painted: the door's washes, the buttons, the links
|
|
506
|
+
// all read --accent already.
|
|
507
|
+
const moodHost = (document.getElementById("infinite") ?? document.documentElement) as HTMLElement;
|
|
508
|
+
const hex = (c: string) => {
|
|
509
|
+
const m = /^#([0-9a-f]{6})$/i.exec(c.trim());
|
|
510
|
+
return m ? [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)] : null;
|
|
511
|
+
};
|
|
512
|
+
let moodRaf = 0;
|
|
513
|
+
const moodTo = (args: any) => {
|
|
514
|
+
if (typeof args?.energy === "number")
|
|
515
|
+
moodHost.style.setProperty("--stage-energy", String(Math.max(0, Math.min(1, args.energy))));
|
|
516
|
+
const to = typeof args?.accent === "string" ? hex(args.accent) : null;
|
|
517
|
+
if (!to) return;
|
|
518
|
+
const from = hex(getComputedStyle(moodHost).getPropertyValue("--accent")) ?? to;
|
|
519
|
+
cancelAnimationFrame(moodRaf);
|
|
520
|
+
const t0 = performance.now();
|
|
521
|
+
const step = (now: number) => {
|
|
522
|
+
const k = Math.min(1, (now - t0) / 600);
|
|
523
|
+
const e = k * k * (3 - 2 * k);
|
|
524
|
+
const c = from.map((f, i) => Math.round(f + (to[i] - f) * e));
|
|
525
|
+
moodHost.style.setProperty("--accent", `rgb(${c[0]}, ${c[1]}, ${c[2]})`);
|
|
526
|
+
if (k < 1) moodRaf = requestAnimationFrame(step);
|
|
527
|
+
};
|
|
528
|
+
moodRaf = requestAnimationFrame(step);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
bus.register({
|
|
532
|
+
method: "stage",
|
|
533
|
+
handler: (a: any) => {
|
|
534
|
+
// No protocol 2, no stage — the rule, not a courtesy. See the boot answer below.
|
|
535
|
+
if (protocol < 2) return { granted: false };
|
|
536
|
+
setStage(a?.mode === "playing" ? "playing" : "paused");
|
|
537
|
+
return { granted: true };
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
bus.register({ method: "mood", handler: (a: any) => { moodTo(a); return {}; } });
|
|
541
|
+
|
|
542
|
+
// The shell's asks, all funnelled through one line so every reason travels the same path.
|
|
543
|
+
const askPause = (reason: string) =>
|
|
544
|
+
bus.proxy({ method: "pause", service: "game", args: { reason }, options: { timeout: 3000 } })
|
|
545
|
+
.catch(() => {});
|
|
546
|
+
// The pause handle and the fullscreen control live in the toolbar's own row; it dispatches,
|
|
547
|
+
// this listens. The shell restores its chrome only when the GAME announces `paused` — one
|
|
548
|
+
// source of truth for state, which is the game, always.
|
|
549
|
+
document.addEventListener("justai:pause-request", () => askPause("chrome"));
|
|
550
|
+
document.addEventListener("justai:fullscreen-request", () =>
|
|
551
|
+
(stage as any).requestFullscreen?.().catch(() => {}));
|
|
552
|
+
document.addEventListener("visibilitychange", () => {
|
|
553
|
+
if (document.hidden && document.documentElement.dataset.stage === "playing") askPause("hidden");
|
|
554
|
+
});
|
|
555
|
+
document.addEventListener("keydown", (e) => {
|
|
556
|
+
if (e.key === "Escape" && document.documentElement.dataset.stage === "playing") askPause("chrome");
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
432
560
|
const arrived = bus
|
|
433
561
|
? new Promise<void>((resolve) => bus.on("connected", (e: any) => { if (e?.name === "game") resolve(); }))
|
|
434
562
|
: Promise.resolve();
|
|
@@ -496,6 +624,11 @@ const safeId = "game-" + (id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/
|
|
|
496
624
|
]);
|
|
497
625
|
if (watchdog) clearTimeout(watchdog);
|
|
498
626
|
|
|
627
|
+
// The boot answer carries the game's protocol version, and version 2 is the declaration
|
|
628
|
+
// that `pause` is registered and will be honoured. Read before open(), so a game announcing
|
|
629
|
+
// `playing` in its very first frame is already trusted.
|
|
630
|
+
protocol = typeof answer?.protocol === "number" ? answer.protocol : 1;
|
|
631
|
+
|
|
499
632
|
open();
|
|
500
633
|
// `curtain: true` is the game saying "hand over early, I will cover the rest myself" — which
|
|
501
634
|
// is what a console does, where the logo animation IS the loading screen. Nothing else here
|
package/src/Toolbar.astro
CHANGED
|
@@ -156,6 +156,8 @@ import BackToZone from "./BackToZone.astro";
|
|
|
156
156
|
const {
|
|
157
157
|
sectionsLabel, profileLabel, kernelLabel, postsLabel,
|
|
158
158
|
files = true, filesLabel = "Files", kernelTitle, postsTitle, avatar = "/icon.svg", parent,
|
|
159
|
+
pauseLabel = "Pause",
|
|
160
|
+
fullscreenLabel = "Fullscreen",
|
|
159
161
|
} = Astro.props;
|
|
160
162
|
// A child's row answers UP to its parent, not out to the zone, and has no stream of its own — so
|
|
161
163
|
// both default off the moment `parent` is given, rather than each consumer remembering to say so.
|
|
@@ -261,6 +263,29 @@ import AuthMount from "./AuthMount.astro";
|
|
|
261
263
|
page (justai.pro carries app.zone's mark here). Keeping it out of <nav aria-label=sections>
|
|
262
264
|
is the a11y half of the reason it is its own slot. -->
|
|
263
265
|
<slot name="leftExtra" />
|
|
266
|
+
<!-- ── THE HANDLE — the stage protocol's one promise about this row ─────────────────────
|
|
267
|
+
queries: pause button in the toolbar · the way back while playing · stage controls ·
|
|
268
|
+
fullscreen button · oyun icinde geri donus
|
|
269
|
+
|
|
270
|
+
THE SHELL DOES NOT DISAPPEAR. IT WITHDRAWS TO A SINGLE HANDLE, AND THAT HANDLE IS THE
|
|
271
|
+
WAY BACK. While a game is playing the section nav fades and goes inert, and these two
|
|
272
|
+
controls appear in the same row, in the same place — so the way back is where the way
|
|
273
|
+
around used to be. Hiding everything is a trap; hiding everything except the way out is
|
|
274
|
+
a stage.
|
|
275
|
+
|
|
276
|
+
The buttons only DISPATCH. The Preroll owns the bus, so it listens and asks the game;
|
|
277
|
+
the chrome is restored when the GAME announces paused, never optimistically — state has
|
|
278
|
+
one source and it is the game. Fullscreen is feature-detected in the script below:
|
|
279
|
+
absent rather than dead where the platform does not implement it, which is every
|
|
280
|
+
iPhone. -->
|
|
281
|
+
<span class="tb-stage" id="tbStage">
|
|
282
|
+
<button class="tb-nav tb-pausebtn" id="tbPause" type="button" aria-label={pauseLabel} title={pauseLabel}>
|
|
283
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5v14M15 5v14"/></svg>
|
|
284
|
+
</button>
|
|
285
|
+
<button class="tb-nav tb-fsbtn" id="tbFullscreen" type="button" aria-label={fullscreenLabel} title={fullscreenLabel} hidden>
|
|
286
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
|
287
|
+
</button>
|
|
288
|
+
</span>
|
|
264
289
|
</div>
|
|
265
290
|
<div class="tb-right">
|
|
266
291
|
<slot name="toolbarRight" />
|
|
@@ -439,6 +464,19 @@ import AuthMount from "./AuthMount.astro";
|
|
|
439
464
|
.tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
440
465
|
.tb-nav-group { display: flex; align-items: center; gap: 2px; }
|
|
441
466
|
.tb-right { display: flex; align-items: center; min-width: 0; }
|
|
467
|
+
|
|
468
|
+
/* ── the withdraw: what this row does while a game is playing ───────────────────────────────
|
|
469
|
+
* Opacity and pointer-events here; the INERT that takes them out of the tab order is set in the
|
|
470
|
+
* script, because CSS cannot. The transition matters more than it looks: chrome that snaps away
|
|
471
|
+
* reads as breaking, chrome that withdraws reads as dimming the house lights. */
|
|
472
|
+
.tb-stage { display: none; align-items: center; gap: 2px; }
|
|
473
|
+
:global(html[data-stage="playing"]) .tb-stage { display: flex; }
|
|
474
|
+
:global(html[data-stage="playing"]) .tb-nav-group,
|
|
475
|
+
:global(html[data-stage="playing"]) .tb-right,
|
|
476
|
+
:global(html[data-stage="playing"]) .tb-title {
|
|
477
|
+
opacity: 0.16; pointer-events: none;
|
|
478
|
+
transition: opacity 0.4s var(--ease-out);
|
|
479
|
+
}
|
|
442
480
|
/* icon-only section nav — current cut highlighted */
|
|
443
481
|
.tb-nav {
|
|
444
482
|
display: grid; place-items: center; width: 38px; height: 36px;
|
|
@@ -604,6 +642,27 @@ import AuthMount from "./AuthMount.astro";
|
|
|
604
642
|
// Both or neither — see the props above. When both are set the strip names the SECTIONS; core's
|
|
605
643
|
// default, which names the persona and its handle, is left entirely alone otherwise. Nothing here
|
|
606
644
|
// reproduces that default, which is the point: `.pname` and `.handle` stay core's business.
|
|
645
|
+
// ── the stage handle — see the markup's comment for the design ────────────────────────────
|
|
646
|
+
// queries: pause button does nothing · fullscreen hidden on iphone · toolbar during a game
|
|
647
|
+
const tbStage = document.getElementById("tbStage");
|
|
648
|
+
if (tbStage) {
|
|
649
|
+
const fsBtn = document.getElementById("tbFullscreen") as HTMLButtonElement | null;
|
|
650
|
+
// Feature-detected, absent rather than dead: Element.requestFullscreen does not exist in
|
|
651
|
+
// Safari on iPhone (iPad has it), and a button that throws is worse than no button.
|
|
652
|
+
if (fsBtn && "requestFullscreen" in document.documentElement) fsBtn.hidden = false;
|
|
653
|
+
document.getElementById("tbPause")?.addEventListener("click", () =>
|
|
654
|
+
document.dispatchEvent(new CustomEvent("justai:pause-request")));
|
|
655
|
+
fsBtn?.addEventListener("click", () =>
|
|
656
|
+
document.dispatchEvent(new CustomEvent("justai:fullscreen-request")));
|
|
657
|
+
// The nav fades by CSS; inert is what CSS cannot do — out of the tab order and the
|
|
658
|
+
// accessibility tree, so a keyboard or a screen reader is not walking chrome the player left.
|
|
659
|
+
document.addEventListener("justai:stage", (e: any) => {
|
|
660
|
+
const playing = e?.detail?.mode === "playing";
|
|
661
|
+
document.querySelector(".tb-nav-group")?.toggleAttribute("inert", playing);
|
|
662
|
+
document.querySelector(".tb-right")?.toggleAttribute("inert", playing);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
607
666
|
const bar = document.querySelector(".toolbar") as HTMLElement | null;
|
|
608
667
|
const kt = bar?.dataset.kernelTitle;
|
|
609
668
|
const pt = bar?.dataset.postsTitle;
|