@justai/cuts 0.150.0 → 0.152.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@justai/cuts",
3
- "version": "0.150.0",
3
+ "version": "0.152.0",
4
4
  "description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -41,8 +41,8 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@justai/core": "^0.40.0",
45
- "@justai/ui": "^0.27.0",
44
+ "@justai/core": "^0.41.0",
45
+ "@justai/ui": "^0.28.0",
46
46
  "@justai/atrium": "^0.1.9"
47
47
  },
48
48
  "scripts": {
package/src/Base.astro CHANGED
@@ -100,6 +100,34 @@ interface Props {
100
100
  * where it wanted none — rather than an app persona bleeding to the edges.
101
101
  */
102
102
  kind?: "app" | "game" | "art" | "now" | "expert" | "knower" | "platform";
103
+ /**
104
+ * queries: a persona with pages of its own · canonical points at the home · every page has the
105
+ * persona's og image · per-page title · per-page share card · hreflang for a sub-page ·
106
+ * a QR opens a page that previews as the home
107
+ *
108
+ * A PAGE OF ITS OWN — for a persona whose kernel is many named things, each at its own address
109
+ * (`card.cityinmotion.nl/where-waters-meet`). Leave it out and this layout writes the persona's
110
+ * HOME, which is right for every persona that has one page: the persona's title, its `/og.jpg`, and
111
+ * a canonical at the locale's root.
112
+ *
113
+ * Written for a page that is NOT the home, those three are false in the worst way: nothing is red,
114
+ * the page renders, and every one of its tags says "I am the home". A search engine then folds all
115
+ * of them into one result (a canonical is an instruction, not a hint it ignores), and a link to one
116
+ * of them pasted into a chat previews as the persona's generic card instead of the thing linked —
117
+ * which, for a printed QR code, is the one impression there is. Birth 10 found it before the first
118
+ * page shipped.
119
+ *
120
+ * `path(locale)` is the page's address in each locale, so the alternates name THIS page in every
121
+ * language rather than the other languages' homes. `image` is absolute or site-relative; its size is
122
+ * required because a preview that has to fetch the image to learn its shape renders late or not at
123
+ * all.
124
+ */
125
+ page?: {
126
+ path: (locale: string) => string;
127
+ title: string;
128
+ description: string;
129
+ image?: { url: string; width: number; height: number; type?: string; alt?: string };
130
+ };
103
131
  }
104
132
 
105
133
  const {
@@ -108,6 +136,7 @@ const {
108
136
  themeLight,
109
137
  gaId = "G-13L3BP6XCW",
110
138
  kind = "app",
139
+ page,
111
140
  } = Astro.props;
112
141
 
113
142
  // ── ONE HOME FOR THE ORIGIN ───────────────────────────────────────────────────────────────────
@@ -157,6 +186,16 @@ const light = themeLight ?? (placeKind ? GAME_GROUND : "#f2f2f7");
157
186
  const d = dirOf(lang);
158
187
  const consent = consentCopy(lang);
159
188
  const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
189
+
190
+ // The head speaks for the page when the page has its own address, and for the persona otherwise —
191
+ // see `page` above. One set of values, so the canonical, og:url and the alternates cannot disagree.
192
+ const hrefOf = (l: string) => `${site}${page ? page.path(l) : localePath(l)}`;
193
+ const canonical = hrefOf(lang);
194
+ const headTitle = page?.title ?? t.metaTitle;
195
+ const headDescription = page?.description ?? t.metaDescription;
196
+ const ogImage = page?.image
197
+ ? { ...page.image, url: new URL(page.image.url, Astro.site).href }
198
+ : { url: new URL("/og.jpg", Astro.site).href, width: 1200, height: 630, type: "image/jpeg", alt: undefined };
160
199
  ---
161
200
 
162
201
  <!doctype html>
@@ -248,26 +287,27 @@ const localePath = (l: string) => (l === defaultLocale ? "/" : `/${l}/`);
248
287
  } catch (e) {}
249
288
  })();
250
289
  </script>
251
- <title>{t.metaTitle}</title>
252
- <meta name="description" content={t.metaDescription} />
253
- <meta property="og:title" content={t.metaTitle} />
254
- <meta property="og:description" content={t.metaDescription} />
290
+ <title>{headTitle}</title>
291
+ <meta name="description" content={headDescription} />
292
+ <meta property="og:title" content={headTitle} />
293
+ <meta property="og:description" content={headDescription} />
255
294
  <meta property="og:type" content="website" />
