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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +61 -0
- data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
- data/app/assets/tailwind/studio_engine/engine.css +208 -4
- data/app/views/layouts/_navbar.html.erb +6 -74
- data/app/views/layouts/studio/_flash.html.erb +8 -5
- data/app/views/layouts/studio/_head.html.erb +99 -8
- data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
- data/app/views/studio/banners/_app_banner.html.erb +4 -1
- data/app/views/studio/banners/_button.html.erb +2 -2
- data/app/views/studio/banners/_stack.html.erb +12 -4
- data/app/views/studio/modals/_host.html.erb +105 -5
- data/app/views/studio/modals/_scoped_host.html.erb +25 -2
- data/app/views/studio/modals/_wallet_connect.html.erb +315 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +12 -4
- data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
- data/app/views/style/_modals.html.erb +10 -1
- data/app/views/style/modals/_wallet_connect.html.erb +77 -171
- data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
- data/lib/studio/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fcb787be0caeb8c975281d334f56a0d0ce699dbd25851b59948de0e5c6a87a4
|
|
4
|
+
data.tar.gz: a0b21daec66caf0110d3fc6f33f311a678b4216bda5b7d033011d84465ec54d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
-
|
|
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.
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
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
|
}
|