studio-engine 0.62.6 → 0.63.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa64bc8c9da93f3beaede057f50b905755a4d62e549aa83d32d154fd6f2dbf4c
4
- data.tar.gz: 9e3fc2011c4cada24b7d93f08a3e4e54fad76df242a8e3986b83bb0e8a742126
3
+ metadata.gz: 7fcb787be0caeb8c975281d334f56a0d0ce699dbd25851b59948de0e5c6a87a4
4
+ data.tar.gz: a0b21daec66caf0110d3fc6f33f311a678b4216bda5b7d033011d84465ec54d3
5
5
  SHA512:
6
- metadata.gz: 842b7b8f102010ef77ee303a763c42dfceb047ccb3d8cdce2b06e44a95d97fc8ca91a03207eecb6b0c9340f9434baf071ad6914e6cc7f6910a8889f8300b2a8e
7
- data.tar.gz: dea3a8a030028885d614787882af0ceaec4bda5a50a175e863f5a2144d44b3f958a9d9d2594c7dee592365af0636e748239ed1b11579bf92bc91ae28da0d637d
6
+ metadata.gz: 26fdea81fbf8edcb85f0e6121c9bfa8b15dc34cf2b9166ebdd3cf158e2c09636d6ea5401baf203f293f6c2b2a866a2ddecdeacdd326c85bb3df6fa058af16800
7
+ data.tar.gz: 2493f082d861b7a8dadf60963df93c09aac10da94b6f0a7064644600b7da5bbd0079d0b58d4d1d1dbda717881f63af9fc53a5fdb9eab0c0ad644c1e7eaf5fb0b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Changed
8
+
9
+ - **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
10
+ frame.** Three defects in the primitive as first landed, none of which a
11
+ consuming app should inherit:
12
+
13
+ **The band table shipped from the wrong file.** The `.nav-shell` `--nav-*`
14
+ sizes were inline in `layouts/_navbar.html.erb`, so only an app rendering that
15
+ partial got them — and **three of six apps fork the navbar**
16
+ (`turf-monster`, `mcritchie-studio`, `moms-app`), each left to hand-write its
17
+ own. That is precisely how four independent copies of this collapse came to
18
+ exist. They now ship from `engine.css`, which every engine-consuming app
19
+ imports, inside `@layer utilities` so a consumer's own layer still wins on
20
+ source order. The primitive is now three layers, each on a channel reaching
21
+ further than the last: **mechanism** in `layouts/studio/_head` (every app
22
+ renders it) → **sizes** in `engine.css` (every app imports it) → **markup** in
23
+ `layouts/_navbar` (only apps that do not fork). Adopting the collapse in a
24
+ hand-written header is `nav-shell` + `x-data="navCollapse()"` plus overrides
25
+ for only the endpoints that differ.
26
+
27
+ **A flick collapsed the header in one frame.** Progress is a pure function of
28
+ scroll position, so a scrollY jump of 90px moved the header's whole range with
29
+ it. Measured in `turf-monster` at 390×844 — 8px/frame walked 3.9px of header,
30
+ 24px/frame 14.1px, a momentum flick **39.0px, the entire 178 → 139 collapse**.
31
+ Found on a phone, by a person, with every tier here green. `navCollapse` now
32
+ clamps the per-frame step: `maxStep = 3 × --nav-max-step ÷ --nav-ramp`,
33
+ derived from the ramp's documented 3× relation to the collapse total, so one
34
+ number (`5px`) covers every band. **A slow scroll never reaches the clamp**, so
35
+ the deliberate case is not approximated — it is the same arithmetic. The snap
36
+ paths (short-page guard, `prefers-reduced-motion`) bypass it, because each is a
37
+ decision rather than motion. An unfinished clamp schedules its own frame; once
38
+ the finger lifts nothing else will, and the collapse would otherwise freeze
39
+ where the clamp left it.
40
+
41
+ **The geometry publisher had become per-frame.** The `--nav-h` / `--nav-bottom`
42
+ `ResizeObserver` called `publish()` directly rather than through its own rAF
43
+ coalescer. That was fine while the header only resized during a 300ms
44
+ transition; the collapse made it resize every scroll frame, each one forcing
45
+ layout and then writing two **inherited** custom properties on
46
+ `documentElement`. Measured at 6× CPU throttle: frames over 20ms were **13/24
47
+ through the ramp vs 0/24 past it** (median 26ms vs 8ms), and ablating the
48
+ observer alone gave 0/24 and median 13ms — it cost ~2.6× the reflow it was
49
+ reacting to. It now routes through `schedule()`, both reads are hoisted above
50
+ both writes (a read after a write to `documentElement` forces a fresh layout),
51
+ and an unchanged value skips its write entirely.
52
+
53
+ `nav_offset_contract_test` was rebound rather than relaxed: it now captures the
54
+ local each source is read into and requires *that* local to be the one written,
55
+ so the source-swap mutation it was written for still dies, and it gained a
56
+ guard on the read-before-write order.
57
+
58
+
7
59
  ### Breaking