256
- <meta property="og:image" content={new URL("/og.jpg", Astro.site)} />
257
- <meta property="og:image:type" content="image/jpeg" />
258
- <meta property="og:image:width" content="1200" />
259
- <meta property="og:image:height" content="630" />
260
- <meta property="og:url" content={new URL(Astro.url.pathname, Astro.site)} />
295
+ <meta property="og:image" content={ogImage.url} />
296
+ {ogImage.type && <meta property="og:image:type" content={ogImage.type} />}
297
+ <meta property="og:image:width" content={String(ogImage.width)} />
298
+ <meta property="og:image:height" content={String(ogImage.height)} />
299
+ {ogImage.alt && <meta property="og:image:alt" content={ogImage.alt} />}
300
+ <meta property="og:url" content={page ? canonical : new URL(Astro.url.pathname, Astro.site).href} />
261
301
  <meta property="og:site_name" content={new URL(site).host} />
262
302
  <meta name="twitter:card" content="summary_large_image" />
263
- <meta name="twitter:image" content={new URL("/og.jpg", Astro.site)} />
303
+ <meta name="twitter:image" content={ogImage.url} />
264
304
  <meta name="theme-color" content={dark} media="(prefers-color-scheme: dark)" />
265
305
  <meta name="theme-color" content={light} media="(prefers-color-scheme: light)" />
266
- <link rel="canonical" href={`${site}${localePath(lang)}`} />
306
+ <link rel="canonical" href={canonical} />
267
307
  {locales.map((l) => (
268
- <link rel="alternate" hreflang={l} href={`${site}${localePath(l)}`} />
308
+ <link rel="alternate" hreflang={l} href={hrefOf(l)} />
269
309
  ))}
270
- <link rel="alternate" hreflang="x-default" href={`${site}/`} />
310
+ <link rel="alternate" hreflang="x-default" href={page ? hrefOf(defaultLocale) : `${site}/`} />
271
311
  <link rel="icon" href="/favicon.ico" sizes="32x32" />
272
312
  <link
273
313
  rel="icon"
@@ -260,7 +260,7 @@ const chrome = face === "page";
260
260
  exactly that constant (base.css, Posts.astro, eight persona repos) and surfaced only when the
261
261
  children's console started warning about it: a scoped rule inside a cut is where a stale number
262
262
  hides longest, because nobody greps a component for a layout constant. */
263
- .pp #profile, .pp #kernel { scroll-margin-top: var(--shell-top, 78px); }
263
+ .pp #profile, .pp #kernel { scroll-margin-top: var(--shell-top, 52px); }
264
264
 
265
265
  /* THE `sheet` FACE: no column, no ground, no chrome. The sheet around it IS the frame, and a
266
266
  second frame inside one is a box in a box. Keyed on the face name rather than on a boolean, so a
package/src/Posts.astro CHANGED
@@ -167,6 +167,23 @@ const vars = [
167
167
  /* The landing offset is `--shell-top` and only that — see base.css. A hardcoded twin here is a
168
168
  second opinion the browser can pick when it scrolls to `#posts` by itself. */
169
169
  .posts { border-block-start: 1px solid var(--border); scroll-margin-top: var(--shell-top); }
