@justai/cuts 0.127.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.127.0",
4
- "description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
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
+ }
@@ -420,11 +420,72 @@ const { id, title = "Choose a file", src = "/files" } = Astro.props;
420
420
  };
421
421
  }
422
422
 
423
+ // queries: write a file without asking · FilePicker write · a persona's own cached asset ·
424
+ // keep a dependency a project points at · write vs save · save asks and I do not want
425
+ // to ask · store a file the person did not make · project dependency from outside files
426
+ //
427
+ // THE SECOND VERB THAT ASKS NOBODY, AND THE ONE THAT WAS MISSING.
428
+ //
429
+ // `save` is deliberately not a friendlier `write`. The kernel's own corpus states the split and
430
+ // it is not about permission — both write the same origin under the same policy:
431
+ //
432
+ // `write` is arithmetic and keeps the PERSONA's things — a cached asset, a draft it will
433
+ // reopen itself. `save` is an interaction and hands a person THEIRS, because the two questions
434
+ // that make a saved document a document — what is it called, where does it go — are the
435
+ // person's to answer.
436
+ //
437
+ // The service has had `write` since the beginning. This door did not expose it, and for fifteen
438
+ // personas that was invisible, because the only project dependency anybody had was `qr`'s logo —
439
+ // which arrives through `pick`, so it is already in files and already has an id.
440
+ //
441
+ // `3dviewer` is the first persona whose dependency comes from OUTSIDE: a model dragged off a
442
+ // desktop. Its project file must point at that model, so the model needs a home — and it is not
443
+ // a document the person made here, it is the asset this persona will reopen itself. Exactly the
444
+ // case the paragraph above describes, with no verb to express it. The persona was left choosing
445
+ // between a doctrine it could not follow and a second Save sheet asking somebody to name a file
446
+ // they did not create.
447
+ //
448
+ // ADDITIVE, and that matters: nothing that exists behaves differently. The policy still answers
449
+ // "who is asking" — same-origin only, exactly as `read` does — so this widens no door.
450
+ //
451
+ // It returns the NODE, because a caller writing a project dependency needs the `id`: a path is
452
+ // what a person renames and an id is what a thing IS.
453
+ async function write(
454
+ path: string,
455
+ bytes: ArrayBuffer | Uint8Array,
456
+ opts: { mime?: string; keepVersions?: number } = {},
457
+ ): Promise<{ id: string; name: string; path: string } | null> {
458
+ const bus = await router();
459
+ ensureFrame();
460
+ const body = bytes instanceof Uint8Array
461
+ ? (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
462
+ ? bytes.buffer
463
+ : bytes.slice().buffer)
464
+ : bytes;
465
+ const node = await arrived!
466
+ .then(() => bus.proxy({
467
+ service: "files",
468
+ method: "write",
469
+ args: { path, bytes: body, mime: opts.mime, keepVersions: opts.keepVersions },
470
+ // The same 30s ceiling `read` uses. A model is tens of megabytes and a phone's storage is
471
+ // not fast; the default would time out on exactly the files this verb exists for.
472
+ options: { timeout: 30000 },
473
+ }))
474
+ .catch(() => null);
475
+ // A quota that is full, or a path the kernel refused. `null` rather than a throw, for the same
476
+ // reason `read` returns null: a caller must be able to carry on and say so.
477
+ if (!node) return null;
478
+ const n = node as { id: string; name: string };
479
+ return { id: n.id, name: n.name, path };
480
+ }
481
+
423
482
  (host as any).pick = pick;
424
483
  (sheet as any).pick = pick;
425
484
  (host as any).save = save;
426
485
  (sheet as any).save = save;
427
486
  (host as any).read = read;
428
487
  (sheet as any).read = read;
488
+ (host as any).write = write;
489
+ (sheet as any).write = write;
429
490
  }
430
491
  </script>
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;