8
60
 
9
61
  - **The age-gate DOB modal is renamed, and the refusal moved to its own card.**
@@ -73,6 +125,23 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
73
125
  absent rather than rendered dead) and a back link that returns to the birthday
74
126
  card. Displays `min_age` / `state`; computes no eligibility of its own.
75
127
 
128
+ - **`studio/modals/_wallet_connect` — the Connect Wallet picker, engine-owned.**
129
+ The reown-style wallet chooser existed three times before this — turf-monster's
130
+ 226 lines, mcritchie-studio's 107 and the style guide's 176 — sharing no code.
131
+ It is one partial now, and `style/modals/_wallet_connect` CONFIGURES it rather
132
+ than porting it, so the specimen and production cannot drift again. Locals:
133
+ `store` (default `"modals"`), `connect_fn` (default `"solanaConnectAndVerify"`),
134
+ `title`, `extra_data` (extra x-data members as a brace-less JS fragment), plus a
135
+ BLOCK for the pre-connect slot. App behaviour arrives as optional hook METHODS
136
+ defined in `extra_data`, each called only if it exists: `onInit`, `canPick`,
137
+ `verifyArgs`, `onConnected(result)`, `onDeepLink`, `onBack`. Carries the mobile
138
+ Phantom single-row fix (one Phantom row in every state) and a `role="alert"` on
139
+ the connect error that both app copies lacked.
140
+
141
+ **NO SHIPPING CONSUMER RENDERS IT YET.** Both apps still ship their own copy and
142
+ adopt this one after it releases, so the green consumer CI here says they still
143
+ compile — it says nothing about this partial.
144
+
76
145
  ### Fixed
77
146
 
78
147
  - **The engine navbar's collapse tracks the finger instead of a clock.**
data/README.md CHANGED
@@ -238,6 +238,31 @@ the block:
238
238
  <% end %>
