@justai/cuts 0.133.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Toolbar.astro CHANGED
@@ -286,382 +286,308 @@ import AuthMount from "./AuthMount.astro";
286
286
  </div>
287
287
 
288
288
  <style>
289
- /* ---- app toolbar — sticky to the top of the active region (kernel + posts); it never
290
- enters the Profile (it lives inside .below). Flat / knife-cut: a left group and a
291
- right group, no rounded pills. ---- */
292
- /* queries: tb-slot · why is the toolbar wrapped · the bar changes height · the kernel is 26px
293
- * short · a band of the feed at the bottom · the toolbar reserves a box · sticky flow
294
- *
295
- * ── THE SLOT IS WHAT FLOW SEES; THE BAR IS WHAT THE EYE SEES ──────────────────────────────
296
- *
297
- * `position: sticky` NEVER LEAVES FLOW. It reserves its full border box and paints inside it — so
298
- * the bar's LIVE height positioned everything under it (52 with the title strip shut, 78 open)
299
- * while `--shell-top` was deliberately frozen at the open height so that nothing would sample the
300
- * 0.35s animation. The HEIGHT stopped sampling it. THE POSITION NEVER DID. Two numbers that must
301
- * be equal, written a hundred lines apart in two packages, and their difference is a band of the
302
- * next section above the fold: measured on the live persona with the scroll untouched, the kernel
303
- * sat 52px below `#below` instead of 78 and 25.81px of the feed was on screen.
304
- *
305
- * The wrapper separates them. The SLOT is sticky and its box is `--shell-top`; the BAR is static
306
- * inside it and may be any height it likes. Nothing above the kernel changes size any more, so
307
- * there is nothing for an engine to compensate for — which is also why only a device ever showed
308
- * it: Blink scroll-anchors the step away and WebKit has no scroll anchoring. */
309
- .tb-slot { position: sticky; top: 0; z-index: 40; display: flow-root; }
310
- /* queries: the title strip moves the fold · the page drifts after it lands · a band of posts under
311
- * the game · tb-slot min-height · the slot follows the bar · the strip pushes the page down
312
- *
313
- * ── THE BOX IS THE SLOT'S OWN, SO IT TRAVELS WITH THE SLOT ─────────────────────────────────────
314
- *
315
- * `position: sticky` never leaves flow: it reserves its full border box, so whatever the bar's LIVE
316
- * height is at any instant, that is where everything under it sits. The strip opens and closes over
317
- * 0.35s inside that box — so without a reserved height the shell breathes 26px and the whole
318
- * document below it breathes with it. That is the drift the founder lost days to.
319
- *
320
- * IT LIVED IN `@justai/ui`'s base.css UNTIL 2026-08-06, and measured on pin.games that was the same
321
- * as not existing: a persona that never imports base.css got the slot's structure from here and its
322
- * BOX from a file it does not load, so the slot collapsed to the bar and posts moved 26px between
323
- * the kernel and the profile. The first law again — a component brings its own ground — and the
324
- * slot is this component's element, so its box is this component's business. The rule that keeps
325
- * the whole strip safe cannot be the one rule that lives somewhere the strip does not.
326
- *
327
- * AND THE RESERVED BAND WEARS THE BAR'S OWN MATERIAL, not the room's. A slot taller than its bar is
328
- * 26px of SOMETHING under the shelf whenever the strip is shut — the state at the profile, on
329
- * screen, at rest. Painted in the persona's ground it was a hard black band between a light profile
330
- * and a light kernel: the founder photographed it within a minute. It is the BAR'S box, so it takes
331
- * the bar's fill, bleeding by the same `--edge` expression so the two cannot leave slivers. */
332
- .tb-slot { min-height: var(--shell-top, 78px); }
333
- .tb-slot::before {
334
- content: "";
335
- position: absolute;
336
- z-index: -1;
337
- inset-block: 0;
338
- inset-inline:
339
- calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left)))
340
- calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
341
- background: var(--toolbar-bg, #1e202a);
342
- }
343
- /* The shelf's fill has two branches and so must its box: `--toolbar-bg` is one token but its
344
- FALLBACK differs by scheme, and a band carrying only the dark default is a black strip on a light
345
- page — which is exactly what the founder photographed a minute after the first attempt. */
346
- @media (prefers-color-scheme: light) {
347
- /* Guarded like every light fork in the fleet: data-theme="dark" wins over the media query. */
348
- :global(html:not([data-theme="dark"])) .tb-slot::before { background: var(--toolbar-bg, #fbfcfe); }
349
- }
350
- .toolbar {
351
- /* position, top and z-index live on .tb-slot above — this element is static inside it now. */
352
- display: flex; flex-direction: column;
353
- /* full-bleed: the bar (frosted bg + hairline) touches the contained-view edges; the icons stay
354
- aligned with the body via matching inline padding. --edge is the persona's page inline padding
355
- (16px default; a persona that pads its body differently — e.g. time at 20px — sets --edge so the
356
- bar bleeds to the SAME edge as its content instead of a hardcoded 16px). */
357
- margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
358
- padding-block: max(8px, env(safe-area-inset-top)) 8px;
359
- padding-inline: max(var(--edge, 16px), env(safe-area-inset-left)) max(var(--edge, 16px), env(safe-area-inset-right));
360
-
361
- /* ── THE BAR IS A SHELF, NOT A TINT ──────────────────────────────────────────────────────
289
+ /* Scoped by Astro's hash AND placed in the cascade: a persona overriding this never has to
290
+ out-specify it. See @justai/ui/tokens.css for the order, and for what deliberately stays out. */
291
+ @layer justai.components {
292
+ /* ---- app toolbar — sticky to the top of the active region (kernel + posts); it never
293
+ enters the Profile (it lives inside .below). Flat / knife-cut: a left group and a
294
+ right group, no rounded pills. ---- */
295
+ /* queries: tb-slot · why is the toolbar wrapped · the bar changes height · the kernel is 26px
296
+ * short · a band of the feed at the bottom · the toolbar reserves a box · sticky flow
362
297
  *
363
- * queries: the toolbar reads as a black strip · why is the bar darker in dark than in light ·
364
- * the bar looks flat against the column · toolbar material · --toolbar-bg
298
+ * ── THE SLOT IS WHAT FLOW SEES; THE BAR IS WHAT THE EYE SEES ──────────────────────────────
365
299
  *
366
- * It used to derive its fill from `--bg` — `color-mix(--bg 70%, transparent)` under a blur.
367
- * One declaration, but it meant two different things: `--bg` is a near-white in light and #000
368
- * in dark, so the same rule read "lighten a near-white a little" on one ground and "pull a
369
- * lifted surface to pure black" on the other. Measured against the column it sits on: **-6 in
370
- * light, -17 in dark.** That asymmetry was a by-product of the token, never a decision.
300
+ * `position: sticky` NEVER LEAVES FLOW. It reserves its full border box and paints inside it — so
301
+ * the bar's LIVE height positioned everything under it (52 with the title strip shut, 78 open)
302
+ * while `--shell-top` was deliberately frozen at the open height so that nothing would sample the
303
+ * 0.35s animation. The HEIGHT stopped sampling it. THE POSITION NEVER DID. Two numbers that must
304
+ * be equal, written a hundred lines apart in two packages, and their difference is a band of the
305
+ * next section above the fold: measured on the live persona with the scroll untouched, the kernel
306
+ * sat 52px below `#below` instead of 78 and 25.81px of the feed was on screen.
371
307
  *
372
- * So it takes the CARD's material instead, and the light comes from above like everything else:
373
- * an opaque fill with a shadow cast downward, and the page sliding under it. The fill is opaque
374
- * ON PURPOSE — the blur was inert in light anyway, because the column is 92% opaque and there is
375
- * nothing behind it to refract.
308
+ * The wrapper separates them. The SLOT is sticky and its box is `--shell-top`; the BAR is static
309
+ * inside it and may be any height it likes. Nothing above the kernel changes size any more, so
310
+ * there is nothing for an engine to compensate for — which is also why only a device ever showed
311
+ * it: Blink scroll-anchors the step away and WebKit has no scroll anchoring. */
312
+ .tb-slot { position: sticky; top: 0; z-index: 40; display: flow-root; }
313
+ /* queries: the title strip moves the fold · the page drifts after it lands · a band of posts under
314
+ * the game · tb-slot min-height · the slot follows the bar · the strip pushes the page down
376
315
  *
377
- * WHY IT IS HERE NOW. app.zone settled this, qr copied it verbatim — same two hex values, same
378
- * two shadows — and BOTH left a comment saying it belongs in cuts. Two consumers is the
379
- * promotion trigger, and the note the second one wrote is the specification:
316
+ * ── THE BOX IS THE SLOT'S OWN, SO IT TRAVELS WITH THE SLOT ─────────────────────────────────────
380
317
  *
381
- * "the right end state is cuts reading `var(--toolbar-bg, <its current expression>)`,
382
- * after which these four declarations delete themselves and the tokens alone do the work."
318
+ * `position: sticky` never leaves flow: it reserves its full border box, so whatever the bar's LIVE
319
+ * height is at any instant, that is where everything under it sits. The strip opens and closes over
320
+ * 0.35s inside that box — so without a reserved height the shell breathes 26px and the whole
321
+ * document below it breathes with it. That is the drift the founder lost days to.
383
322
  *
384
- * The token remains the seam: a persona whose ground is genuinely different sets `--toolbar-bg`
385
- * and `--toolbar-shadow` and this rule steps aside. */
386
- background: var(--toolbar-bg, #1e202a);
387
- box-shadow: var(--toolbar-shadow, 0 2px 12px -2px rgba(0, 0, 0, 0.55));
388
- /* No hairline: it was the only thing separating the bar from the column, and the shadow does
389
- that now. A line under an OPAQUE shelf reads as a seam rather than as a separation. */
390
- border-block-end: none;
391
- }
392
- @media (prefers-color-scheme: light) {
393
- :global(html:not([data-theme="dark"])) .toolbar {
394
- background: var(--toolbar-bg, #fbfcfe);
395
- box-shadow: var(--toolbar-shadow, 0 1px 2px rgba(16, 24, 40, 0.06), 0 8px 20px -8px rgba(16, 24, 40, 0.16));
323
+ * IT LIVED IN `@justai/ui`'s base.css UNTIL 2026-08-06, and measured on pin.games that was the same
324
+ * as not existing: a persona that never imports base.css got the slot's structure from here and its
325
+ * BOX from a file it does not load, so the slot collapsed to the bar and posts moved 26px between
326
+ * the kernel and the profile. The first law again — a component brings its own ground — and the
327
+ * slot is this component's element, so its box is this component's business. The rule that keeps
328
+ * the whole strip safe cannot be the one rule that lives somewhere the strip does not.
329
+ *
330
+ * AND THE RESERVED BAND WEARS THE BAR'S OWN MATERIAL, not the room's. A slot taller than its bar is
331
+ * 26px of SOMETHING under the shelf whenever the strip is shut — the state at the profile, on
332
+ * screen, at rest. Painted in the persona's ground it was a hard black band between a light profile
333
+ * and a light kernel: the founder photographed it within a minute. It is the BAR'S box, so it takes
334
+ * the bar's fill, bleeding by the same `--edge` expression so the two cannot leave slivers. */
335
+ .tb-slot { min-height: var(--shell-top, 78px); }
336
+ .tb-slot::before {
337
+ content: "";
338
+ position: absolute;
339
+ z-index: -1;
340
+ inset-block: 0;
341
+ inset-inline:
342
+ calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left)))
343
+ calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
344
+ background: var(--toolbar-bg, #1e202a);
396
345
  }
397
- }
398
- /* `.stuck` is still set by the script below and still means "content is under the bar" — it simply
399
- has nothing to paint now that the shelf is opaque and always shadowed. Kept because the class is
400
- part of this cut's contract and a consumer may hang its own behaviour on it. */
401
- @media (min-width: 640px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
402
- /* icons row + the scroll-reactive title strip below it */
403
- .tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
404
- /* queries: --tb-strip · the title strip's height · the toolbar changes height · 26px ·
405
- * the kernel is 26px short · the game frame does not reach the fold
406
- *
407
- * ── THE ONE NUMBER THAT MAKES THIS ROW'S HEIGHT A MOVING TARGET, DECLARED SO IT CAN BE READ ──
408
- *
409
- * The strip opens and closes over 0.35s, so `.toolbar`'s height is anywhere in a 26px range at any
410
- * given moment — and anything that measures the row and feeds a LAYOUT with it is sampling an
411
- * animation. `--shell-top` did exactly that and the kernel's height came out 26px short whenever
412
- * the page happened to be measured with the strip open, which is to say whenever somebody opened
413
- * the persona at #kernel. It reads as a strip of the next section showing above the fold.
414
- *
415
- * So the number is a custom property rather than a literal: this rule uses it, and @justai/core's
416
- * `personaScroll` reads it to compute the row's EXPANDED height without waiting for or racing the
417
- * transition — `toolbar - strip` is the row, and the row plus this is what the bar will be. One
418
- * declaration, two readers, and nothing samples a moving value. */
419
- .toolbar { --tb-strip: 26px; }
420
- /* queries: the bar grows after load · why does the strip animate on arrival · data-motion ·
421
- * first paint animation · can this transition wait for the first interaction
422
- *
423
- * ── THE TRANSITION IS ALSO A DAMPER, WHICH IS WHY IT CANNOT SIMPLY WAIT ─────────────────────
424
- *
425
- * The founder asked the obvious and right question: *"şu geçiş süresi güzel — ama neden bu özellik
426
- * kullanıcının ilk interaction'ından sonra aktif edilmiyor?"* A persona lands on its kernel, so the
427
- * strip's correct state on the first frame is open; animating into it grows the bar 26px just after
428
- * the page appears, which is a glitch nobody asked for.
429
- *
430
- * IT WAS TRIED, AND IT BREAKS SOMETHING ELSE. Gated behind a `data-motion` flag set on the first
431
- * pointer/key/wheel, the strip snaps open during the landing — and that 26px arrives in a SINGLE
432
- * frame, inside `#below`, while the scroll spy is deciding which cut is active. That is the
433
- * bistable oscillator `personaScroll` documents at length: the spy measures a page its own output
434
- * moved. Measured: with the gate in place, `#navProfile` no longer navigates — the page re-asserts
435
- * the kernel and stays. The 0.35s is not only easing; it is what keeps that step out of one frame.
436
- *
437
- * SO THE HEIGHT IS NEVER READ INSTEAD. `--shell-top` is computed as `row + --tb-strip` — the row
438
- * being the bar minus whatever the strip currently is, which is the same number at every frame of
439
- * the transition. Nothing samples the animation, so nothing depends on when it was sampled.
440
- *
441
- * The real fix for the founder's question is a toolbar whose height does not change at all: reserve
442
- * the strip's space always and fade only its content. That removes the feedback at the source and
443
- * makes the gate free — and it is a fleet-wide design decision about how the bar looks at the
444
- * profile, so it is his to take rather than one to slip in. */
445
- .tb-title { display: flex; align-items: center; height: 0; overflow: hidden; opacity: 0; transition: height 0.35s var(--ease-out), opacity 0.25s var(--ease-out); }
446
- /* The fallback is not decoration: the token is declared on `.toolbar` and consumed here, which is
447
- a different selector — so the framework's own token spec reads it as ungrounded and is right to.
448
- A rule that silently collapses to `height: 0` would close the strip and say nothing. */
449
- .tb-title.show { height: 26px; height: var(--tb-strip, 26px); opacity: 1; }
450
- .tb-title-text { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.16s var(--ease-out); }
451
- .tb-title-text.fade { opacity: 0; }
452
- /* nav lives on the left, beside the back affordance — the middle is intentionally empty */
453
- .tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
454
- .tb-nav-group { display: flex; align-items: center; gap: 2px; }
455
- .tb-right { display: flex; align-items: center; min-width: 0; }
456
-
457
- /* ── the withdraw: what this row does while a game is playing ───────────────────────────────
458
- * Opacity and pointer-events here; the INERT that takes them out of the tab order is set in the
459
- * script, because CSS cannot. The transition matters more than it looks: chrome that snaps away
460
- * reads as breaking, chrome that withdraws reads as dimming the house lights. */
461
- /* ── HOW THIS ROW GOES QUIET WHILE A GAME PLAYS: IT DOES NOT ─────────────────────────────────
462
- * queries: toolbar during a game · theater · toolbar looks disabled
463
- *
464
- * Four versions in one day, each struck by the founder, and the last one is no CSS at all. A
465
- * pause handle in the row; then inert; then faded ("disable gibi gozukmemeli - tiklanabiliyorsa");
466
- * then stepped under a custom dim layer. The standing answer: the stage switches the page to the
467
- * persona's own DARK THEME (data-theme on html, see Preroll), and this row simply wears it - the
468
- * same bar every dark-system visitor already sees, fully alive, auto-pausing the game when
469
- * touched. Nothing here needs to know a game exists. */
470
- /* icon-only section nav — current cut highlighted */
471
- .tb-nav {
472
- display: grid; place-items: center; width: 38px; height: 36px;
473
- color: var(--text-dim);
474
- transition: color 0.2s, opacity 0.2s, transform 0.2s var(--ease-spring);
475
- }
476
- .tb-nav:hover { color: var(--text); }
477
- .tb-nav:active { transform: scale(0.9); }
478
- /* The active glyph is a graphic, so it wants 3:1 rather than 4.5 — which a light accent still
479
- misses. Same ink hook as everywhere the accent stops being paint. */
480
- .tb-nav.active { color: var(--accent-text, var(--accent)); }
481
-
482
- /* queries: should the persona mark be coloured · the mark is always accent · two icons look active
483
- THE MARK IS A CUT ICON LIKE THE OTHER TWO — the founder's rule, 2026-08-03: the ACTIVE cut wears
484
- the accent and no passive one does, the persona's own mark included.
485
-
486
- This comment used to argue the opposite: *"the three stroke icons are generic glyphs whose colour
487
- IS a UI state; a mark is an identity… a faded logo reads as disabled."* Kept, because it is a true
488
- premise ageing into a wrong conclusion. It was true while the mark arrived as an `<img>`, which
489
- cannot take `currentColor` — dimming was then the only way to say "not here", and a dimmed logo
490
- does read as disabled. The premise expired when `toolbarMark` began deriving an inline SVG: an
491
- inline mark can be INK, and ink is not dimming. `toolbarMark` neutralises its paint, so the two
492
- rules below reach it exactly as they reach the bolt and the bubble.
346
+ /* The shelf's fill has two branches and so must its box: `--toolbar-bg` is one token but its
347
+ FALLBACK differs by scheme, and a band carrying only the dark default is a black strip on a light
348
+ page — which is exactly what the founder photographed a minute after the first attempt. */
349
+ @media (prefers-color-scheme: light) {
350
+ /* Guarded like every light fork in the fleet: data-theme="dark" wins over the media query. */
351
+ :global(html:not([data-theme="dark"])) .tb-slot::before { background: var(--toolbar-bg, #fbfcfe); }
352
+ }
353
+ .toolbar {
354
+ /* position, top and z-index live on .tb-slot above — this element is static inside it now. */
355
+ display: flex; flex-direction: column;
356
+ /* full-bleed: the bar (frosted bg + hairline) touches the contained-view edges; the icons stay
357
+ aligned with the body via matching inline padding. --edge is the persona's page inline padding
358
+ (16px default; a persona that pads its body differently — e.g. time at 20px — sets --edge so the
359
+ bar bleeds to the SAME edge as its content instead of a hardcoded 16px). */
360
+ margin-inline: calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left))) calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
361
+ padding-block: max(8px, env(safe-area-inset-top)) 8px;
362
+ padding-inline: max(var(--edge, 16px), env(safe-area-inset-left)) max(var(--edge, 16px), env(safe-area-inset-right));
493
363
 
494
- A mark that keeps its colours while another cut is current is a second voice on the one channel
495
- the accent has — measured, several passive marks were more coloured than the active icon. A
496
- persona that wants its own gradient WHILE CURRENT re-points to it from its own CSS; the `<defs>`
497
- survive the derivation for that. `.tb-mark` remains, because the sizing rule below still needs to
498
- know a mark is here. */
499
- /* The shell sizes the mark and says NOTHING about `display`, deliberately. Two files arrive for the two
500
- themes — an SVG's internal `prefers-color-scheme` does not survive <img>, measured — and the persona
501
- hides one with a media query. A `display: block` here would be two classes deep against the persona's
502
- one and would WIN, showing both marks stacked. That exact mistake shipped once in this shell and the
503
- founder counted the duplicate logos before anyone read the CSS. Width and height only.
364
+ /* ── THE BAR IS A SHELF, NOT A TINT ──────────────────────────────────────────────────────
365
+ *
366
+ * queries: the toolbar reads as a black strip · why is the bar darker in dark than in light ·
367
+ * the bar looks flat against the column · toolbar material · --toolbar-bg
368
+ *
369
+ * It used to derive its fill from `--bg` — `color-mix(--bg 70%, transparent)` under a blur.
370
+ * One declaration, but it meant two different things: `--bg` is a near-white in light and #000
371
+ * in dark, so the same rule read "lighten a near-white a little" on one ground and "pull a
372
+ * lifted surface to pure black" on the other. Measured against the column it sits on: **-6 in
373
+ * light, -17 in dark.** That asymmetry was a by-product of the token, never a decision.
374
+ *
375
+ * So it takes the CARD's material instead, and the light comes from above like everything else:
376
+ * an opaque fill with a shadow cast downward, and the page sliding under it. The fill is opaque
377
+ * ON PURPOSE — the blur was inert in light anyway, because the column is 92% opaque and there is
378
+ * nothing behind it to refract.
379
+ *
380
+ * WHY IT IS HERE NOW. app.zone settled this, qr copied it verbatim — same two hex values, same
381
+ * two shadows — and BOTH left a comment saying it belongs in cuts. Two consumers is the
382
+ * promotion trigger, and the note the second one wrote is the specification:
383
+ *
384
+ * "the right end state is cuts reading `var(--toolbar-bg, <its current expression>)`,
385
+ * after which these four declarations delete themselves and the tokens alone do the work."
386
+ *
387
+ * The token remains the seam: a persona whose ground is genuinely different sets `--toolbar-bg`
388
+ * and `--toolbar-shadow` and this rule steps aside. */
389
+ background: var(--toolbar-bg, #1e202a);
390
+ box-shadow: var(--toolbar-shadow, 0 2px 12px -2px rgba(0, 0, 0, 0.55));
391
+ /* No hairline: it was the only thing separating the bar from the column, and the shadow does
392
+ that now. A line under an OPAQUE shelf reads as a seam rather than as a separation. */
393
+ border-block-end: none;
394
+ }
395
+ @media (prefers-color-scheme: light) {
396
+ :global(html:not([data-theme="dark"])) .toolbar {
397
+ background: var(--toolbar-bg, #fbfcfe);
398
+ box-shadow: var(--toolbar-shadow, 0 1px 2px rgba(16, 24, 40, 0.06), 0 8px 20px -8px rgba(16, 24, 40, 0.16));
399
+ }
400
+ }
401
+ /* `.stuck` is still set by the script below and still means "content is under the bar" — it simply
402
+ has nothing to paint now that the shelf is opaque and always shadowed. Kept because the class is
403
+ part of this cut's contract and a consumer may hang its own behaviour on it. */
404
+ @media (min-width: 640px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
405
+ /* icons row + the scroll-reactive title strip below it */
406
+ .tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
407
+ /* queries: --tb-strip · the title strip's height · the toolbar changes height · 26px ·
408
+ * the kernel is 26px short · the game frame does not reach the fold
409
+ *
410
+ * ── THE ONE NUMBER THAT MAKES THIS ROW'S HEIGHT A MOVING TARGET, DECLARED SO IT CAN BE READ ──
411
+ *
412
+ * The strip opens and closes over 0.35s, so `.toolbar`'s height is anywhere in a 26px range at any
413
+ * given moment — and anything that measures the row and feeds a LAYOUT with it is sampling an
414
+ * animation. `--shell-top` did exactly that and the kernel's height came out 26px short whenever
415
+ * the page happened to be measured with the strip open, which is to say whenever somebody opened
416
+ * the persona at #kernel. It reads as a strip of the next section showing above the fold.
417
+ *
418
+ * So the number is a custom property rather than a literal: this rule uses it, and @justai/core's
419
+ * `personaScroll` reads it to compute the row's EXPANDED height without waiting for or racing the
420
+ * transition — `toolbar - strip` is the row, and the row plus this is what the bar will be. One
421
+ * declaration, two readers, and nothing samples a moving value. */
422
+ .toolbar { --tb-strip: 26px; }
423
+ /* queries: the bar grows after load · why does the strip animate on arrival · data-motion ·
424
+ * first paint animation · can this transition wait for the first interaction
425
+ *
426
+ * ── THE TRANSITION IS ALSO A DAMPER, WHICH IS WHY IT CANNOT SIMPLY WAIT ─────────────────────
427
+ *
428
+ * The founder asked the obvious and right question: *"şu geçiş süresi güzel — ama neden bu özellik
429
+ * kullanıcının ilk interaction'ından sonra aktif edilmiyor?"* A persona lands on its kernel, so the
430
+ * strip's correct state on the first frame is open; animating into it grows the bar 26px just after
431
+ * the page appears, which is a glitch nobody asked for.
432
+ *
433
+ * IT WAS TRIED, AND IT BREAKS SOMETHING ELSE. Gated behind a `data-motion` flag set on the first
434
+ * pointer/key/wheel, the strip snaps open during the landing — and that 26px arrives in a SINGLE
435
+ * frame, inside `#below`, while the scroll spy is deciding which cut is active. That is the
436
+ * bistable oscillator `personaScroll` documents at length: the spy measures a page its own output
437
+ * moved. Measured: with the gate in place, `#navProfile` no longer navigates — the page re-asserts
438
+ * the kernel and stays. The 0.35s is not only easing; it is what keeps that step out of one frame.
439
+ *
440
+ * SO THE HEIGHT IS NEVER READ INSTEAD. `--shell-top` is computed as `row + --tb-strip` — the row
441
+ * being the bar minus whatever the strip currently is, which is the same number at every frame of
442
+ * the transition. Nothing samples the animation, so nothing depends on when it was sampled.
443
+ *
444
+ * The real fix for the founder's question is a toolbar whose height does not change at all: reserve
445
+ * the strip's space always and fade only its content. That removes the feedback at the source and
446
+ * makes the gate free — and it is a fleet-wide design decision about how the bar looks at the
447
+ * profile, so it is his to take rather than one to slip in. */
448
+ .tb-title { display: flex; align-items: center; height: 0; overflow: hidden; opacity: 0; transition: height 0.35s var(--ease-out), opacity 0.25s var(--ease-out); }
449
+ /* The fallback is not decoration: the token is declared on `.toolbar` and consumed here, which is
450
+ a different selector — so the framework's own token spec reads it as ungrounded and is right to.
451
+ A rule that silently collapses to `height: 0` would close the strip and say nothing. */
452
+ .tb-title.show { height: 26px; height: var(--tb-strip, 26px); opacity: 1; }
453
+ .tb-title-text { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.16s var(--ease-out); }
454
+ .tb-title-text.fade { opacity: 0; }
455
+ /* nav lives on the left, beside the back affordance — the middle is intentionally empty */
456
+ .tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
457
+ .tb-nav-group { display: flex; align-items: center; gap: 2px; }
458
+ .tb-right { display: flex; align-items: center; min-width: 0; }
504
459
 
505
- 19px, the same box the stroke icons get. 0.65.0 set this to 14.5px to make the mark's INK match the
506
- neighbours' — a glyph carries padding and a mark does not, so at an equal box the mark reads about a
507
- third larger. The measurement was right and the conclusion was the founder's to make, not mine: he
508
- wants the mark at its own size. A logo is allowed to be more present than a glyph.
509
- So the difference stays, and it is a difference in ink rather than in box: mark ~19px, glyphs ~14.3px
510
- inside the same 19px. If the row is ever to read level, the way there is to grow the GLYPHS, not to
511
- shrink the identity. */
512
- /* ── EVERY ICON READS AT 20 ─────────────────────────────────────────────────────────────────
513
- *
514
- * queries: toolbar icon size · 20 · why is my icon smaller than the others · 26.67 · the mark
515
- * looks small · icon ink
516
- *
517
- * The founder's rule, and it is about the DRAWING rather than the box: an icon's picture hits 20
518
- * on its longer side, whichever side its design naturally uses. A bolt is tall and narrow and hits
519
- * 20 on its height; a folder hits it on its width. The container is whatever that takes.
520
- *
521
- * One number, two boxes, because a viewBox is not a drawing:
522
- *
523
- * a MARK fills its own viewBox → a 20px box puts 20px on screen
524
- * a GLYPH sits inside 18 of 24 (0.75) → 20 / 0.75 = 26.67px
525
- *
526
- * A persona whose mark does NOT fill its viewBox needs its own number, and that is where the
527
- * confusion came from: those marks looked small in the same box as everything else because their
528
- * drawing occupies less of it. The box is derived — 20 divided by how much of the viewBox the
529
- * drawing uses — so a redraw changes it, and the comment beside each one says so.
530
- *
531
- * (This replaced a 19-with-a-20-in-front rule that lasted an afternoon. The distinction was real —
532
- * the first slot is not one of the sections — but it is said by POSITION and the -7px pull above,
533
- * not by a pixel of size nobody can see and everybody can drift.) */
534
- .tb-nav.tb-mark :global(img) { width: 20px; height: 20px; }
460
+ /* ── the withdraw: what this row does while a game is playing ───────────────────────────────
461
+ * Opacity and pointer-events here; the INERT that takes them out of the tab order is set in the
462
+ * script, because CSS cannot. The transition matters more than it looks: chrome that snaps away
463
+ * reads as breaking, chrome that withdraws reads as dimming the house lights. */
464
+ /* ── HOW THIS ROW GOES QUIET WHILE A GAME PLAYS: IT DOES NOT ─────────────────────────────────
465
+ * queries: toolbar during a game · theater · toolbar looks disabled
466
+ *
467
+ * Four versions in one day, each struck by the founder, and the last one is no CSS at all. A
468
+ * pause handle in the row; then inert; then faded ("disable gibi gozukmemeli - tiklanabiliyorsa");
469
+ * then stepped under a custom dim layer. The standing answer: the stage switches the page to the
470
+ * persona's own DARK THEME (data-theme on html, see Preroll), and this row simply wears it - the
471
+ * same bar every dark-system visitor already sees, fully alive, auto-pausing the game when
472
+ * touched. Nothing here needs to know a game exists. */
473
+ /* icon-only section nav — current cut highlighted */
474
+ .tb-nav {
475
+ display: grid; place-items: center; width: 38px; height: 36px;
476
+ color: var(--text-dim);
477
+ transition: color 0.2s, opacity 0.2s, transform 0.2s var(--ease-spring);
478
+ }
479
+ .tb-nav:hover { color: var(--text); }
480
+ .tb-nav:active { transform: scale(0.9); }
481
+ /* The active glyph is a graphic, so it wants 3:1 rather than 4.5 — which a light accent still
482
+ misses. Same ink hook as everywhere the accent stops being paint. */
483
+ .tb-nav.active { color: var(--accent-text, var(--accent)); }
535
484
 
536
- /* ── THE FIRST POSITION STARTS WHERE THE ROW STARTS ─────────────────────────────────────────
537
- *
538
- * queries: the first icon is further in on a child persona · navParent inset · -7px ·
539
- * the row starts in a different place
540
- *
541
- * `BackToZone` pulls itself 7px left so the row begins flush with the bar's edge rather than a tap
542
- * target's worth inside it. Anything standing in the first position has the same job and needs the
543
- * same pull — measured on a live child, the parent's face sat 26px from the bar's edge where a
544
- * persona's way out sits at 17. Nine pixels, found by eye before any probe looked: a person moving
545
- * between two surfaces sees a difference no single page can show.
546
- *
547
- * POSITION IS WHAT MAKES THE FIRST SLOT DIFFERENT — not size. Every icon reads at 20; what says
548
- * "this one is the way out rather than a section" is where it sits, flush to the edge, before the
549
- * group. Structural for that reason: BackToZone sizes and pulls itself, and when it is absent the
550
- * nav group becomes the first child of `.tb-left` and its first item inherits the job. */
551
- .tb-left > .tb-nav-group:first-child > .tb-nav:first-child { margin-inline-start: -7px; }
552
- .tb-nav :global(svg) { width: 20px; height: 20px; }
485
+ /* queries: should the persona mark be coloured · the mark is always accent · two icons look active
486
+ THE MARK IS A CUT ICON LIKE THE OTHER TWO — the founder's rule, 2026-08-03: the ACTIVE cut wears
487
+ the accent and no passive one does, the persona's own mark included.
553
488
 
554
- /* ── EQUAL INK, TWO BOXES ────────────────────────────────────────────────────────────────────
555
- *
556
- * queries: the toolbar icons are not the same size · the mark looks bigger than the glyphs ·
557
- * the row is not level · 25.33px
558
- *
559
- * The paragraph above ends by naming the way out — *"grow the GLYPHS, not the identity"* — and
560
- * this is that, arriving from the two repos that measured it.
561
- *
562
- * "19px" means two different things in this row. A Lucide glyph is drawn in a 24 viewBox with its
563
- * ink between 3 and 21, so a 19px box puts **14.25px** of ink on screen; a mark runs to the edge
564
- * of its own viewBox and puts **18.65px**. The mark therefore reads about a third larger, which is
565
- * exactly what it did — and while the mark was still coloured, colour carried part of that
566
- * difference. Now that it is monochrome at rest, the size gap is the only thing left and it shows.
567
- *
568
- * 19 × 24/18 = 25.33px is the box at which a glyph's INK measures 19px, the same 19px the mark
569
- * occupies. `:not(.tb-mark)` is not a special case — it is the same rule read the other way: a
570
- * glyph carries padding inside its viewBox and a mark does not, so equal ink needs unequal boxes.
571
- *
572
- * app.zone settled it, qr arrived at the identical number, and qr's own comment says why it is
573
- * here: *"otherwise every persona has to remember to carry it, which is precisely the thing the
574
- * shared shell exists to prevent."* */
575
- .tb-nav:not(.tb-mark) :global(svg) { width: 26.67px; height: 26.67px; }
489
+ This comment used to argue the opposite: *"the three stroke icons are generic glyphs whose colour
490
+ IS a UI state; a mark is an identity… a faded logo reads as disabled."* Kept, because it is a true
491
+ premise ageing into a wrong conclusion. It was true while the mark arrived as an `<img>`, which
492
+ cannot take `currentColor` — dimming was then the only way to say "not here", and a dimmed logo
493
+ does read as disabled. The premise expired when `toolbarMark` began deriving an inline SVG: an
494
+ inline mark can be INK, and ink is not dimming. `toolbarMark` neutralises its paint, so the two
495
+ rules below reach it exactly as they reach the bolt and the bubble.
576
496
 
577
- /* THE BUBBLE HANGS. Its silhouette's centre measures 1.05px below the line the other three sit on
578
- (39.05 against 38.0) because the tail is part of the shape and pulls the box down with it — the
579
- glyph is centred while the thing you SEE is not. A pixel is inside the noise on most rows; on
580
- four icons at one size sharing one baseline it is the only thing left that is not level.
581
- Nudged rather than redrawn, and `transform` on the svg never collides with the press feedback:
582
- `.tb-nav:active`'s scale lives on the anchor. Both repos carry this verbatim. */
583
- #navPosts :global(svg) { transform: translateY(-1px); }
584
- </style>
497
+ A mark that keeps its colours while another cut is current is a second voice on the one channel
498
+ the accent has — measured, several passive marks were more coloured than the active icon. A
499
+ persona that wants its own gradient WHILE CURRENT re-points to it from its own CSS; the `<defs>`
500
+ survive the derivation for that. `.tb-mark` remains, because the sizing rule below still needs to
501
+ know a mark is here. */
502
+ /* The shell sizes the mark and says NOTHING about `display`, deliberately. Two files arrive for the two
503
+ themes — an SVG's internal `prefers-color-scheme` does not survive <img>, measured — and the persona
504
+ hides one with a media query. A `display: block` here would be two classes deep against the persona's
505
+ one and would WIN, showing both marks stacked. That exact mistake shipped once in this shell and the
506
+ founder counted the duplicate logos before anyone read the CSS. Width and height only.
585
507
 
586
- <script>
587
- // queries: where does the toolbar's scroll behaviour live · land on the kernel · scroll spy ·
588
- // title strip · why is the scroll code not in this file any more
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";
508
+ 19px, the same box the stroke icons get. 0.65.0 set this to 14.5px to make the mark's INK match the
509
+ neighbours' — a glyph carries padding and a mark does not, so at an equal box the mark reads about a
510
+ third larger. The measurement was right and the conclusion was the founder's to make, not mine: he
511
+ wants the mark at its own size. A logo is allowed to be more present than a glyph.
512
+ So the difference stays, and it is a difference in ink rather than in box: mark ~19px, glyphs ~14.3px
513
+ inside the same 19px. If the row is ever to read level, the way there is to grow the GLYPHS, not to
514
+ shrink the identity. */
515
+ /* ── EVERY ICON READS AT 20 ─────────────────────────────────────────────────────────────────
516
+ *
517
+ * queries: toolbar icon size · 20 · why is my icon smaller than the others · 26.67 · the mark
518
+ * looks small · icon ink
519
+ *
520
+ * The founder's rule, and it is about the DRAWING rather than the box: an icon's picture hits 20
521
+ * on its longer side, whichever side its design naturally uses. A bolt is tall and narrow and hits
522
+ * 20 on its height; a folder hits it on its width. The container is whatever that takes.
523
+ *
524
+ * One number, two boxes, because a viewBox is not a drawing:
525
+ *
526
+ * a MARK fills its own viewBox → a 20px box puts 20px on screen
527
+ * a GLYPH sits inside 18 of 24 (0.75) → 20 / 0.75 = 26.67px
528
+ *
529
+ * A persona whose mark does NOT fill its viewBox needs its own number, and that is where the
530
+ * confusion came from: those marks looked small in the same box as everything else because their
531
+ * drawing occupies less of it. The box is derived — 20 divided by how much of the viewBox the
532
+ * drawing uses — so a redraw changes it, and the comment beside each one says so.
533
+ *
534
+ * (This replaced a 19-with-a-20-in-front rule that lasted an afternoon. The distinction was real —
535
+ * the first slot is not one of the sections — but it is said by POSITION and the -7px pull above,
536
+ * not by a pixel of size nobody can see and everybody can drift.) */
537
+ .tb-nav.tb-mark :global(img) { width: 20px; height: 20px; }
601
538
 
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" });
539
+ /* ── THE FIRST POSITION STARTS WHERE THE ROW STARTS ─────────────────────────────────────────
540
+ *
541
+ * queries: the first icon is further in on a child persona · navParent inset · -7px ·
542
+ * the row starts in a different place
543
+ *
544
+ * `BackToZone` pulls itself 7px left so the row begins flush with the bar's edge rather than a tap
545
+ * target's worth inside it. Anything standing in the first position has the same job and needs the
546
+ * same pull — measured on a live child, the parent's face sat 26px from the bar's edge where a
547
+ * persona's way out sits at 17. Nine pixels, found by eye before any probe looked: a person moving
548
+ * between two surfaces sees a difference no single page can show.
549
+ *
550
+ * POSITION IS WHAT MAKES THE FIRST SLOT DIFFERENT — not size. Every icon reads at 20; what says
551
+ * "this one is the way out rather than a section" is where it sits, flush to the edge, before the
552
+ * group. Structural for that reason: BackToZone sizes and pulls itself, and when it is absent the
553
+ * nav group becomes the first child of `.tb-left` and its first item inherits the job. */
554
+ .tb-left > .tb-nav-group:first-child > .tb-nav:first-child { margin-inline-start: -7px; }
555
+ .tb-nav :global(svg) { width: 20px; height: 20px; }
614
556
 
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" });
557
+ /* ── EQUAL INK, TWO BOXES ────────────────────────────────────────────────────────────────────
558
+ *
559
+ * queries: the toolbar icons are not the same size · the mark looks bigger than the glyphs ·
560
+ * the row is not level · 25.33px
561
+ *
562
+ * The paragraph above ends by naming the way out — *"grow the GLYPHS, not the identity"* — and
563
+ * this is that, arriving from the two repos that measured it.
564
+ *
565
+ * "19px" means two different things in this row. A Lucide glyph is drawn in a 24 viewBox with its
566
+ * ink between 3 and 21, so a 19px box puts **14.25px** of ink on screen; a mark runs to the edge
567
+ * of its own viewBox and puts **18.65px**. The mark therefore reads about a third larger, which is
568
+ * exactly what it did — and while the mark was still coloured, colour carried part of that
569
+ * difference. Now that it is monochrome at rest, the size gap is the only thing left and it shows.
570
+ *
571
+ * 19 × 24/18 = 25.33px is the box at which a glyph's INK measures 19px, the same 19px the mark
572
+ * occupies. `:not(.tb-mark)` is not a special case — it is the same rule read the other way: a
573
+ * glyph carries padding inside its viewBox and a mark does not, so equal ink needs unequal boxes.
574
+ *
575
+ * app.zone settled it, qr arrived at the identical number, and qr's own comment says why it is
576
+ * here: *"otherwise every persona has to remember to carry it, which is precisely the thing the
577
+ * shared shell exists to prevent."* */
578
+ .tb-nav:not(.tb-mark) :global(svg) { width: 26.67px; height: 26.67px; }
631
579
 
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
- );
580
+ /* THE BUBBLE HANGS. Its silhouette's centre measures 1.05px below the line the other three sit on
581
+ (39.05 against 38.0) because the tail is part of the shape and pulls the box down with it — the
582
+ glyph is centred while the thing you SEE is not. A pixel is inside the noise on most rows; on
583
+ four icons at one size sharing one baseline it is the only thing left that is not level.
584
+ Nudged rather than redrawn, and `transform` on the svg never collides with the press feedback:
585
+ `.tb-nav:active`'s scale lives on the anchor. Both repos carry this verbatim. */
586
+ #navPosts :global(svg) { transform: translateY(-1px); }
587
+ }
588
+ </style>
658
589
 
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>
590
+ <script>
591
+ // The behaviour lives in ./behaviour/toolbar.ts — see that file for why it is not in here.
592
+ import "./behaviour/toolbar";
593
+ </script>