170
+ /* queries: the posts icon does not reach the posts · first tap on posts stops short · #posts lands
171
+ * in the middle of the kernel · the second tap works · the feed is lazy
172
+ *
173
+ * ── A CUT YOU CAN BE SENT TO MUST BE TALL ENOUGH TO BE ARRIVED AT ───────────────────────────────
174
+ *
175
+ * The feed loads when this section comes into view, so until then the section is only its footer.
176
+ * Landing on it means scrolling until its top sits under the bar — and the document did not reach
177
+ * that far: the browser stops at the bottom of the page and says nothing (cause 1 of the landing
178
+ * doc, met again in the toolbar's own link). Measured on location at 390x844: the page ends at
179
+ * 5119, the most it can scroll is 5119 - 844 = 4275, the posts landing is 5007 — so the first tap
180
+ * on the bubble and every `#posts` link stopped 706px short, with the icon and the address bar
181
+ * already saying "posts". The second tap worked because by then the feed had loaded.
182
+ *
183
+ * So the section is at least one active view tall, the same floor the kernel has in base.css: the
184
+ * landing always has room, and the feed arriving later grows the page BELOW the reader rather than
185
+ * moving anything. The literal first, for engines without dvh. */
186
+ .posts { min-height: calc(100vh - 52px); min-height: calc(100dvh - var(--shell-top)); box-sizing: border-box; }
170
187
  /* A post is hoverable because it is a link to its own page, and the hover used to say so with a grey wash
171
188
  — `--text` at 3.5% — which reads as dimming rather than as lifting. It now uses the card material, so
172
189
  hovering a post looks like the same gesture as hovering a catalogue card.
package/src/Toolbar.astro CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  // ░░ TOOLBAR cut — the persona's sticky chrome: home · section-nav · language · sign-in, plus the
3
- // scroll-reactive title strip and the scroll-spy that binds the whole page together. ░░
3
+ // scroll-spy that binds the whole page together. One row, one height. ░░
4
4
  //
5
5
  // This markup + its ~40 lines of CSS + its ~80-line scroll-spy were near-identical in every persona's
6
6
  // main component (only the kernel glyph and a couple of optional controls differed). It lives here now,
@@ -33,18 +33,15 @@
33
33
  // something plausible is how thirteen of fifteen personas wore nobody's face for months with
34
34
  // nothing red anywhere. Pass the slot when an inline SVG is wanted instead of an <img>.
35
35
  // Note what a mark cannot do: an <img> ignores `currentColor`, so `.active`'s accent never reaches
36
- // it. It is not replaced with anything: the title strip already closes on this cut and opens off
37
- // it, and a mark is an identity rather than a UI state. Slot presence adds `.tb-mark`, which the
38
- // sizing rule uses.
36
+ // it. Slot presence adds `.tb-mark`, which the sizing rule uses.
39
37
  // • langPicker — needs the persona's own locale data (the list must NOT live in the shared package —
40
38
  // it bit us twice; see @justai/ui/locale) → a slot the persona fills with its <LangPicker/>.
41
39
  // • navExtra — optional extra nav item after Posts (e.g. the timers' fullscreen button) → slot.
42
40
  // • toolbarRight — optional right-side control before the picker (e.g. time's 24H/AM-PM toggle) → slot.
43
41
  //
44
42
  // CONTRACT with the page (the same three cut ids the whole shell agrees on): the scroll-spy needs a
45
- // `#below` wrapper around toolbar+kernel+posts, a `<section id="kernel">` between `#profile` and
46
- // `#posts`, and reads `.pname`/`.handle` from the Profile for the title strip. It touches NO kernel
47
- // internals. It is also the page's REVEAL: it sets `body.ready` (the other half of @justai/core's
43
+ // `#below` wrapper around toolbar+kernel+posts and a `<section id="kernel">` between `#profile` and
44
+ // `#posts`. It touches NO kernel internals. It is also the page's REVEAL: it sets `body.ready` (the other half of @justai/core's
48
45
  // REVEAL_CSS) — so every persona that renders a Toolbar is revealed; move it and the page stays blank.
49
46
  interface Props {
50
47
  sectionsLabel: string; // aria-label for the section-nav group
@@ -72,22 +69,6 @@ interface Props {
72
69
  // Posts is required only where there IS a stream (doctrine 02 — an empty cut shipped to look
73
70
  // complete is the buffet). A child persona has none, so `parent` turns this off by default.
74
71
  posts?: boolean;
75
- // What the title strip says when the Kernel or the Posts cut is the one at the toolbar line.
76
- //
77
- // OMIT THEM AND NOTHING MOVES: the strip keeps naming the persona and its handle, which is right
78
- // almost everywhere. On a leaf the kernel IS the persona — the strip reading "QR Code" while you
79
- // use qr replaces the profile header that scrolled away, and "App" would be strictly less.
80
- //
81
- // app.zone is the exception the pair exists for, and the reason is structural rather than a matter
82
- // of taste: it is the one persona whose kernel is NOT itself. Its kernel is a catalogue of the
83
- // others, and its profile header — a few pixels above — already says app.zone. So the strip spends
84
- // its only line repeating what the visitor can see. There it says "Apps" and "Posts" instead.
85
- //
86
- // BOTH OR NEITHER, deliberately. Half an override would need this file to reproduce the other
87
- // half's default, which means copying core's `.pname` / `.handle` selectors into a second place
88
- // that nothing keeps in step. The strip is one line and its vocabulary should be one kind of
89
- // thing — names or sections, not one of each — so the API says so out loud.
90
- kernelTitle?: string;
91
72
  /**
92
73
  * Which cut a visit with no hash opens on. Defaults to the kernel, which is right for every
93
74
  * persona whose kernel is the thing it does.
@@ -99,7 +80,6 @@ interface Props {
99
80
  * markup, and the behaviour reads the markup rather than being handed a closure.
100
81
  */
