studio-engine 0.62.4 → 0.62.6
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 +63 -0
- data/app/views/layouts/_navbar.html.erb +83 -17
- data/app/views/layouts/studio/_head.html.erb +155 -0
- data/app/views/navbar/show.html.erb +29 -25
- data/app/views/studio/modals/_host.html.erb +45 -2
- data/app/views/studio/modals/_scoped_host.html.erb +35 -6
- data/app/views/studio/modals/blocks/_success_card.html.erb +9 -1
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa64bc8c9da93f3beaede057f50b905755a4d62e549aa83d32d154fd6f2dbf4c
|
|
4
|
+
data.tar.gz: 9e3fc2011c4cada24b7d93f08a3e4e54fad76df242a8e3986b83bb0e8a742126
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 842b7b8f102010ef77ee303a763c42dfceb047ccb3d8cdce2b06e44a95d97fc8ca91a03207eecb6b0c9340f9434baf071ad6914e6cc7f6910a8889f8300b2a8e
|
|
7
|
+
data.tar.gz: dea3a8a030028885d614787882af0ceaec4bda5a50a175e863f5a2144d44b3f958a9d9d2594c7dee592365af0636e748239ed1b11579bf92bc91ae28da0d637d
|
data/CHANGELOG.md
CHANGED
|
@@ -40,6 +40,30 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
40
40
|
|
|
41
41
|
### Added
|
|
42
42
|
|
|
43
|
+
- **`--nav-p` and `navCollapse()` — the navbar collapse becomes a primitive.**
|
|
44
|
+
`layouts/studio/_head` now publishes a third header property beside `--nav-h`
|
|
45
|
+
and `--nav-bottom`. The first two are measurements OF the header; `--nav-p` is
|
|
46
|
+
an input TO it: collapse progress, `0` expanded to `1` collapsed, written on
|
|
47
|
+
the `<header>` once per animation frame from `window.scrollY`, and registered
|
|
48
|
+
with `@property` as a `<number>` so it is legal inside `calc()`, real before
|
|
49
|
+
the first scroll, and interpolable.
|
|
50
|
+
|
|
51
|
+
**It ships no sizing opinion.** A host adopts it by putting `nav-shell` and
|
|
52
|
+
`x-data="navCollapse()"` on its header, giving each breakpoint band a
|
|
53
|
+
`--nav-ramp`, and writing its collapsing dimensions as `calc()`s off
|
|
54
|
+
`--nav-p`. An app whose navbar collapses to different endpoints than this
|
|
55
|
+
gem's — `mcritchie-studio` goes `w-8 → w-5`, not `w-12 → w-8` — can adopt the
|
|
56
|
+
mechanism without changing its endpoints.
|
|
57
|
+
|
|
58
|
+
**NO SHIPPING CONSUMER EXERCISES THIS YET, and the adoptions are follow-ons.**
|
|
59
|
+
`turf-monster` overrides `layouts/_navbar.html.erb` AND redefines
|
|
60
|
+
`window.navCollapse` in `shared/_alpine_factories.html.erb`, rendered after the
|
|
61
|
+
engine head, so the engine's copy is shadowed. `mcritchie-studio` never renders
|
|
62
|
+
`layouts/navbar` at all — its inline header still carries `@scroll.window` and
|
|
63
|
+
`transition-all duration-300`. Said plainly because it also means the green
|
|
64
|
+
consumer CI on this change is **not evidence about this code path**: both apps
|
|
65
|
+
compile and pass against a primitive neither of them runs.
|
|
66
|
+
|
|
43
67
|
- **`studio/modals/blocks/_age_gate` — the refusal card.** Where the birthday card
|
|
44
68
|
hands off when the date is under the app's bar. Headed "Easy, Young'un" under a
|
|
45
69
|
teddy bear rather than a red X, because a person who will be welcome later is
|
|
@@ -49,6 +73,45 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
49
73
|
absent rather than rendered dead) and a back link that returns to the birthday
|
|
50
74
|
card. Displays `min_age` / `state`; computes no eligibility of its own.
|
|
51
75
|
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **The engine navbar's collapse tracks the finger instead of a clock.**
|
|
79
|
+
`layouts/_navbar` flipped `scrolled` at `scrollY > 60` and fed that step into
|
|
80
|
+
`transition-all duration-300` on padding, logo `width`/`height` and two font
|
|
81
|
+
sizes. The finger set the step; an ease curve owned everything after it.
|
|
82
|
+
Measured in `turf-monster` at 390×844 on the same code before the change: the
|
|
83
|
+
header ran 178px → 139px and **34 of those 39px of document reflow landed
|
|
84
|
+
AFTER the scroll had stopped, over 232ms**, at up to 3px per frame — plus a
|
|
85
|
+
1px REVERSE lurch in the frame the class flipped, where a discrete
|
|
86
|
+
`text-3xl → text-xl` swap collided with the stylesheet's own
|
|
87
|
+
`transition: font-size`.
|
|
88
|
+
|
|
89
|
+
Every collapsing dimension is now a `calc()` off `--nav-p`, and nothing on the
|
|
90
|
+
path carries a time-based transition. **Both endpoints are unchanged at every
|
|
91
|
+
breakpoint** — this is the same navbar, re-plumbed, so a consumer sees no
|
|
92
|
+
visual difference at rest.
|
|
93
|
+
|
|
94
|
+
Content still accelerates *during* the collapse: a sticky header is in flow,
|
|
95
|
+
so shrinking it adds to the scroll and content necessarily outruns the finger.
|
|
96
|
+
That is the point of a collapse, not a defect. What is tunable is the shape,
|
|
97
|
+
and `--nav-ramp` is 3× the band's collapse total with a smoothstep ease, whose
|
|
98
|
+
slope is zero at both ends — so the burst leaves 1×, peaks near 1.5×, and
|
|
99
|
+
returns to 1× with no velocity step. A linear ramp equal to the collapse hits
|
|
100
|
+
2× and steps straight back.
|
|
101
|
+
|
|
102
|
+
Also included: a passive, rAF-coalesced listener that writes to the header
|
|
103
|
+
rather than `:root` (keeping each frame's style recalc inside the navbar
|
|
104
|
+
subtree); a short-page guard, because collapsing shortens the document and on
|
|
105
|
+
a barely-scrollable page that deletes the very scroll room that triggered it,
|
|
106
|
+
clamps `scrollY` to 0, and flaps forever; and a `prefers-reduced-motion`
|
|
107
|
+
branch that snaps `--nav-p` to `0`/`1`, since resizing type under a moving
|
|
108
|
+
finger is itself motion worth dropping.
|
|
109
|
+
|
|
110
|
+
`/navbar`'s Scrolled toggle drops **twelve `!important` rules** that restated
|
|
111
|
+
every collapsed value — it sets `--nav-p: 0|1` and transitions the registered
|
|
112
|
+
property, so the preview exercises the shipped `calc()`s instead of a parallel
|
|
113
|
+
copy of them.
|
|
114
|
+
|
|
52
115
|
### Added
|
|
53
116
|
|
|
54
117
|
- **A Geo row in the shared admin dropdown, with signage when it is off.** Every
|
|
@@ -37,7 +37,16 @@
|
|
|
37
37
|
logo_path = Studio.logo_for("Navbar Logo")
|
|
38
38
|
%>
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
<%# nav-shell is the --nav-p scope: navCollapse() (layouts/studio/_head) writes
|
|
41
|
+
the collapse progress, 0 expanded .. 1 collapsed, on THIS element once per
|
|
42
|
+
animation frame, and the band table below derives the row padding, logo size
|
|
43
|
+
and title sizes from it with calc(). Nothing on the collapse path carries a
|
|
44
|
+
time-based transition — the head's comment carries the measurements that
|
|
45
|
+
bought that. transition-shadow stays: box-shadow paints, it never reflows,
|
|
46
|
+
so it cannot move content under the reader.
|
|
47
|
+
Preview keeps nav-shell (the calc()s have to resolve) but no x-data — the
|
|
48
|
+
/navbar review page drives --nav-p from its own Scrolled toggle. %>
|
|
49
|
+
<header <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
|
|
41
50
|
<%# top-0, a STATIC value, and never a custom property. Any bars render as
|
|
42
51
|
this header's sibling in normal flow (studio/banners/_stack), so they
|
|
43
52
|
already occupy their own height above it and there is nothing to
|
|
@@ -46,7 +55,7 @@
|
|
|
46
55
|
measurement landed, moved again on a webfont swap, and drew twice
|
|
47
56
|
during a view transition composited at two different tops. Only CSS
|
|
48
57
|
can set this top now, so none of that is reachable. %>
|
|
49
|
-
class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
|
|
58
|
+
class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
|
|
50
59
|
:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
|
|
51
60
|
<style>
|
|
52
61
|
.user-nav-col { width: 14rem; }
|
|
@@ -56,37 +65,94 @@
|
|
|
56
65
|
long nowrap username sizes the column past a narrow viewport. */
|
|
57
66
|
.user-nav-fit { max-width: 14rem; }
|
|
58
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
|
+
|
|
59
117
|
@media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
|
|
60
118
|
@media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
|
|
61
119
|
@media (max-width: 767px) {
|
|
62
|
-
.nav-title {
|
|
120
|
+
.nav-title { flex-direction: column; gap: 0; }
|
|
63
121
|
.nav-title span:first-child { margin-bottom: -4px; }
|
|
64
|
-
.nav-title span:last-child { font-size: 1.5rem; transition: font-size 0.3s; }
|
|
65
122
|
.nav-logo-link { gap: 0.5rem !important; }
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
135
|
}
|
|
70
136
|
@media (max-width: 399px) {
|
|
71
137
|
.nav-logo-link { gap: 0.25rem !important; }
|
|
72
|
-
.nav-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
142
|
}
|
|
77
143
|
</style>
|
|
78
|
-
<div class="flex items-center
|
|
79
|
-
<%= ":class=\"scrolled ? 'py-2' : 'py-6'\"".html_safe unless is_preview %>>
|
|
144
|
+
<div class="nav-row flex items-center">
|
|
80
145
|
<%# Left side: logo + nav %>
|
|
81
146
|
<div class="flex-1 px-4">
|
|
82
147
|
<div class="flex items-center gap-6">
|
|
83
148
|
<%= link_to root_path, class: "nav-logo-link inline-flex items-center gap-3 group" do %>
|
|
84
149
|
<% if logo_path %>
|
|
85
|
-
|
|
86
|
-
|
|
150
|
+
<%# No w-*/h-* utility here: .nav-shell .nav-logo sizes it off --nav-p.
|
|
151
|
+
transition-transform, NOT transition-all — the hover lift still
|
|
152
|
+
eases, while width/height follow the finger with no clock. %>
|
|
153
|
+
<img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="nav-logo rounded-full shadow-lg <%= "group-hover:scale-105 transition-transform duration-300" unless is_preview %>">
|
|
87
154
|
<% end %>
|
|
88
|
-
<h1 class="nav-title font-extrabold text-heading tracking-tight
|
|
89
|
-
<%= "x-bind:class=\"scrolled ? 'text-xl' : 'text-3xl'\"".html_safe unless is_preview %>><span><%= prefix %></span><span class="text-primary"><%= last_word %></span></h1>
|
|
155
|
+
<h1 class="nav-title font-extrabold text-heading tracking-tight"><span><%= prefix %></span><span class="text-primary"><%= last_word %></span></h1>
|
|
90
156
|
<% end %>
|
|
91
157
|
<%# Desktop nav — apps should override this partial to add links here %>
|
|
92
158
|
<nav class="hidden md:flex items-center gap-4">
|
|
@@ -244,6 +244,161 @@
|
|
|
244
244
|
})();
|
|
245
245
|
</script>
|
|
246
246
|
|
|
247
|
+
<style>
|
|
248
|
+
/* studio-engine: --nav-p, the sticky header's COLLAPSE PROGRESS, 0 (expanded)
|
|
249
|
+
to 1 (collapsed). Third of the header properties published from this file,
|
|
250
|
+
and the only one that is an INPUT to the header rather than a measurement
|
|
251
|
+
of it — --nav-h and --nav-bottom above say how big it is, this says how far
|
|
252
|
+
through its collapse it has got.
|
|
253
|
+
|
|
254
|
+
REGISTERED, not merely declared. Untyped, `calc(3rem - 1rem * var(--nav-p))`
|
|
255
|
+
is invalid at computed-value time on a page that has never scrolled and
|
|
256
|
+
every collapsing dimension drops to its unstyled default; the <number> type
|
|
257
|
+
is what makes the arithmetic legal and the initial value real. Registration
|
|
258
|
+
also makes it INTERPOLABLE, which is how /navbar's Scrolled toggle animates
|
|
259
|
+
the whole collapse by transitioning one property. */
|
|
260
|
+
@property --nav-p {
|
|
261
|
+
syntax: "<number>";
|
|
262
|
+
inherits: true;
|
|
263
|
+
initial-value: 0;
|
|
264
|
+
}
|
|
265
|
+
</style>
|
|
266
|
+
<script>
|
|
267
|
+
// studio-engine: navCollapse — the scroll-linked navbar collapse.
|
|
268
|
+
//
|
|
269
|
+
// Publishes --nav-p on the <header> once per animation frame from
|
|
270
|
+
// window.scrollY. The navbar's own stylesheet derives every collapsing
|
|
271
|
+
// dimension from it with calc(), so the header moves only in a frame the
|
|
272
|
+
// finger moved it, and stops the instant the finger does.
|
|
273
|
+
//
|
|
274
|
+
// It replaces `@scroll.window="scrolled = scrolled ? (scrollY > 5) : (scrollY
|
|
275
|
+
// > 60)"` plus `transition-all duration-300`. That pair let the FINGER set a
|
|
276
|
+
// step and an ease curve own everything after it. Measured in turf-monster at
|
|
277
|
+
// 390x844 before the change: the header ran 178px -> 139px and 34 of those
|
|
278
|
+
// 39px of document reflow landed AFTER the scroll had stopped, over 232ms, at
|
|
279
|
+
// up to 3px per frame of content nobody asked to move -- plus a 1px REVERSE
|
|
280
|
+
// lurch in the frame the class flipped, where a discrete text-3xl -> text-xl
|
|
281
|
+
// swap collided with the stylesheet's own `transition: font-size`.
|
|
282
|
+
//
|
|
283
|
+
// ADOPTING IT: put `nav-shell` and `x-data="navCollapse()"` on the header,
|
|
284
|
+
// give each breakpoint band a `--nav-ramp`, and write the collapsing
|
|
285
|
+
// dimensions as calc()s off --nav-p. This file ships NO sizing opinion, so an
|
|
286
|
+
// app whose navbar collapses to different endpoints than the engine's adopts
|
|
287
|
+
// the mechanism without touching its markup.
|
|
288
|
+
//
|
|
289
|
+
// Four details are load-bearing:
|
|
290
|
+
//
|
|
291
|
+
// passive + rAF — the listener never blocks the compositor and coalesces a
|
|
292
|
+
// burst of scroll events (iOS momentum fires far above 60Hz) into one write
|
|
293
|
+
// per frame. The write lands on the HEADER, not :root: an inherited custom
|
|
294
|
+
// property written on :root dirties style for the whole document every
|
|
295
|
+
// frame, and it would leak the live page's scroll progress into the preview
|
|
296
|
+
// headers on /navbar.
|
|
297
|
+
//
|
|
298
|
+
// the smoothstep — collapsing a sticky, IN-FLOW header pulls the page up,
|
|
299
|
+
// so during the collapse content moves by the scroll AND by the shrink:
|
|
300
|
+
// faster than the finger, always. That is inherent; reclaiming the vertical
|
|
301
|
+
// space is the point. What is tunable is the shape of the burst. --nav-ramp
|
|
302
|
+
// is sized at 3x the band's collapse total and the ramp is smoothstepped,
|
|
303
|
+
// whose slope is zero at both ends, so content speed LEAVES 1x, peaks near
|
|
304
|
+
// 1.5x mid-ramp, and returns to 1x with no velocity step. A linear ramp
|
|
305
|
+
// equal to the collapse hits 2x and steps straight back to 1x.
|
|
306
|
+
//
|
|
307
|
+
// the short-page guard — collapsing shortens the document by the collapse
|
|
308
|
+
// total. On a page with barely more than that to scroll, the collapse
|
|
309
|
+
// deletes the very scroll room that triggered it, the browser clamps
|
|
310
|
+
// scrollY to 0, and the navbar flaps open and shut forever. roomExpanded
|
|
311
|
+
// adds back the shrink ALREADY applied, so the measurement cannot chase
|
|
312
|
+
// itself as it collapses.
|
|
313
|
+
//
|
|
314
|
+
// reduced motion — scroll-linked motion has no clock left to slow down, but
|
|
315
|
+
// resizing type under a moving finger is itself the motion some readers are
|
|
316
|
+
// asking us to drop. Under the query --nav-p snaps 0/1 on the old
|
|
317
|
+
// 60/5 hysteresis instead of interpolating.
|
|
318
|
+
window.navCollapse = function () {
|
|
319
|
+
return {
|
|
320
|
+
scrolled: false,
|
|
321
|
+
p: 0,
|
|
322
|
+
_ramp: 144,
|
|
323
|
+
_reduce: null,
|
|
324
|
+
_onScroll: null,
|
|
325
|
+
_onResize: null,
|
|
326
|
+
_onReduce: null,
|
|
327
|
+
init: function () {
|
|
328
|
+
var self = this;
|
|
329
|
+
var el = this.$el;
|
|
330
|
+
var queued = false;
|
|
331
|
+
|
|
332
|
+
this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
333
|
+
|
|
334
|
+
function readRamp() {
|
|
335
|
+
var raw = parseFloat(getComputedStyle(el).getPropertyValue('--nav-ramp'));
|
|
336
|
+
self._ramp = raw > 0 ? raw : 144;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function apply() {
|
|
340
|
+
queued = false;
|
|
341
|
+
var ramp = self._ramp;
|
|
342
|
+
// Clamped: rubber-band overscroll reports a NEGATIVE scrollY, and a
|
|
343
|
+
// negative progress inflates the navbar past its expanded size.
|
|
344
|
+
var y = Math.max(0, window.scrollY);
|
|
345
|
+
|
|
346
|
+
// The height the document WOULD have with the navbar expanded. The
|
|
347
|
+
// add-back is the whole trick — see the guard note above.
|
|
348
|
+
var roomExpanded = document.documentElement.scrollHeight
|
|
349
|
+
- window.innerHeight
|
|
350
|
+
+ ramp * self.p;
|
|
351
|
+
|
|
352
|
+
var p;
|
|
353
|
+
if (roomExpanded < ramp + 24) {
|
|
354
|
+
p = 0;
|
|
355
|
+
} else if (self._reduce.matches) {
|
|
356
|
+
p = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
|
|
357
|
+
} else {
|
|
358
|
+
var t = Math.min(1, y / ramp);
|
|
359
|
+
p = t * t * (3 - 2 * t);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (p !== self.p) {
|
|
363
|
+
self.p = p;
|
|
364
|
+
el.style.setProperty('--nav-p', p.toFixed(4));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The shadow is the one thing still on a clock, and it may stay
|
|
368
|
+
// there: box-shadow paints, it never reflows, so it cannot move
|
|
369
|
+
// content. Hysteresis keeps it from strobing at the boundary.
|
|
370
|
+
var lit = self.scrolled ? y > 5 : y > 60;
|
|
371
|
+
if (lit !== self.scrolled) self.scrolled = lit;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this._onScroll = function () {
|
|
375
|
+
if (queued) return;
|
|
376
|
+
queued = true;
|
|
377
|
+
requestAnimationFrame(apply);
|
|
378
|
+
};
|
|
379
|
+
this._onResize = function () { readRamp(); self._onScroll(); };
|
|
380
|
+
this._onReduce = apply;
|
|
381
|
+
|
|
382
|
+
readRamp();
|
|
383
|
+
apply();
|
|
384
|
+
|
|
385
|
+
window.addEventListener('scroll', this._onScroll, { passive: true });
|
|
386
|
+
window.addEventListener('resize', this._onResize, { passive: true });
|
|
387
|
+
if (this._reduce.addEventListener) this._reduce.addEventListener('change', this._onReduce);
|
|
388
|
+
},
|
|
389
|
+
destroy: function () {
|
|
390
|
+
// A Turbo visit tears the header down and builds a new one; without
|
|
391
|
+
// this every visit would stack another listener on window.
|
|
392
|
+
if (this._onScroll) window.removeEventListener('scroll', this._onScroll);
|
|
393
|
+
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
|
394
|
+
if (this._reduce && this._reduce.removeEventListener && this._onReduce) {
|
|
395
|
+
this._reduce.removeEventListener('change', this._onReduce);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
</script>
|
|
401
|
+
|
|
247
402
|
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
248
403
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
249
404
|
<% if Studio.sticky_table_headers %>
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
border: 2px solid var(--color-border-strong);
|
|
8
8
|
border-radius: 0.5rem;
|
|
9
9
|
pointer-events: none;
|
|
10
|
-
|
|
10
|
+
/* --nav-p is a registered <number>, so the Scrolled toggle can TRANSITION
|
|
11
|
+
it directly — one interpolating property in place of the five
|
|
12
|
+
per-element font-size/width/padding transitions this block used to
|
|
13
|
+
carry. */
|
|
14
|
+
transition: width 0.15s ease, --nav-p 0.3s ease;
|
|
11
15
|
}
|
|
12
16
|
.navbar-preview header { position: static !important; }
|
|
13
17
|
|
|
@@ -18,19 +22,32 @@
|
|
|
18
22
|
.navbar-preview.is-mobile .user-nav-col, .navbar-preview.is-mobile .user-nav-fit { padding-left: 0 !important; padding-right: 1rem !important; }
|
|
19
23
|
.navbar-preview.is-mobile .nav-title { flex-direction: column !important; gap: 0 !important; line-height: 1.15 !important; }
|
|
20
24
|
.navbar-preview.is-mobile .nav-title span:first-child { margin-bottom: -4px !important; }
|
|
21
|
-
.navbar-preview.is-mobile .nav-title span:last-child { font-size: 1.5rem !important; }
|
|
22
25
|
.navbar-preview.is-mobile .nav-logo-link { gap: 0.5rem !important; }
|
|
23
26
|
|
|
27
|
+
/* Breakpoint simulation for the --nav-p sizes. The live navbar picks these
|
|
28
|
+
up from media queries in layouts/_navbar, which cannot fire for a
|
|
29
|
+
CONTAINER at a fixed viewport width — so the same calc()s are re-keyed on
|
|
30
|
+
the wrapper. They are the same expressions, not a second set of numbers:
|
|
31
|
+
retune the collapse and retune both, or the preview starts lying. */
|
|
32
|
+
.navbar-preview.is-mobile .nav-shell {
|
|
33
|
+
--nav-logo-size: calc(3rem - 0.5rem * var(--nav-p));
|
|
34
|
+
}
|
|
35
|
+
.navbar-preview.bp-tiny .nav-shell {
|
|
36
|
+
--nav-title-size: calc(1.1rem - 0.2rem * var(--nav-p));
|
|
37
|
+
--nav-title-lead-size: calc(1.3rem - 0.3rem * var(--nav-p));
|
|
38
|
+
}
|
|
39
|
+
.navbar-preview.bp-small .nav-shell {
|
|
40
|
+
--nav-title-size: calc(1.25rem - 0.25rem * var(--nav-p));
|
|
41
|
+
--nav-title-lead-size: calc(1.5rem - 0.35rem * var(--nav-p));
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/* Tiny (< 400px) */
|
|
25
|
-
.navbar-preview.bp-tiny .nav-title { font-size: 1.1rem !important; }
|
|
26
|
-
.navbar-preview.bp-tiny .nav-title span:last-child { font-size: 1.3rem !important; }
|
|
27
45
|
.navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
|
|
28
46
|
.navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
|
|
29
47
|
.navbar-preview.bp-tiny .user-nav-fit { max-width: 14rem !important; }
|
|
30
48
|
.navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
|
|
31
49
|
|
|
32
50
|
/* Small (400-767px) */
|
|
33
|
-
.navbar-preview.bp-small .nav-title { font-size: 1.25rem !important; }
|
|
34
51
|
.navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
|
|
35
52
|
.navbar-preview.bp-small .user-nav-fit { max-width: 15rem !important; }
|
|
36
53
|
.navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
|
|
@@ -40,27 +57,14 @@
|
|
|
40
57
|
.navbar-preview.is-desktop .user-nav-fit { max-width: 20rem !important; }
|
|
41
58
|
.navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
|
|
42
59
|
|
|
43
|
-
/*
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
/* Scrolled state. The size half of this used to be TWELVE !important rules
|
|
61
|
+
restating every collapsed value; the toggle now just sets --nav-p: 1 on
|
|
62
|
+
the wrapper (below) and the shipped calc()s do the rest, so the preview
|
|
63
|
+
exercises the real rules instead of a parallel copy of them. What is left
|
|
64
|
+
is the shadow, which the live header gets from .is-scrolled and a preview
|
|
65
|
+
header (no x-data, so no `scrolled`) cannot. */
|
|
48
66
|
.navbar-preview header { transition: box-shadow 0.3s, border-color 0.3s; }
|
|
49
|
-
.navbar-preview [data-balance-display] { transition: font-size 0.3s; }
|
|
50
|
-
|
|
51
|
-
/* Scrolled state: base (all breakpoints) */
|
|
52
67
|
.navbar-preview.is-scrolled-preview header { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); border-bottom: 1px solid var(--color-border-subtle); }
|
|
53
|
-
.navbar-preview.is-scrolled-preview .py-6 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
|
|
54
|
-
.navbar-preview.is-scrolled-preview .nav-logo { width: 2rem !important; height: 2rem !important; }
|
|
55
|
-
.navbar-preview.is-scrolled-preview .nav-title { font-size: 1.25rem !important; }
|
|
56
|
-
.navbar-preview.is-scrolled-preview [data-balance-display] { font-size: 1.125rem !important; }
|
|
57
|
-
|
|
58
|
-
/* Scrolled state: mobile overrides */
|
|
59
|
-
.navbar-preview.is-mobile.is-scrolled-preview .nav-logo { width: 2.5rem !important; height: 2.5rem !important; }
|
|
60
|
-
.navbar-preview.bp-tiny.is-scrolled-preview .nav-title { font-size: 0.9rem !important; }
|
|
61
|
-
.navbar-preview.bp-tiny.is-scrolled-preview .nav-title span:last-child { font-size: 1rem !important; }
|
|
62
|
-
.navbar-preview.bp-small.is-scrolled-preview .nav-title { font-size: 1rem !important; }
|
|
63
|
-
.navbar-preview.bp-small.is-scrolled-preview .nav-title span:last-child { font-size: 1.15rem !important; }
|
|
64
68
|
|
|
65
69
|
/* Slider with device marker */
|
|
66
70
|
.slider-wrap { position: relative; }
|
|
@@ -139,7 +143,7 @@
|
|
|
139
143
|
Scrolled
|
|
140
144
|
</button>
|
|
141
145
|
</div>
|
|
142
|
-
<div class="navbar-preview <%= bp[:classes] %>" :class="scrolled && 'is-scrolled-preview'" :style="'width: ' + w + 'px'">
|
|
146
|
+
<div class="navbar-preview <%= bp[:classes] %>" :class="scrolled && 'is-scrolled-preview'" :style="'width: ' + w + 'px; --nav-p: ' + (scrolled ? 1 : 0)">
|
|
143
147
|
<%= render "layouts/navbar", preview: true, show_logged_in: section[:show_logged_in] %>
|
|
144
148
|
</div>
|
|
145
149
|
</div>
|
|
@@ -301,7 +301,7 @@
|
|
|
301
301
|
_backdropEl = null;
|
|
302
302
|
// Only restore if the element is still in the document — a Turbo visit can
|
|
303
303
|
// replace the page under an open modal, and focusing a detached node
|
|
304
|
-
// silently moves focus to
|
|
304
|
+
// silently moves focus to the document body, which is worse than leaving it alone.
|
|
305
305
|
if (target && target.focus && document.contains(target)) target.focus();
|
|
306
306
|
},
|
|
307
307
|
|
|
@@ -349,13 +349,25 @@
|
|
|
349
349
|
var entry = this.current();
|
|
350
350
|
if (!entry) return 'Dialog';
|
|
351
351
|
var p = entry.props || {};
|
|
352
|
-
|
|
352
|
+
// TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
|
|
353
|
+
// pass straight through — and the accessible-name computation then trims it to
|
|
354
|
+
// empty and announces a bare "dialog", the exact outcome this fallback chain
|
|
355
|
+
// exists to prevent. An empty string already fell through (it is falsy); a
|
|
356
|
+
// whitespace-only one did not, which is the more likely typo of the two.
|
|
357
|
+
var named = function(v) {
|
|
358
|
+
return (typeof v === 'string' && v.trim() !== '') ? v : null;
|
|
359
|
+
};
|
|
360
|
+
return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
|
|
353
361
|
},
|
|
354
362
|
|
|
355
363
|
open: function(id, props, opts) {
|
|
356
364
|
props = props || {};
|
|
357
365
|
opts = opts || {};
|
|
358
366
|
var self = this;
|
|
367
|
+
// Set by any branch that re-mounts the INNER content template without
|
|
368
|
+
// re-mounting the OUTER one — see the push branch below. The replace
|
|
369
|
+
// branch returns early and calls refocus() from its own phase 2.
|
|
370
|
+
var remounted = false;
|
|
359
371
|
|
|
360
372
|
if (opts.replace && this.stack.length > 0) {
|
|
361
373
|
var current = this.current();
|
|
@@ -404,10 +416,25 @@
|
|
|
404
416
|
}
|
|
405
417
|
// Already mid-close — just hot-swap so we don't double up timers.
|
|
406
418
|
this.stack[this.stack.length - 1] = { id: id, props: props };
|
|
419
|
+
remounted = true;
|
|
407
420
|
} else {
|
|
421
|
+
// A push onto a NON-EMPTY stack is the third re-mount seam, and the one
|
|
422
|
+
// the trap missed longest. current() stays truthy, so the OUTER template
|
|
423
|
+
// never re-mounts and x-init never re-runs captureFocus — while the INNER
|
|
424
|
+
// content template DOES re-mount and unmounts whatever was focused.
|
|
425
|
+
// Focus lands on the document body, which is not a descendant of the backdrop, so the
|
|
426
|
+
// @keydown.tab handler bound there stops firing and native tabbing resumes.
|
|
427
|
+
// MEASURED on /lab/birthday_gate: open a card, Tab once, push a second
|
|
428
|
+
// card, then Shift+Tab three times and you walk onto three BACKGROUND-PAGE
|
|
429
|
+
// buttons behind a still-open dialog.
|
|
430
|
+
//
|
|
431
|
+
// Only the FIRST push is safe: current() goes falsy -> truthy, the outer
|
|
432
|
+
// template mounts, and x-init captures for us.
|
|
433
|
+
remounted = this.stack.length > 0;
|
|
408
434
|
this.stack.push({ id: id, props: props });
|
|
409
435
|
}
|
|
410
436
|
this._sync();
|
|
437
|
+
if (remounted) this.refocus();
|
|
411
438
|
},
|
|
412
439
|
// Sugar — `swap('foo', props)` ≡ `open('foo', props, { replace: true })`.
|
|
413
440
|
// Pass opts through so callers can request a back-direction slide
|
|
@@ -491,7 +518,14 @@
|
|
|
491
518
|
// (open → swap → close) unmounts one dialog and mounts the next, and
|
|
492
519
|
// restoring to the background page in between would yank focus out of
|
|
493
520
|
// a flow the user is still inside.
|
|
521
|
+
//
|
|
522
|
+
// BUT NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down to an
|
|
523
|
+
// underlying modal re-mounts the inner template, so focus falls to the document body
|
|
524
|
+
// with the dialog still open — the trap is off while the user is still
|
|
525
|
+
// inside it. releaseFocus() was correctly gated on an empty stack; the
|
|
526
|
+
// missing half was the else. MEASURED on both hosts.
|
|
494
527
|
if (self.stack.length === 0) self.releaseFocus();
|
|
528
|
+
else self.refocus();
|
|
495
529
|
}, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
|
|
496
530
|
},
|
|
497
531
|
closeAll: function() {
|
|
@@ -517,6 +551,15 @@
|
|
|
517
551
|
return modal.props && modal.props.dismissible === false;
|
|
518
552
|
});
|
|
519
553
|
this._sync();
|
|
554
|
+
// Same re-mount, same release: a surviving non-dismissible card is still on
|
|
555
|
+
// screen, and the cards dropped from under it took focus with them. MEASURED:
|
|
556
|
+
// activeElement the document body behind a still-open non-dismissible dialog.
|
|
557
|
+
//
|
|
558
|
+
// Nothing is done when the stack empties. That is deliberate and different
|
|
559
|
+
// from close(): this runs on the Turbo/bfcache teardown path, where the page
|
|
560
|
+
// is going away and closeAll() already declines to restore into a document
|
|
561
|
+
// about to be replaced.
|
|
562
|
+
if (this.stack.length > 0) this.refocus();
|
|
520
563
|
},
|
|
521
564
|
// isOpen(id) — is a card with this id ON THE STACK, in any
|
|
522
565
|
// lifecycle state? A card mid-close still counts: close() flips
|
|
@@ -162,7 +162,15 @@
|
|
|
162
162
|
var entry = this.current();
|
|
163
163
|
if (!entry) return 'Dialog';
|
|
164
164
|
var p = entry.props || {};
|
|
165
|
-
|
|
165
|
+
// TRIMMED, not merely truthy. `title: ' '` is a truthy string, so it used to
|
|
166
|
+
// pass straight through — and the accessible-name computation then trims it to
|
|
167
|
+
// empty and announces a bare "dialog", the exact outcome this fallback chain
|
|
168
|
+
// exists to prevent. An empty string already fell through (it is falsy); a
|
|
169
|
+
// whitespace-only one did not, which is the more likely typo of the two.
|
|
170
|
+
var named = function(v) {
|
|
171
|
+
return (typeof v === 'string' && v.trim() !== '') ? v : null;
|
|
172
|
+
};
|
|
173
|
+
return named(p.ariaLabel) || named(p.title) || String(entry.id).replace(/[-_]/g, ' ');
|
|
166
174
|
},
|
|
167
175
|
|
|
168
176
|
// open(id, props, opts) — opts.replace swaps the top entry in place
|
|
@@ -172,17 +180,26 @@
|
|
|
172
180
|
opts = opts || {};
|
|
173
181
|
var entry = { id: id, props: props };
|
|
174
182
|
|
|
175
|
-
|
|
183
|
+
// Did this call re-mount the INNER content template without re-mounting the
|
|
184
|
+
// OUTER one? That is the whole question the focus trap turns on.
|
|
185
|
+
var remounted = false;
|
|
176
186
|
if (opts.replace && this.stack.length > 0) {
|
|
177
187
|
this.stack.splice(this.stack.length - 1, 1, entry);
|
|
178
|
-
|
|
188
|
+
remounted = true;
|
|
179
189
|
} else {
|
|
190
|
+
// THE COMMENT THIS REPLACES SAID "only on a REPLACE", on the premise that a
|
|
191
|
+
// fresh push takes current() falsy -> truthy so the outer template mounts and
|
|
192
|
+
// x-init captures for us. That is TRUE only when the stack was EMPTY, and
|
|
193
|
+
// silently false for a stacked push: current() was already truthy, so the
|
|
194
|
+
// outer template does not re-mount, x-init never re-runs, and the inner
|
|
195
|
+
// template unmounts whatever was focused. Focus lands on the document body — not a
|
|
196
|
+
// descendant of the backdrop — so the @keydown.tab handler bound there stops
|
|
197
|
+
// firing and native tabbing walks straight onto the page behind.
|
|
198
|
+
remounted = this.stack.length > 0;
|
|
180
199
|
this.stack.push(entry);
|
|
181
200
|
}
|
|
182
201
|
this._sync();
|
|
183
|
-
|
|
184
|
-
// outer template mounts and x-init calls captureFocus for us.
|
|
185
|
-
if (replaced) this.refocus();
|
|
202
|
+
if (remounted) this.refocus();
|
|
186
203
|
},
|
|
187
204
|
|
|
188
205
|
swap: function(id, props, opts) {
|
|
@@ -205,7 +222,13 @@
|
|
|
205
222
|
// Only when the LAST modal leaves — a stacked flow unmounts one dialog
|
|
206
223
|
// and mounts the next, and restoring in between yanks focus out of a
|
|
207
224
|
// flow the user is still inside.
|
|
225
|
+
//
|
|
226
|
+
// NOT RESTORING IS NOT THE SAME AS DOING NOTHING. Closing down TO an
|
|
227
|
+
// underlying modal re-mounts the inner template, so focus falls to the document body
|
|
228
|
+
// with that dialog still open — the trap is off while the user is still
|
|
229
|
+
// inside it. The gate was right; the else was missing.
|
|
208
230
|
if (self.stack.length === 0) self.releaseFocus();
|
|
231
|
+
else self.refocus();
|
|
209
232
|
}, CLOSE_ANIM_MS);
|
|
210
233
|
},
|
|
211
234
|
|
|
@@ -224,6 +247,12 @@
|
|
|
224
247
|
return entry.props && entry.props.dismissible === false;
|
|
225
248
|
});
|
|
226
249
|
this._sync();
|
|
250
|
+
// Same re-mount, same release: a surviving non-dismissible card is still on
|
|
251
|
+
// screen and the cards dropped from under it took focus with them. Nothing is
|
|
252
|
+
// done when the stack empties — deliberately unlike close(): this is the
|
|
253
|
+
// Turbo/bfcache teardown path, where closeAll() already declines to restore
|
|
254
|
+
// into a document about to be replaced.
|
|
255
|
+
if (this.stack.length > 0) this.refocus();
|
|
227
256
|
},
|
|
228
257
|
|
|
229
258
|
// isOpen(id) — on the stack in ANY lifecycle state, including a card
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
_remaining: #{redirect_secs},
|
|
79
79
|
_total: #{redirect_secs},
|
|
80
80
|
_redirectTimer: null,
|
|
81
|
+
_confettiTimer: null,
|
|
81
82
|
startCountdown(url) {
|
|
82
83
|
if (!url) return;
|
|
83
84
|
var self = this;
|
|
@@ -92,9 +93,16 @@
|
|
|
92
93
|
}, 1000);
|
|
93
94
|
},
|
|
94
95
|
fireConfetti() {
|
|
95
|
-
|
|
96
|
+
var self = this;
|
|
97
|
+
if (self._confettiTimer) clearTimeout(self._confettiTimer);
|
|
98
|
+
self._confettiTimer = setTimeout(function() {
|
|
99
|
+
self._confettiTimer = null;
|
|
96
100
|
try { if (window.fireSuccessConfetti) window.fireSuccessConfetti(); } catch (_) {}
|
|
97
101
|
}, 100);
|
|
102
|
+
},
|
|
103
|
+
destroy() {
|
|
104
|
+
if (this._redirectTimer) { clearInterval(this._redirectTimer); this._redirectTimer = null; }
|
|
105
|
+
if (this._confettiTimer) { clearTimeout(this._confettiTimer); this._confettiTimer = null; }
|
|
98
106
|
}
|
|
99
107
|
}".gsub(/\s+/, ' ').html_safe
|
|
100
108
|
|
data/lib/studio/version.rb
CHANGED