239
239
  ```
240
240
 
241
+ **Connect Wallet — `studio/modals/wallet_connect`.** The reown-style wallet
242
+ picker, engine-owned so the apps stop each keeping a copy. Register it like any
243
+ other modal and configure it with locals:
244
+
245
+ ```erb
246
+ <template x-if="$store.modals.current().id === 'wallet-connect'">
247
+ <%= render "studio/modals/wallet_connect" %>
248
+ </template>
249
+ ```
250
+
251
+ Locals: `store` (default `"modals"`), `connect_fn` (the window function that
252
+ connects AND verifies, default `"solanaConnectAndVerify"`), `title`, `slot` /
253
+ `slot_locals` (a partial rendered before the wallet rows — an app's legal-age
254
+ attestation goes here), and `extra_data` (a JS fragment merged into the
255
+ component's `x-data`).
256
+
257
+ App behaviour arrives as optional **hook methods** defined in `extra_data`, each
258
+ called only when present: `onInit`, `canPick` (falsy aborts a pick or a deep
259
+ link), `verifyArgs` (merged into the connect options), `onConnected(result)`,
260
+ `onDeepLink`, `onBack`. An app that needs none passes no `extra_data`.
261
+
262
+ The slot is a **named local, not a block** — `block_given?` is always true inside
263
+ a compiled Rails partial, so a block-shaped slot yields the captured page body
264
+ into the card whenever no block is passed.
265
+
241
266
  **Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
242
267
  own modals (because not every consuming app renders a shared host, and the ones
243
268
  that do register their own modal set), render a second host on its own Alpine
@@ -323,6 +348,42 @@ against this (unknown keys and gutted registries fall back to the built-in
323
348
  `'pop'`), but your other custom keys are lost unless the late script merges
324
349
  into the existing object instead of assigning over it.
325
350
 
351
+ `window.StudioModals.CARD_WIDTHS` is the **card-width registry**, and it works
352
+ exactly like the animation one: define it **before** the host renders and your
353
+ entries merge over the engine defaults. Keys are modal ids, values are the
354
+ Tailwind `max-w-*` class that card should use. `DEFAULT_CARD_WIDTH` (default
355
+ `max-w-sm`) covers every id you don't name:
356
+
357
+ ```html
358
+ <script>
359
+ window.StudioModals = window.StudioModals || {};
360
+ window.StudioModals.CARD_WIDTHS = { 'wallet-setup': 'max-w-md' };
361
+ </script>
362
+ ```
363
+
364
+ By modal **id** rather than by prop on purpose: a card opened from several
365
+ places would have to carry the prop at every opener, and one miss renders the
366
+ same card at two widths depending on how the user got there. The width is
367
+ resolved inside `cardClasses()`, so the card element carries no static
368
+ `max-w-*` — don't add one, or the winner is left to stylesheet source order.
369
+
370
+ **App-wide registrations — `app/views/modals/_host_extras.html.erb`.** The
371
+ block above registers modals per *call site*. An app that renders the host from
372
+ more than one layout (a live one and, say, an `/admin` preview harness) would
373
+ have to repeat every registration in each. Define this optional partial instead
374
+ and the host renders it inside the card on **every** render path:
375
+
376
+ ```erb
377
+ <%# app/views/modals/_host_extras.html.erb %>
378
+ <template x-if="$store.modals.current().id === 'cosign-rejected'">
379
+ <%= render "modals/cosign_rejected" %>
380
+ </template>
381
+ ```
382
+
383
+ It's a convention, not a local — an app that ships no such file renders nothing
384
+ and needs no call-site change. Use it for modals that belong to the *app*; keep
385
+ page-specific ones in the block where their call site can see them.
386
+
326
387
  `window.StudioModals.holdAtLeast(ms)` returns a thenable that resolves no
327
388
  sooner than `ms` after creation — stamp it when a processing view becomes
328
389
  visible so fast operations don't flash the spinner.
@@ -25,7 +25,10 @@
25
25
  display: none;
26
26
  overflow: hidden;
27
27
  pointer-events: none;
28
- z-index: 45;
28
+ /* --z-sticky: it fakes a sticky <thead>, so a side panel or a menu opened over
29
+ the table still covers it. The old bare 45 sat between the panel and dropdown
30
+ tiers for no recorded reason. */
31
+ z-index: var(--z-sticky, 30);
29
32
  }
30
33
 
31
34
  .sticky-table-header-clone table {
@@ -1137,7 +1137,30 @@
1137
1137
  ========================================================================== */
1138
1138
 
1139
1139
  /* Scroll lock applied by the store's _sync() when the stack is non-empty. */
1140
- body.modal-open { overflow: hidden; }
1140
+ /* THE SCROLL LOCK GOES ON THE ELEMENT THAT ACTUALLY SCROLLS.
1141
+ Measured on a consumer, 2026-08-27, before this change: with a modal open a
1142
+ real wheel gesture still scrolled the page (600px → 1000px) and the sticky
1143
+ header unpinned and slid away with it. The lock had been inert there for as
1144
+ long as the link sidebar had shipped.
1145
+
1146
+ `body { overflow: hidden }` locks the viewport only when it PROPAGATES to
1147
+ it, and body's overflow propagates only while `html` is `overflow: visible`.
1148
+ This gem's own link-sidebar partial sets `html { overflow-x: clip }` so the
1149
+ off-canvas panel never flashes a scrollbar — correct on its own terms, clip
1150
+ creates no scroll container — but it ends the propagation. The lock then
1151
+ stopped locking the viewport and started making BODY a scroll container
1152
+ instead: one holding scrollTop 0 forever while the viewport scrolled past
1153
+ it. Every `position: sticky` child of body then has a scrollport that never
1154
+ moves, which is why the header unpinned and why the modal-open banner lift
1155
+ had nothing to pin against.
1156
+
1157
+ So lock `html`, and put body back to `visible` so it is not a second scroll
1158
+ container. An app that sets no overflow on html is unaffected — locking html
1159
+ is correct there too; it was simply never the only thing that worked.
1160
+
1161
+ :has() is the trigger because the class lands on body, not html. */
1162
+ html:has(body.modal-open) { overflow: hidden; }
1163
+ body.modal-open { overflow: visible; }
1141
1164
 
1142
1165
  /* Drain-bar keyframe — a translucent overlay sweeping a CTA button over its
1143
1166
  countdown (studio/modals/blocks/_cta_redirect + _success_card cta_drain). */
@@ -28,6 +28,91 @@
28
28
  their local copies for this import.
29
29
  */
30
30
 
31
+ /* -- Layer scale ------------------------------------------------------------
32
+ The ONE stacking order every Studio app shares. Read the ORDER, not the
33
+ number: the tier is the contract, the integer is an implementation detail.
34
+
35
+ BELOW 100 is page furniture that never blocks another layer, and those tiers
36
+ line up with Tailwind's own z-10..z-50 — `z-20` and var(--z-raised) are the
37
+ same rung, so either spelling is fine down here.
38
+
39
+ AT 100 AND UP every tier is chrome that can COVER other chrome, and that is
40
+ where a raw integer stops being readable. The defect this scale was built
41
+ from: a docked bottom bar at inline `z-index:9999` painting over a modal
42
+ backdrop at `z-[120]`, with 9,798 unclaimed values between them and no
43
+ ladder anywhere that said which should win. Three layers were tied at 130
44
+ and two at 10000, each tie resolved by source order rather than by anyone's
45
+ decision.
46
+
47
+ Consumers override a TIER in their own :root (loaded after this file) — the
48
+ same seam --studio-toast-z has always had. Override the tier, never the call
49
+ site: a bespoke number at a call site is how the ladder rotted the first time.
50
+ */
51
+ :root {
52
+ --z-behind: -1; /* decoration painted behind its own box */
53
+ --z-base: 0; /* fixed page backgrounds and their scrims */
54
+ --z-content: 10; /* raised content inside a card */
55
+ --z-raised: 20; /* hover lifts, in-page click catchers */
56
+ --z-sticky: 30; /* in-page sticky headers, bars pinned under the nav */
57
+ --z-panel: 40; /* in-page side panels */
58
+ --z-dropdown: 50; /* menus and anchored popovers */
59
+
60
+ --z-docked: 100; /* docked bottom bar — must never cover a modal */
61
+ --z-nav: 110; /* the pinned navbar */
62
+ --z-drawer: 150; /* full-cover drawers (link sidebar, gear sidebar) */
63
+ --z-modal: 200; /* modal backdrop + card — THE app blocker */
64
+ --z-lightbox: 210; /* a lightbox opened from inside a modal */
65
+ --z-alert: 300; /* transient overlays that must not be missed */
66
+ --z-toast-blur: 399; /* the frosted halo behind the toasts */
67
+ --z-toast: 400; /* toasts */
68
+ --z-banner: 500; /* environment / DEV MODE — reachable mid-modal */
69
+ --z-tooltip: 600; /* tooltips on the banner, so they clear it */
70
+ }
71
+
72
+ /* The environment / impersonation bars stay in NORMAL FLOW at rest
73
+ (banners/_stack records why: pinning them in the RESTING state needs one
74
+ pinned sibling to measure the other, and the measured version visibly
75
+ jumped). Flow costs them a stacking level, so the modal backdrop dims the DEV
76
+ MODE and Email buttons exactly when a QA session needs them.
77
+
78
+ So both the level AND the pin are scoped to `body.modal-open` — set by every
79
+ modal host in this repo and by the consumer forks of it, and the one state
80
+ where each is free:
81
+
82
+ · the LEVEL is free because that class also locks body scroll
83
+ (engine-motion.css), so the bars can never scroll up behind the pinned
84
+ navbar while they outrank it;
85
+ · the PIN is free because a frozen page cannot jump. The measurement
86
+ problem banners/_stack records is a RESTING-state problem — two pinned
87
+ siblings of unknown height must lay out adjacently, so one has to measure
88
+ the other. Here they deliberately OVERLAP: the navbar is dimmed under a
89
+ backdrop and is not the reader's business.
90
+
91
+ With no modal open the bars are unpositioned, exactly as before.
92
+
93
+ sticky, NOT fixed, and the difference is load-bearing. A LEVEL ALONE IS NOT
94
+ THE FIX: the first cut used `position: relative`, which escapes z-index:auto
95
+ and reads correctly at the top of a page — and leaves the bars exactly where
96
+ they were for a reader who has scrolled, which is every reader who did
97
+ anything before opening a modal. Measured on a consumer board at scrollY 900
98
+ with the modal open: relative put the stack at top -900, off screen; sticky
99
+ put it at top 0. `fixed` also pins, but pulls the stack out of flow and the
100
+ page jumps up by its height — sticky keeps the space reserved, so applying it
101
+ on open shifts nothing (document height identical before and after, measured).
102
+
103
+ THE ONE REQUIREMENT ON THE CONSUMER: render the bars as a SIBLING of the
104
+ navbar, before it — not inside <header>. A pinned header carrying a z-index
105
+ is a stacking context, and a descendant's z-index is clamped inside it, so a
106
+ bar nested in the header composites at the HEADER's level no matter what it
107
+ asks for. --nav-bottom already reports the header's live bottom edge for
108
+ anything that needs to sit under chrome stacked above it. */
109
+ body.modal-open .studio-bar-stack,
110
+ body.modal-open .studio-app-banner {
111
+ position: sticky;
112
+ top: 0;
113
+ z-index: var(--z-banner, 500);
114
+ }
115
+
31
116
  /* -- Surfaces -------------------------------------------------------------- */
32
117
 
33
118
  @utility card {
@@ -193,12 +278,16 @@
193
278
 
194
279
  /* -- Link sidebar (out-of-the-box navigation) ------------------------------
195
280
  Paired with components/_link_sidebar + _link_sidebar_trigger and the
196
- Studio.sidebar_sections config. The layer sits above the sticky header
197
- (z-50) and the modal host. Inert without the panel in the DOM, so apps
198
- that declare no sections are unaffected. */
281
+ Studio.sidebar_sections config. A full-cover drawer, so it takes the shared
282
+ --z-drawer tier: above the navbar and any docked bottom bar, BELOW the modal
283
+ host. It used to be a bare 10000, which put it over every modal and tied it
284
+ exactly with turf-monster's gear sidebar — two unrelated drawers on one
285
+ number, resolved by source order. A modal opened FROM a drawer is the newer
286
+ task and should win. Inert without the panel in the DOM, so apps that declare
287
+ no sections are unaffected. */
199
288
 
200
289
  @utility studio-link-sidebar-layer {
201
- z-index: 10000;
290
+ z-index: var(--z-drawer, 150);
202
291
  }
203
292
 
204
293
  /* Alpine's pre-init cloak. Engine partials (sidebar panels, dropdowns, the
@@ -283,3 +372,118 @@
283
372
  animation: none !important;
284
373
  }
285
374
  }
375
+
376
+ /* === THE NAVBAR COLLAPSE BAND TABLE =====================================
377
+ LAYER 2 OF THE PRIMITIVE, and it lives here rather than in
378
+ layouts/_navbar.html.erb for one reason: every engine-consuming app imports
379
+ this stylesheet, but only some render that partial. Three of six FORK the
380
+ navbar (turf-monster, mcritchie-studio, moms-app), and while the table sat
381
+ inline each fork had to hand-write its own — which is exactly how four
382
+ independent copies of this collapse came to exist.
383
+
384
+ The primitive is three layers, each on a channel that reaches further than
385
+ the last:
386
+
387
+ 1. MECHANISM layouts/studio/_head @property --nav-p + navCollapse()
388
+ — every app renders it
389
+ 2. SIZES this file the .nav-shell band table
390
+ — every app imports it
391
+ 3. MARKUP layouts/_navbar the header itself
392
+ — only apps that do not fork
393
+
394
+ So adopting the collapse in a hand-written header is: put `nav-shell` and
395
+ x-data="navCollapse()" on it, and override only the endpoints that differ
396
+ from the defaults below.
397
+
398
+ LAYERED ON PURPOSE. These are DEFAULTS, so a consuming app has to be able to
399
+ beat them, and unlayered CSS beats every layered rule regardless of
400
+ specificity — shipping them bare would make the engine's numbers
401
+ unoverridable by the apps they exist to serve. Inside @layer utilities the
402
+ engine's import comes FIRST in a consumer's application.css, so the
403
+ consumer's own `@layer utilities` block wins on source order at equal
404
+ specificity, which is the whole "override only what differs" promise.
405
+ `.nav-shell .nav-logo` still outranks a bare `.w-12` on specificity, so an
406
+ app that DOES put a size utility on its logo has to mean it. */
407
+ @layer utilities {
408
+ .nav-title { display: flex; gap: 0.25em; align-items: baseline; }
409
+
410
+ /* THE COLLAPSE BAND TABLE. Each band is the two ENDPOINTS of its collapse
411
+ written once, not two rule sets that have to be kept in step — the old
412
+ shape, where a base rule and an `.is-scrolled` override each carried half
413
+ the answer and `transition: font-size` guessed the middle.
414
+
415
+ --nav-ramp is the scroll distance the collapse is spread over, and it is
416
+ 3x that band's collapse TOTAL (mobile: 8px of logo + 32px of padding =
417
+ 40px, ramp 120px; desktop: 16px + 32px = 48px, ramp 144px). That ratio is
418
+ what puts the peak content speed near 1.5x rather than 2x — see the
419
+ navCollapse comment in layouts/studio/_head. Retune one and retune the
420
+ other, or the easing stops matching the geometry.
421
+
422
+ Endpoints below are UNCHANGED from the threshold build. This is the same
423
+ navbar; only the path between the ends is new.
424
+
425
+ EVERY var() CARRIES A FALLBACK OF 0, and it is inert where @property works.
426
+ navCollapse only WRITES --nav-p when progress changes, so on an unscrolled
427
+ page it is never written at all and the registration is doing 100% of the work
428
+ on first paint. In a browser that ignores @property, an unregistered custom
429
+ property is the guaranteed-invalid token and every calc() here goes
430
+ invalid-at-computed-value-time — measured: padding-block 0 and the logo at its
431
+ intrinsic size. `var(--nav-p, 0)` degrades to "always expanded" instead, which
432
+ is the pre-collapse navbar rather than a broken one. It costs nothing where
433
+ @property is honoured, which is why it is written unconditionally. (Not a NEW
434
+ exposure: the built Tailwind v4 already ships 59 @property registrations, so
435
+ that browser band is unsupported engine-wide.) */
436
+ .nav-shell {
437
+ --nav-ramp: 144px;
438
+ /* How far the collapse may travel in ONE animation frame, as header
439
+ height. Position-linked progress alone puts the WHOLE collapse in a
440
+ single frame on a flick — measured 39px in turf-monster, the full
441
+ 178 -> 139. navCollapse() converts this to --nav-p units off
442
+ --nav-ramp's 3x relation to the collapse total, so one number covers
443
+ every band. */
444
+ --nav-max-step: 5px;
445
+ --nav-pad: calc(1.5rem - 1rem * var(--nav-p, 0)); /* py-6 -> py-2 */
446
+ --nav-logo-size: calc(3rem - 1rem * var(--nav-p, 0)); /* 48px -> 32px */
447
+ --nav-title-size: calc(1.875rem - 0.625rem * var(--nav-p, 0)); /* 3xl -> xl */
448
+ --nav-title-lead-size: var(--nav-title-size); /* one line here */
449
+ /* THE LINE BOX, and the reason this variable exists at all. `text-3xl` and
450
+ `text-xl` are FONT-SIZE AND LINE-HEIGHT PAIRS (1.875rem/2.25rem and
451
+ 1.25rem/1.75rem). Carrying only the font-size across dropped the
452
+ line-height to whatever the h1 inherited, and the header grew 9px at every
453
+ width >= 768px — expanded 84px -> 93px, measured. Collapsed was unaffected
454
+ because the 32px logo, not the title, sets the row height there, which is
455
+ exactly why a one-endpoint check would have missed this. Mobile was clean
456
+ only because its band already declared line-height: 1.15 explicitly.
457
+ Interpolate the PAIR or the endpoints move. */
458
+ --nav-title-lead: calc(2.25rem - 0.5rem * var(--nav-p, 0)); /* 36px -> 28px */
459
+ }
460
+ .nav-row { padding-block: var(--nav-pad); }
461
+ .nav-shell .nav-logo { width: var(--nav-logo-size); height: var(--nav-logo-size); }
462
+ .nav-shell .nav-title { font-size: var(--nav-title-size); line-height: var(--nav-title-lead); }
463
+ .nav-shell .nav-title span:last-child { font-size: var(--nav-title-lead-size); }
464
+
465
+ @media (max-width: 767px) {
466
+ .nav-title { flex-direction: column; gap: 0; }
467
+ .nav-title span:first-child { margin-bottom: -4px; }
468
+ .nav-logo-link { gap: 0.5rem !important; }
469
+ .nav-shell {
470
+ --nav-ramp: 120px;
471
+ --nav-logo-size: calc(3rem - 0.5rem * var(--nav-p, 0)); /* 48px -> 40px */
472
+ --nav-title-size: calc(1.25rem - 0.25rem * var(--nav-p, 0)); /* 20px -> 16px */
473
+ --nav-title-lead-size: calc(1.5rem - 0.35rem * var(--nav-p, 0));/* 24px -> 18.4px */
474
+ /* Mobile stacks the two words, so its line box is a RATIO, not the desktop
475
+ pair. Declared here rather than as a `line-height` on .nav-title, because
476
+ `.nav-shell .nav-title` outranks a bare `.nav-title` on specificity no
477
+ matter which comes later in the sheet — one declaration site, like every
478
+ other endpoint in this table. */
479
+ --nav-title-lead: 1.15;
480
+ }
481
+ }
482
+ @media (max-width: 399px) {
483
+ .nav-logo-link { gap: 0.25rem !important; }
484
+ .nav-shell {
485
+ --nav-title-size: calc(1.1rem - 0.2rem * var(--nav-p, 0)); /* 17.6px -> 14.4px */
486
+ --nav-title-lead-size: calc(1.3rem - 0.3rem * var(--nav-p, 0)); /* 20.8px -> 16px */
487
+ }
488
+ }
489
+ }
@@ -64,82 +64,14 @@
64
64
  mirror the col's clamps: the item is flex-shrink-0, so without them a
65
65
  long nowrap username sizes the column past a narrow viewport. */
66
66
  .user-nav-fit { max-width: 14rem; }
67
- .nav-title { display: flex; gap: 0.25em; align-items: baseline; }
68
-
69
- /* THE COLLAPSE BAND TABLE. Each band is the two ENDPOINTS of its collapse
70
- written once, not two rule sets that have to be kept in step — the old
71
- shape, where a base rule and an `.is-scrolled` override each carried half
72
- the answer and `transition: font-size` guessed the middle.
73
-
74
- --nav-ramp is the scroll distance the collapse is spread over, and it is
75
- 3x that band's collapse TOTAL (mobile: 8px of logo + 32px of padding =
76
- 40px, ramp 120px; desktop: 16px + 32px = 48px, ramp 144px). That ratio is
77
- what puts the peak content speed near 1.5x rather than 2x — see the
78
- navCollapse comment in layouts/studio/_head. Retune one and retune the
79
- other, or the easing stops matching the geometry.
80
-
81
- Endpoints below are UNCHANGED from the threshold build. This is the same
82
- navbar; only the path between the ends is new.
83
-
84
- EVERY var() CARRIES A FALLBACK OF 0, and it is inert where @property works.
85
- navCollapse only WRITES --nav-p when progress changes, so on an unscrolled
86
- page it is never written at all and the registration is doing 100% of the work
87
- on first paint. In a browser that ignores @property, an unregistered custom
88
- property is the guaranteed-invalid token and every calc() here goes
89
- invalid-at-computed-value-time — measured: padding-block 0 and the logo at its
90
- intrinsic size. `var(--nav-p, 0)` degrades to "always expanded" instead, which
91
- is the pre-collapse navbar rather than a broken one. It costs nothing where
92
- @property is honoured, which is why it is written unconditionally. (Not a NEW
93
- exposure: the built Tailwind v4 already ships 59 @property registrations, so
94
- that browser band is unsupported engine-wide.) */
95
- .nav-shell {
96
- --nav-ramp: 144px;
97
- --nav-pad: calc(1.5rem - 1rem * var(--nav-p, 0)); /* py-6 -> py-2 */
98
- --nav-logo-size: calc(3rem - 1rem * var(--nav-p, 0)); /* 48px -> 32px */
99
- --nav-title-size: calc(1.875rem - 0.625rem * var(--nav-p, 0)); /* 3xl -> xl */
100
- --nav-title-lead-size: var(--nav-title-size); /* one line here */
101
- /* THE LINE BOX, and the reason this variable exists at all. `text-3xl` and
102
- `text-xl` are FONT-SIZE AND LINE-HEIGHT PAIRS (1.875rem/2.25rem and
103
- 1.25rem/1.75rem). Carrying only the font-size across dropped the
104
- line-height to whatever the h1 inherited, and the header grew 9px at every
105
- width >= 768px — expanded 84px -> 93px, measured. Collapsed was unaffected
106
- because the 32px logo, not the title, sets the row height there, which is
107
- exactly why a one-endpoint check would have missed this. Mobile was clean
108
- only because its band already declared line-height: 1.15 explicitly.
109
- Interpolate the PAIR or the endpoints move. */
110
- --nav-title-lead: calc(2.25rem - 0.5rem * var(--nav-p, 0)); /* 36px -> 28px */
111
- }
112
- .nav-row { padding-block: var(--nav-pad); }
113
- .nav-shell .nav-logo { width: var(--nav-logo-size); height: var(--nav-logo-size); }
114
- .nav-shell .nav-title { font-size: var(--nav-title-size); line-height: var(--nav-title-lead); }
115
- .nav-shell .nav-title span:last-child { font-size: var(--nav-title-lead-size); }
116
-
117
67
  @media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
118
68
  @media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
119
- @media (max-width: 767px) {
120
- .nav-title { flex-direction: column; gap: 0; }
121
- .nav-title span:first-child { margin-bottom: -4px; }
122
- .nav-logo-link { gap: 0.5rem !important; }
123
- .nav-shell {
124
- --nav-ramp: 120px;
125
- --nav-logo-size: calc(3rem - 0.5rem * var(--nav-p, 0)); /* 48px -> 40px */
126
- --nav-title-size: calc(1.25rem - 0.25rem * var(--nav-p, 0)); /* 20px -> 16px */
127
- --nav-title-lead-size: calc(1.5rem - 0.35rem * var(--nav-p, 0));/* 24px -> 18.4px */
128
- /* Mobile stacks the two words, so its line box is a RATIO, not the desktop
129
- pair. Declared here rather than as a `line-height` on .nav-title, because
130
- `.nav-shell .nav-title` outranks a bare `.nav-title` on specificity no
131
- matter which comes later in the sheet — one declaration site, like every
132
- other endpoint in this table. */
133
- --nav-title-lead: 1.15;
134
- }
135
- }
136
- @media (max-width: 399px) {
137
- .nav-logo-link { gap: 0.25rem !important; }
138
- .nav-shell {
139
- --nav-title-size: calc(1.1rem - 0.2rem * var(--nav-p, 0)); /* 17.6px -> 14.4px */
140
- --nav-title-lead-size: calc(1.3rem - 0.3rem * var(--nav-p, 0)); /* 20.8px -> 16px */
141
- }
142
- }
69
+ /* The COLLAPSE band table used to live here, which meant only an app
70
+ rendering THIS partial got it. Three of six apps fork the navbar, so each
71
+ was left to hand-write its own — the way four independent copies happened
72
+ in the first place. It ships from engine.css now (every consuming app
73
+ imports it), so a forking app adopts the collapse with `nav-shell` +
74
+ x-data="navCollapse()" and overrides only the endpoints that differ. */
143
75
  </style>
144
76
  <div class="nav-row flex items-center">
145
77
  <%# Left side: logo + nav %>
@@ -12,10 +12,13 @@
12
12
 
13
13
  <style>
14
14
  /* Z-indexes are CSS-var-driven so consumer apps can override without
15
- !important. The defaults (60 / 55) sit just above typical sticky-nav
16
- baselines (50). Apps with higher navbars set --studio-toast-z and
17
- --studio-toast-blur-z in :root. */
18
- #toast-container { z-index: var(--studio-toast-z, 60); }
15
+ !important. The per-app names stay they are the published seam — but the
16
+ DEFAULT now comes from the shared layer scale in engine.css rather than a
17
+ bare 60/55 that sat below every app navbar. A toast is the one thing that
18
+ must surface over an open modal, so --z-toast is above --z-modal and an app
19
+ that never overrides gets that for free. Apps that DO override should set
20
+ the tier (--z-toast) instead, and drop the per-component name. */
21
+ #toast-container { z-index: var(--studio-toast-z, var(--z-toast, 400)); }
19
22
  .toast-shadow-all {
20
23
  box-shadow: 0 0 30px rgba(0,0,0,0.4), 0 0 10px rgba(0,0,0,0.2);
21
24
  }
@@ -62,7 +65,7 @@
62
65
  mask-image: radial-gradient(ellipse 70% 80% at 50% 0%, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0) 100%);
63
66
  -webkit-mask-image: radial-gradient(ellipse 70% 80% at 50% 0%, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0) 100%);
64
67
  pointer-events: none;
65
- z-index: var(--studio-toast-blur-z, 55);
68
+ z-index: var(--studio-toast-blur-z, var(--z-toast-blur, 399));
66
69
  transition: opacity 0.5s ease;
67
70
  background: rgba(255, 255, 255, 0.15);
68
71
  }