101
82
  landOn?: string;
102
- postsTitle?: string;
103
83
 
104
84
  // queries: toolbar shows a generic person icon · the profile icon is not my persona ·
105
85
  // how do I put my own mark in the toolbar · avatar
@@ -171,7 +151,7 @@ interface Props {
171
151
  import BackToZone from "./BackToZone.astro";
172
152
  const {
173
153
  sectionsLabel, profileLabel, kernelLabel, postsLabel,
174
- files = true, filesLabel = "Files", kernelTitle, postsTitle, landOn, avatar = "/icon.svg", parent,
154
+ files = true, filesLabel = "Files", landOn, avatar = "/icon.svg", parent,
175
155
  } = Astro.props;
176
156
  // A child's row answers UP to its parent, not out to the zone, and has no stream of its own — so
177
157
  // both default off the moment `parent` is given, rather than each consumer remembering to say so.
@@ -179,11 +159,11 @@ const { home = !parent, posts = !parent, apex = "app.zone" } = Astro.props;
179
159
  import AuthMount from "./AuthMount.astro";
180
160
  ---
181
161
 
182
- <!-- The two titles reach the script as data attributes rather than through `define:vars`, which would
162
+ <!-- `landOn` reaches the script as a data attribute rather than through `define:vars`, which would
183
163
  make this script inline and it has an import. Astro drops an attribute whose value is undefined,
184
- so a persona that passes neither leaves no trace in the markup at all. -->
164
+ so a persona that passes nothing leaves no trace in the markup at all. -->
185
165
  <div class="tb-slot">
186
- <header class="toolbar" data-kernel-title={kernelTitle} data-posts-title={postsTitle} data-land-on={landOn}>
166
+ <header class="toolbar" data-land-on={landOn}>
187
167
  <div class="tb-row">
188
168
  <div class="tb-left">
189
169
  {home && <BackToZone apex={apex} />}
@@ -297,7 +277,6 @@ import AuthMount from "./AuthMount.astro";
297
277
  <AuthMount />
298
278
  </div>
299
279
  </div>
300
- <div class="tb-title" id="tbTitle"><span class="tb-title-text" id="tbTitleText"></span></div>
301
280
  </header>
302
281
  </div>
303
282
 
@@ -305,64 +284,22 @@ import AuthMount from "./AuthMount.astro";
305
284
  /* ---- app toolbar — sticky to the top of the active region (kernel + posts); it never
306
285
  enters the Profile (it lives inside .below). Flat / knife-cut: a left group and a
307
286
  right group, no rounded pills. ---- */
308
- /* queries: tb-slot · why is the toolbar wrapped · the bar changes height · the kernel is 26px
309
- * short · a band of the feed at the bottom · the toolbar reserves a box · sticky flow
287
+ /* queries: tb-slot · why is the toolbar wrapped · the gap under the toolbar · the band under the
288
+ * bar · the toolbar reserves a box · secondary line · title strip
310
289
  *
311
- * ── THE SLOT IS WHAT FLOW SEES; THE BAR IS WHAT THE EYE SEES ──────────────────────────────
290
+ * ── THE SLOT IS STICKY; THE BAR IS STATIC INSIDE IT, AND THEY ARE THE SAME HEIGHT ──────────────
312
291
  *
313
- * `position: sticky` NEVER LEAVES FLOW. It reserves its full border box and paints inside it — so
314
- * the bar's LIVE height positioned everything under it (52 with the title strip shut, 78 open)
315
- * while `--shell-top` was deliberately frozen at the open height so that nothing would sample the
316
- * 0.35s animation. The HEIGHT stopped sampling it. THE POSITION NEVER DID. Two numbers that must
317
- * be equal, written a hundred lines apart in two packages, and their difference is a band of the
318
- * next section above the fold: measured on the live persona with the scroll untouched, the kernel
319
- * sat 52px below `#below` instead of 78 and 25.81px of the feed was on screen.
292
+ * There used to be a second line under the row — a title strip that opened over 0.35s when the
293
+ * kernel or the posts reached the bar. `position: sticky` never leaves flow, so the strip moved the
294
+ * whole page by 26px each time it opened or closed; the slot was given a fixed reserved box so it
295
+ * would not, and that box was 26px of empty bar under the row whenever the strip was shut — the
296
+ * band at the profile on every persona. The founder struck the strip on 2026-10-12: *"o boşluk
297
+ * oluşuyor ve bundan nefret ediyorum."* The active icon already says which cut you are on, and
298
+ * the name the strip repeated sits one line above in the persona's own mark.
320
299
  *
321
- * The wrapper separates them. The SLOT is sticky and its box is `--shell-top`; the BAR is static
322
- * inside it and may be any height it likes. Nothing above the kernel changes size any more, so
323
- * there is nothing for an engine to compensate for — which is also why only a device ever showed
324
- * it: Blink scroll-anchors the step away and WebKit has no scroll anchoring. */
300
+ * With one row the bar's height no longer changes, so the slot needs no box of its own: it is
301
+ * exactly the bar. It stays because it is the sticky element the cut's contract names. */
325
302
  .tb-slot { position: sticky; top: 0; z-index: 40; display: flow-root; }
326
- /* queries: the title strip moves the fold · the page drifts after it lands · a band of posts under
327
- * the game · tb-slot min-height · the slot follows the bar · the strip pushes the page down
328
- *
329
- * ── THE BOX IS THE SLOT'S OWN, SO IT TRAVELS WITH THE SLOT ─────────────────────────────────────
330
- *
331
- * `position: sticky` never leaves flow: it reserves its full border box, so whatever the bar's LIVE
332
- * height is at any instant, that is where everything under it sits. The strip opens and closes over
333
- * 0.35s inside that box — so without a reserved height the shell breathes 26px and the whole
334
- * document below it breathes with it. That is the drift the founder lost days to.
335
- *
336
- * IT LIVED IN `@justai/ui`'s base.css UNTIL 2026-08-06, and measured on pin.games that was the same
337
- * as not existing: a persona that never imports base.css got the slot's structure from here and its
338
- * BOX from a file it does not load, so the slot collapsed to the bar and posts moved 26px between
339
- * the kernel and the profile. The first law again — a component brings its own ground — and the
340
- * slot is this component's element, so its box is this component's business. The rule that keeps
341
- * the whole strip safe cannot be the one rule that lives somewhere the strip does not.
342
- *
343
- * AND THE RESERVED BAND WEARS THE BAR'S OWN MATERIAL, not the room's. A slot taller than its bar is
344
- * 26px of SOMETHING under the shelf whenever the strip is shut — the state at the profile, on
345
- * screen, at rest. Painted in the persona's ground it was a hard black band between a light profile
346
- * and a light kernel: the founder photographed it within a minute. It is the BAR'S box, so it takes
347
- * the bar's fill, bleeding by the same `--edge` expression so the two cannot leave slivers. */
348
- .tb-slot { min-height: var(--shell-top, 78px); }
349
- .tb-slot::before {
350
- content: "";
351
- position: absolute;
352
- z-index: -1;
353
- inset-block: 0;
354
- inset-inline:
355
- calc(-1 * max(var(--edge, 16px), env(safe-area-inset-left)))
356
- calc(-1 * max(var(--edge, 16px), env(safe-area-inset-right)));
357
- background: var(--toolbar-bg, #1e202a);
358
- }
359
- /* The shelf's fill has two branches and so must its box: `--toolbar-bg` is one token but its
360
- FALLBACK differs by scheme, and a band carrying only the dark default is a black strip on a light
361
- page — which is exactly what the founder photographed a minute after the first attempt. */
362
- @media (prefers-color-scheme: light) {
363
- /* Guarded like every light fork in the fleet: data-theme="dark" wins over the media query. */
364
- :global(html:not([data-theme="dark"])) .tb-slot::before { background: var(--toolbar-bg, #fbfcfe); }
365
- }
366
303
  .toolbar {
367
304
  /* position, top and z-index live on .tb-slot above — this element is static inside it now. */
368
305
  display: flex; flex-direction: column;
@@ -415,56 +352,7 @@ import AuthMount from "./AuthMount.astro";
415
352
  has nothing to paint now that the shelf is opaque and always shadowed. Kept because the class is
416
353
  part of this cut's contract and a consumer may hang its own behaviour on it. */
417
354
  @media (min-width: 640px) { .toolbar { margin-inline: -28px; padding-inline: 28px; } }
418
- /* icons row + the scroll-reactive title strip below it */
419
355
  .tb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
420
- /* queries: --tb-strip · the title strip's height · the toolbar changes height · 26px ·
421
- * the kernel is 26px short · the game frame does not reach the fold
422
- *
423
- * ── THE ONE NUMBER THAT MAKES THIS ROW'S HEIGHT A MOVING TARGET, DECLARED SO IT CAN BE READ ──
424
- *
425
- * The strip opens and closes over 0.35s, so `.toolbar`'s height is anywhere in a 26px range at any
426
- * given moment — and anything that measures the row and feeds a LAYOUT with it is sampling an
427
- * animation. `--shell-top` did exactly that and the kernel's height came out 26px short whenever
428
- * the page happened to be measured with the strip open, which is to say whenever somebody opened
429
- * the persona at #kernel. It reads as a strip of the next section showing above the fold.
430
- *
431
- * So the number is a custom property rather than a literal: this rule uses it, and @justai/core's
432
- * `personaScroll` reads it to compute the row's EXPANDED height without waiting for or racing the
433
- * transition — `toolbar - strip` is the row, and the row plus this is what the bar will be. One
434
- * declaration, two readers, and nothing samples a moving value. */
435
- .toolbar { --tb-strip: 26px; }
436
- /* queries: the bar grows after load · why does the strip animate on arrival · data-motion ·
437
- * first paint animation · can this transition wait for the first interaction
438
- *
439
- * ── THE TRANSITION IS ALSO A DAMPER, WHICH IS WHY IT CANNOT SIMPLY WAIT ─────────────────────
440
- *
441
- * The founder asked the obvious and right question: *"şu geçiş süresi güzel — ama neden bu özellik
442
- * kullanıcının ilk interaction'ından sonra aktif edilmiyor?"* A persona lands on its kernel, so the
443
- * strip's correct state on the first frame is open; animating into it grows the bar 26px just after
444
- * the page appears, which is a glitch nobody asked for.
445
- *
446
- * IT WAS TRIED, AND IT BREAKS SOMETHING ELSE. Gated behind a `data-motion` flag set on the first
447
- * pointer/key/wheel, the strip snaps open during the landing — and that 26px arrives in a SINGLE
448
- * frame, inside `#below`, while the scroll spy is deciding which cut is active. That is the
449
- * bistable oscillator `personaScroll` documents at length: the spy measures a page its own output
450
- * moved. Measured: with the gate in place, `#navProfile` no longer navigates — the page re-asserts
451
- * the kernel and stays. The 0.35s is not only easing; it is what keeps that step out of one frame.
452
- *
453
- * SO THE HEIGHT IS NEVER READ INSTEAD. `--shell-top` is computed as `row + --tb-strip` — the row
454
- * being the bar minus whatever the strip currently is, which is the same number at every frame of
455
- * the transition. Nothing samples the animation, so nothing depends on when it was sampled.
456
- *
457
- * The real fix for the founder's question is a toolbar whose height does not change at all: reserve
458
- * the strip's space always and fade only its content. That removes the feedback at the source and
459
- * makes the gate free — and it is a fleet-wide design decision about how the bar looks at the
460
- * profile, so it is his to take rather than one to slip in. */
461
- .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); }
462
- /* The fallback is not decoration: the token is declared on `.toolbar` and consumed here, which is
463
- a different selector — so the framework's own token spec reads it as ungrounded and is right to.
464
- A rule that silently collapses to `height: 0` would close the strip and say nothing. */
465
- .tb-title.show { height: 26px; height: var(--tb-strip, 26px); opacity: 1; }
466
- .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); }
467
- .tb-title-text.fade { opacity: 0; }
468
356
  /* nav lives on the left, beside the back affordance — the middle is intentionally empty */
469
357
  .tb-left { display: flex; align-items: center; gap: 2px; min-width: 0; }
470
358
  .tb-nav-group { display: flex; align-items: center; gap: 2px; }
@@ -8,18 +8,18 @@
8
8
  // Out here a compiler can open it and a test can call it. What stays in the component is the markup
9
9
  // and the CSS, which is what a component is.
10
10
  // queries: where does the toolbar's scroll behaviour live · land on the kernel · scroll spy ·
11
- // title strip · why is the scroll code not in this file any more
11
+ // why is the scroll code not in this file any more
12
12
  //
13
13
  // The BEHAVIOUR moved to @justai/core (`personaScroll`); what stays here is the markup and the CSS.
14
14
  //
15
15
  // It moved because this cut can only serve pages that adopt its markup, and three surfaces cannot:
16
16
  // `/me` and `/files` carry extra navigation of their own and the feed is a different build. All
17
17
  // three hand-wrote the same eighty lines, and by the third copy they had drifted — `/files` opened
18
- // on the profile with an empty title strip, and its hash writer clobbered the query string where
19
- // this one preserved it. One implementation, four consumers.
18
+ // on the profile, and its hash writer clobbered the query string where this one preserved it. One
19
+ // implementation, four consumers.
20
20
  //
21
- // The defaults ARE this cut's contract — the three cut ids, `nav<Cut>`, `#tbTitle`/`#tbTitleText`,
22
- // `.toolbar`, `#below` — so a persona using the shell passes nothing at all.
21
+ // The defaults ARE this cut's contract — the three cut ids, `nav<Cut>`, `.toolbar`, `#below` — so a
22
+ // persona using the shell passes nothing at all.
23
23
  import { personaScroll, adoptMark } from "@justai/core";
24
24
 
25
25
  // queries: the parent icon in a child toolbar has a background · navParent tile · the child's row
@@ -52,9 +52,6 @@ if (document.getElementById("navParent")) adoptMark({ id: "navParent" });
52
52
  const ownMark = document.querySelector("#navProfile img");
53
53
  if (ownMark) adoptMark({ id: "navProfile" });
54
54
 
55
- // Both or neither — see the props above. When both are set the strip names the SECTIONS; core's
56
- // default, which names the persona and its handle, is left entirely alone otherwise. Nothing here
57
- // reproduces that default, which is the point: `.pname` and `.handle` stay core's business.
58
55
  // ── the stage: the row stays LIVE, and using it pauses the game ───────────────────────────
59
56
  // queries: toolbar during a game · tapping posts while playing · auto pause · data-stage-silent
60
57
  //
@@ -80,15 +77,7 @@ document.querySelector(".toolbar")?.addEventListener(
80
77
  );
81
78
 
82
79
  const bar = document.querySelector(".toolbar") as HTMLElement | null;
83
- const kt = bar?.dataset.kernelTitle;
84
- const pt = bar?.dataset.postsTitle;
85
- // `landOn` travels as a data attribute for the same reason the two titles do: this file runs in a
86
- // page it did not render, so the markup is the interface. Undefined when the attribute is absent,
87
- // which is the option's default — the kernel.
80
+ // `landOn` travels as a data attribute: this file runs in a page it did not render, so the markup is
81
+ // the interface. Undefined when the attribute is absent, which is the option's default — the kernel.
88
82
  const landOn = bar?.dataset.landOn;
89
- personaScroll({
90
- ...(kt && pt
91
- ? { titleFor: (id: string) => (id === "kernel" ? kt : id === "posts" ? pt : "") }
92
- : {}),
93
- ...(landOn ? { landOn } : {}),
94
- });
83
+ personaScroll(landOn ? { landOn } : {});
package/src/ground.css CHANGED
@@ -149,7 +149,7 @@
149
149
  /* What the shell takes above the kernel — see @justai/ui base.css, which measures it. Ground only:
150
150
  `:where()` is specificity zero, so a consumer that ships the shell always wins, and a surface that
151
151
  ships no shell still lands its cuts clear of a sticky bar instead of under one. */
152
- :where(:root) { --shell-top: 78px; }
152
+ :where(:root) { --shell-top: 52px; }
153
153
 
154
154
  :where(html) {
155
155
  background: var(--bg);