studio-engine 0.123.0 → 0.124.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 +64 -0
- data/README.md +16 -1
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/views/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/show.html.erb +10 -18
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_form.html.erb +1 -1
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +17 -28
- data/docs/SITE_FOOTER.md +113 -2
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +22 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +83 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7f68b7fc8c9d951dfa0f8069db58d4d819a9e5c785a35c5319c00d1a7450fab
|
|
4
|
+
data.tar.gz: 3674ddc7b3c29a7f58e06ee8c928bf63223a52388a191f819a90919986355e79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b8755f0f24f02d33aaf1f91bac610325e988a5cf9633ec74b31227b14f9f8582293ff0bd225b38efdc62e468b433dabb66fa14590e785355073f70d339add32
|
|
7
|
+
data.tar.gz: '083f8e499dedaa8efb934df433a3cd99c196828d7625e77e0cd4cd3f5003d5ab57a26990eee8c031a803d4b80fb7777ce05133cb4fda5bd616a38b448a288dc6'
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,70 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.124.0 — 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **App-to-app page transitions: `Studio.page_transition_direction`** (`:left`, `:right`,
|
|
12
|
+
`:top`, `:bottom`, default nil). Each app moves its pages one way, and each page change
|
|
13
|
+
draws that direction's fade or unfold at random, at the Codrops demo's values. It is the
|
|
14
|
+
one switch: a set direction turns smooth-load on, metas and navbar pin included. The
|
|
15
|
+
navbar and banner stack hold still; eager images load before the swap, capped at 500ms;
|
|
16
|
+
a same-page visit, reduced motion and a browser without view transitions play nothing.
|
|
17
|
+
With nil, the HTML is unchanged except one more import map entry that nothing fetches.
|
|
18
|
+
New: `studio/page_transitions`, loaded by its own tag only with the setting on, and the
|
|
19
|
+
`data-vt-morph` marker for a named element (the `agent_chat` portrait and composer carry
|
|
20
|
+
it; a host marks its own when it sets a direction). No host changes here. The doc holds
|
|
21
|
+
a proposed assignment. [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
|
|
22
|
+
- **The marketing kit: every app has a public `/marketing-kit`, and its footer links
|
|
23
|
+
it, with no setup.** `Studio.routes` draws the page (`Studio.draw_marketing_kit_routes`,
|
|
24
|
+
default true; helper `studio_marketing_kit_path`) with no sign-in, and a host's own
|
|
25
|
+
route of that path wins from either side of `Studio.routes`. `Studio.marketing_kit`
|
|
26
|
+
(a Hash or a callable taking the view) declares `logos`, `palette`, `typefaces` and
|
|
27
|
+
`tagline`; unset, the logos derive from `Studio.navbar_wordmark`, the footer's lockup
|
|
28
|
+
and `Studio.theme_logos`, and the colours from what the app paints. Logos read "Download Light" and "Download Dark", the PNG when
|
|
29
|
+
there is one, else the SVG; a missing file shows no link and no broken image. One
|
|
30
|
+
click on a colour copies its hex and confirms (new: the lazy `copy-text` controller).
|
|
31
|
+
An app with no database can draw the page alone with `Studio.marketing_kit_routes(self)`.
|
|
32
|
+
- **The footer's Marketing Kit link**, in the brand block (`.ftr-kit`), on every app
|
|
33
|
+
with the page, signed in or out. An app that links the page in a column or on the
|
|
34
|
+
legal line gets no second link, and `marketing_kit: false` in the facts shows none.
|
|
35
|
+
Its columns and legal line are unchanged. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md),
|
|
36
|
+
*The marketing kit*.
|
|
37
|
+
- **`studio/_page_header`, and the triple stack as a standard.** A page title,
|
|
38
|
+
optional meta beside it and optional tabs on the right. At rest it has no
|
|
39
|
+
background and no border; once stuck under the navbar it takes the bar. It joins
|
|
40
|
+
the pinned stack (`data-pin="page"`), so a sticky table header sits under it:
|
|
41
|
+
navbar, page row, table header, with no overlap. `/admin/style`, `/admin/models`
|
|
42
|
+
and `/admin/surveys` use it.
|
|
43
|
+
[`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md) carries the rule and the locals. New:
|
|
44
|
+
`studio/page_header` and the lazy `page-header` controller.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **A signed-out visitor's link sidebar has no "Links" title.** Signed-in and admin titles
|
|
49
|
+
are unchanged, and the panel keeps its accessible name.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **A tap no longer leaves the navbar's account group lit.** On a touch screen a tap
|
|
54
|
+
leaves `:hover` on what was tapped, so the gear, the name and the avatar (THE ACCOUNT
|
|
55
|
+
GROUP in `engine.css`) stayed lit after the sidebar closed. Every `:hover` of the
|
|
56
|
+
group now sits inside `@media (hover: hover)`; `:focus-visible` stays outside it, so
|
|
57
|
+
a keyboard still lights the group. A mouse sees no change.
|
|
58
|
+
- **The theme toggle draws left of the gear at every width, signed in or out.** The
|
|
59
|
+
signed-in desktop rail already did. The signed-out rail (`layouts/_navbar` and the
|
|
60
|
+
signed-out branch of `components/_user_nav`) and the phone row drew the gear first.
|
|
61
|
+
The navbar baseline (`test/fixtures/files/navbar_unset_baseline`) is regenerated for
|
|
62
|
+
this order: 14 of its 19 files move, and only the order of their lines changes. A
|
|
63
|
+
host that draws its own pair keeps its own order: the hub's
|
|
64
|
+
`layouts/_navbar_phone_account` still draws gear, then toggle.
|
|
65
|
+
- **A table's sticky header no longer sits on top of the page row on `/admin/models`.**
|
|
66
|
+
- **The open tab on `/admin/models` is scrolled into view at phone width.**
|
|
67
|
+
- **`studio/pinned_stack` places a header with no `data-pin` at its place in the
|
|
68
|
+
document.** It was appended, so it read as below every other layer, and a layer
|
|
69
|
+
under it composed a top of 0.
|
|
70
|
+
|
|
7
71
|
## 0.123.0 — 2026-11-03
|
|
8
72
|
|
|
9
73
|
### Added
|
data/README.md
CHANGED
|
@@ -41,6 +41,7 @@ resolved.
|
|
|
41
41
|
- **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
|
|
42
42
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
43
43
|
- **Branded loader**: `studio_loader`, `studio_loader_pending` and `studio_loader_overlay` draw the app's own icon in its theme colours for page loads, long waits and pending buttons, in the style `config.loader` names (Breathe by default). See [`docs/LOADER.md`](docs/LOADER.md).
|
|
44
|
+
- **App-to-app page transitions**: `config.page_transition_direction` (`:left`, `:right`, `:top`, `:bottom`) moves an app's pages one way on every page change, with the navbar and banners held still. It turns smooth-load on. See [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
|
|
44
45
|
- **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
45
46
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
46
47
|
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off; `config.site_footer_chat_path` adds a standard Chat link to the Contact column. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
@@ -169,6 +170,7 @@ Studio.configure do |config|
|
|
|
169
170
|
# multi-second pages BEFORE opting in — no-preview holds the old page until
|
|
170
171
|
# the fresh response arrives.
|
|
171
172
|
config.smooth_load = true
|
|
173
|
+
# config.page_transition_direction = :left # the app's page move; also turns smooth-load on
|
|
172
174
|
# Nav spinner minimum display (default 2500). Smooth-load apps typically
|
|
173
175
|
# drop to ~300; keep the high floor if multi-second ops ride the spinner.
|
|
174
176
|
config.nav_spinner_min_ms = 300
|
|
@@ -285,6 +287,17 @@ actions; the engine supplies only the banner.
|
|
|
285
287
|
|
|
286
288
|
## UI Primitives
|
|
287
289
|
|
|
290
|
+
### The page header — `studio/_page_header`
|
|
291
|
+
|
|
292
|
+
An admin page's title: a plain header at rest, a bar once stuck under the navbar,
|
|
293
|
+
in the pinned stack so a table's sticky header sits under it
|
|
294
|
+
([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
|
|
295
|
+
|
|
296
|
+
```erb
|
|
297
|
+
<%= render "studio/page_header", title: "Models", meta: "v1.2.3", label: "Models",
|
|
298
|
+
tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %>
|
|
299
|
+
```
|
|
300
|
+
|
|
288
301
|
### The "at" time stamp — `at_time_tag`
|
|
289
302
|
|
|
290
303
|
Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
|
|
@@ -1159,7 +1172,9 @@ pointing at or focusing any of the gear, the name or the avatar lights all three
|
|
|
1159
1172
|
(the primary colour, a turn of the gear, a primary glow on the avatar). The
|
|
1160
1173
|
same holds for a host sidebar: a host that sets both `navbar_menu_trigger` and
|
|
1161
1174
|
`navbar_account_sidebar` gets its gear and account lit as one menu. An account
|
|
1162
|
-
link is never grouped (`engine.css`, *THE ACCOUNT GROUP*).
|
|
1175
|
+
link is never grouped (`engine.css`, *THE ACCOUNT GROUP*). Pointing needs a
|
|
1176
|
+
pointer: the hover half sits inside `@media (hover: hover)`, so a tap on a touch
|
|
1177
|
+
screen lights nothing, and a keyboard still does.
|
|
1163
1178
|
|
|
1164
1179
|
## Transactional emails
|
|
1165
1180
|
|
|
@@ -223,6 +223,93 @@ body.modal-open .studio-app-banner {
|
|
|
223
223
|
line-height: 1.4;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/* -- The page header (studio/_page_header, docs/PAGE_HEADER.md) ---------
|
|
227
|
+
A normal page header at rest and a bar once stuck. The border is there in
|
|
228
|
+
both states, in transparent at rest, so the row's height never changes and
|
|
229
|
+
the pinned stack has nothing to republish. The layout is owned here: the
|
|
230
|
+
engine's views depend on no responsive utility a host may not have emitted.
|
|
231
|
+
The fade is owned too, because .fade-edge-x is in the opt-in motion layer, and
|
|
232
|
+
it marks only an end that hides a tab (studio/controllers/page_header). */
|
|
233
|
+
|
|
234
|
+
@utility page-header {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: center;
|
|
237
|
+
gap: 0.75rem;
|
|
238
|
+
padding-block: 0.5rem;
|
|
239
|
+
border-block: 1px solid transparent;
|
|
240
|
+
background-color: transparent;
|
|
241
|
+
transition: background-color 150ms ease, border-color 150ms ease;
|
|
242
|
+
|
|
243
|
+
&[data-stuck],
|
|
244
|
+
[data-studio-controller-failed~="page-header"] + & {
|
|
245
|
+
@apply bg-surface border-subtle;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@utility page-header-sentinel {
|
|
250
|
+
position: absolute;
|
|
251
|
+
width: 0;
|
|
252
|
+
height: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@utility page-header-lead {
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: baseline;
|
|
258
|
+
gap: 0.5rem;
|
|
259
|
+
flex-shrink: 0;
|
|
260
|
+
min-width: 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@utility page-header-title {
|
|
264
|
+
@apply text-heading;
|
|
265
|
+
font-size: calc(var(--type-base, 16px) * 1.5);
|
|
266
|
+
font-weight: var(--type-display-weight, 800);
|
|
267
|
+
line-height: 1.25;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@utility page-header-meta {
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
|
|
273
|
+
@media (width < 40rem) {
|
|
274
|
+
display: none;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@utility page-header-tabs {
|
|
279
|
+
--page-header-fade-start: 0px;
|
|
280
|
+
--page-header-fade-end: 0px;
|
|
281
|
+
flex: 1 1 0%;
|
|
282
|
+
min-width: 0;
|
|
283
|
+
overflow-x: auto;
|
|
284
|
+
scrollbar-width: none;
|
|
285
|
+
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
|
|
286
|
+
mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
|
|
287
|
+
|
|
288
|
+
&[data-fade-start] {
|
|
289
|
+
--page-header-fade-start: 2rem;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
&[data-fade-end] {
|
|
293
|
+
--page-header-fade-end: 2rem;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* width: max-content, then an auto margin: the tabs float right while they fit,
|
|
298
|
+
and start at the left, all reachable, once they do not. justify-content would
|
|
299
|
+
push the overflow past the start, where no scroll can reach it. */
|
|
300
|
+
@utility page-header-track {
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
gap: 0.5rem;
|
|
304
|
+
width: max-content;
|
|
305
|
+
margin-left: auto;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@utility page-header-actions {
|
|
309
|
+
flex-shrink: 0;
|
|
310
|
+
margin-left: auto;
|
|
311
|
+
}
|
|
312
|
+
|
|
226
313
|
/* -- Scrims: tints and shades ----------------------------------------------
|
|
227
314
|
A scrim is the wash laid over media (a photo, a video) so text reads on it.
|
|
228
315
|
It comes in two TONES, and the tone is a design decision, not a colour pick:
|
|
@@ -565,6 +652,87 @@ body.modal-open .studio-app-banner {
|
|
|
565
652
|
animation: none;
|
|
566
653
|
}
|
|
567
654
|
|
|
655
|
+
/* -- App-to-app page transitions --------------------------------------------
|
|
656
|
+
Studio.page_transition_direction (studio/page_transitions, docs/PAGE_TRANSITIONS.md).
|
|
657
|
+
Every rule is gated on an attribute only that script sets, so an app without
|
|
658
|
+
the setting keeps the fade above. Attribute selectors outrank the bare
|
|
659
|
+
::view-transition-*(root) rules, here and in a host. The effects are from the
|
|
660
|
+
Codrops Page Transitions demo, at its values; the keyframes were written fresh.
|
|
661
|
+
|
|
662
|
+
Black behind the pages gives the shading: a page at opacity 0.3 darkens as it
|
|
663
|
+
moves. */
|
|
664
|
+
html[data-page-transition]:not([data-page-transition="still"])::view-transition {
|
|
665
|
+
background: #000;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
html[data-page-transition]::view-transition-old(root),
|
|
669
|
+
html[data-page-transition]::view-transition-new(root) {
|
|
670
|
+
mix-blend-mode: normal;
|
|
671
|
+
animation-duration: 700ms;
|
|
672
|
+
animation-timing-function: ease;
|
|
673
|
+
animation-fill-mode: both;
|
|
674
|
+
backface-visibility: hidden;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/* A same-page visit (a refresh, a poll) draws nothing. */
|
|
678
|
+
html[data-page-transition="still"]::view-transition-old(root),
|
|
679
|
+
html[data-page-transition="still"]::view-transition-new(root) {
|
|
680
|
+
animation: none;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* The banner stack holds still with the navbar. The name goes on the one element
|
|
684
|
+
the script marks, since a duplicate name silently cancels the transition. It
|
|
685
|
+
lasts for the visit alone: a name at rest makes the stack a stacking context,
|
|
686
|
+
and its tooltip then paints under the navbar. No group animation either: a page
|
|
687
|
+
left scrolled has the stack above the viewport, and it would slide down. */
|
|
688
|
+
html[data-page-transition-direction][data-page-transition-visit] [data-vt-held] {
|
|
689
|
+
view-transition-name: studio-bar-stack;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
::view-transition-group(studio-bar-stack),
|
|
693
|
+
::view-transition-old(studio-bar-stack),
|
|
694
|
+
::view-transition-new(studio-bar-stack) {
|
|
695
|
+
animation: none;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* A named element moves with the page, unless both pages share its name. The
|
|
699
|
+
script decides that before the old page's picture, and only then is the name let
|
|
700
|
+
through, so the morph stands. data-vt-morph carries the name it marks. */
|
|
701
|
+
html[data-page-transition-direction]:not([data-page-transition-morph]) [data-vt-morph] {
|
|
702
|
+
view-transition-name: none !important;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
html[data-page-transition="fade-left"]::view-transition-old(root) { animation-name: pt-out-left; }
|
|
706
|
+
html[data-page-transition="fade-left"]::view-transition-new(root) { animation-name: pt-in-from-right; }
|
|
707
|
+
html[data-page-transition="fade-right"]::view-transition-old(root) { animation-name: pt-out-right; }
|
|
708
|
+
html[data-page-transition="fade-right"]::view-transition-new(root) { animation-name: pt-in-from-left; }
|
|
709
|
+
html[data-page-transition="fade-top"]::view-transition-old(root) { animation-name: pt-out-top; }
|
|
710
|
+
html[data-page-transition="fade-top"]::view-transition-new(root) { animation-name: pt-in-from-bottom; }
|
|
711
|
+
html[data-page-transition="fade-bottom"]::view-transition-old(root) { animation-name: pt-out-bottom; }
|
|
712
|
+
html[data-page-transition="fade-bottom"]::view-transition-new(root) { animation-name: pt-in-from-top; }
|
|
713
|
+
|
|
714
|
+
html[data-page-transition="unfold-right"]::view-transition-old(root) { animation-name: pt-out-left; }
|
|
715
|
+
html[data-page-transition="unfold-right"]::view-transition-new(root) { animation-name: pt-unfold-right; transform-origin: 0% 50%; }
|
|
716
|
+
html[data-page-transition="unfold-left"]::view-transition-old(root) { animation-name: pt-out-right; }
|
|
717
|
+
html[data-page-transition="unfold-left"]::view-transition-new(root) { animation-name: pt-unfold-left; transform-origin: 100% 50%; }
|
|
718
|
+
html[data-page-transition="unfold-bottom"]::view-transition-old(root) { animation-name: pt-out-top; }
|
|
719
|
+
html[data-page-transition="unfold-bottom"]::view-transition-new(root) { animation-name: pt-unfold-bottom; transform-origin: 50% 0%; }
|
|
720
|
+
html[data-page-transition="unfold-top"]::view-transition-old(root) { animation-name: pt-out-bottom; }
|
|
721
|
+
html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-name: pt-unfold-top; transform-origin: 50% 100%; }
|
|
722
|
+
|
|
723
|
+
@keyframes pt-out-left { to { opacity: 0.3; transform: translateX(-100%); } }
|
|
724
|
+
@keyframes pt-out-right { to { opacity: 0.3; transform: translateX(100%); } }
|
|
725
|
+
@keyframes pt-out-top { to { opacity: 0.3; transform: translateY(-100%); } }
|
|
726
|
+
@keyframes pt-out-bottom { to { opacity: 0.3; transform: translateY(100%); } }
|
|
727
|
+
@keyframes pt-in-from-right { from { opacity: 0.3; transform: translateX(100%); } }
|
|
728
|
+
@keyframes pt-in-from-left { from { opacity: 0.3; transform: translateX(-100%); } }
|
|
729
|
+
@keyframes pt-in-from-bottom { from { opacity: 0.3; transform: translateY(100%); } }
|
|
730
|
+
@keyframes pt-in-from-top { from { opacity: 0.3; transform: translateY(-100%); } }
|
|
731
|
+
@keyframes pt-unfold-right { from { opacity: 0; transform: perspective(1200px) translateX(100%) rotateY(90deg); } }
|
|
732
|
+
@keyframes pt-unfold-left { from { opacity: 0; transform: perspective(1200px) translateX(-100%) rotateY(-90deg); } }
|
|
733
|
+
@keyframes pt-unfold-bottom { from { opacity: 0; transform: perspective(1200px) translateY(100%) rotateX(-90deg); } }
|
|
734
|
+
@keyframes pt-unfold-top { from { opacity: 0; transform: perspective(1200px) translateY(-100%) rotateX(90deg); } }
|
|
735
|
+
|
|
568
736
|
/* Turbo's progress bar is the slow-load feedback under no-preview (the old
|
|
569
737
|
page holds until the fresh response). Theme it so every app's bar matches
|
|
570
738
|
its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
|
|
@@ -784,36 +952,67 @@ body.modal-open .studio-app-banner {
|
|
|
784
952
|
saying they are its one menu, as Turf Monster's gear sidebar is.
|
|
785
953
|
With Studio.navbar_name_in_rows the name is its own control in row 1
|
|
786
954
|
([data-nav-name-trigger]), and pointing at it lights the group too.
|
|
787
|
-
An account link is never grouped.
|
|
955
|
+
An account link is never grouped.
|
|
956
|
+
|
|
957
|
+
POINTING NEEDS A POINTER. Every :hover below sits inside
|
|
958
|
+
@media (hover: hover), and each :focus-visible rule stays outside it. On
|
|
959
|
+
a touch screen a tap leaves :hover on what was tapped until the next tap
|
|
960
|
+
lands elsewhere, so the group stayed lit after its sidebar closed. Tailwind's
|
|
961
|
+
hover: and group-hover: variants already carry this guard; these
|
|
962
|
+
hand-written selectors do not get it for free. */
|
|
788
963
|
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger] svg,
|
|
789
964
|
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-nav-account] > [aria-hidden] > * {
|
|
790
965
|
transition: transform 200ms ease, box-shadow 200ms ease;
|
|
791
966
|
}
|
|
792
|
-
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"])
|
|
793
|
-
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:
|
|
794
|
-
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:
|
|
795
|
-
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
967
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger],
|
|
968
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
|
|
969
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
796
970
|
box-shadow:
|
|
797
971
|
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
798
972
|
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
799
973
|
}
|
|
800
974
|
@media (prefers-reduced-motion: no-preference) {
|
|
801
|
-
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:
|
|
975
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
|
|
976
|
+
}
|
|
977
|
+
@media (hover: hover) {
|
|
978
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
|
|
979
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger],
|
|
980
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
|
|
981
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
|
|
982
|
+
box-shadow:
|
|
983
|
+
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
984
|
+
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
985
|
+
}
|
|
986
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
987
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
|
|
988
|
+
}
|
|
802
989
|
}
|
|
803
990
|
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls] svg,
|
|
804
991
|
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-account] > [aria-hidden] > * {
|
|
805
992
|
transition: transform 200ms ease, box-shadow 200ms ease;
|
|
806
993
|
}
|
|
807
|
-
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"]))
|
|
808
|
-
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:
|
|
809
|
-
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:
|
|
810
|
-
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
994
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls],
|
|
995
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
|
|
996
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
811
997
|
box-shadow:
|
|
812
998
|
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
813
999
|
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
814
1000
|
}
|
|
815
1001
|
@media (prefers-reduced-motion: no-preference) {
|
|
816
|
-
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:
|
|
1002
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
|
|
1003
|
+
}
|
|
1004
|
+
@media (hover: hover) {
|
|
1005
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
|
|
1006
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls],
|
|
1007
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
|
|
1008
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
|
|
1009
|
+
box-shadow:
|
|
1010
|
+
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
1011
|
+
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
1012
|
+
}
|
|
1013
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1014
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
|
|
1015
|
+
}
|
|
817
1016
|
}
|
|
818
1017
|
/* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
|
|
819
1018
|
navbar renders the engine's user nav gets this, Turf Monster included:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# /marketing-kit: the app's logos, colours and typefaces, for anyone to use.
|
|
5
|
+
# See Studio::MarketingKit and docs/SITE_FOOTER.md.
|
|
6
|
+
#
|
|
7
|
+
# Public by design: a journalist or a partner has no account. It inherits the
|
|
8
|
+
# host's ApplicationController so the page renders in the app's layout, navbar
|
|
9
|
+
# and theme, and it skips each gate a host is known to put there:
|
|
10
|
+
# Studio::ErrorHandling's sign-in gate (every adopter), the hub's admin wall,
|
|
11
|
+
# and turf-monster's profile-completion redirect. A host with a gate of another
|
|
12
|
+
# name skips it in a to_prepare block (docs/SITE_FOOTER.md).
|
|
13
|
+
#
|
|
14
|
+
# It needs no table and no session, so an app with neither can draw it. Where
|
|
15
|
+
# ThemeSetting exists, the colours read it (Studio.marketing_kit_colors).
|
|
16
|
+
class MarketingKitController < ::ApplicationController
|
|
17
|
+
HOST_GATES = %i[require_authentication require_admin_wall require_admin require_login
|
|
18
|
+
authenticate_user! require_profile_completion].freeze
|
|
19
|
+
|
|
20
|
+
HOST_GATES.each { |gate| skip_before_action gate, raise: false }
|
|
21
|
+
|
|
22
|
+
def show; end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -11,8 +11,8 @@ module Studio
|
|
|
11
11
|
# <%= studio_footer_map %> the map alone, e.g. on a contact page
|
|
12
12
|
#
|
|
13
13
|
# THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
|
|
14
|
-
# renders the default footer (the site name, the logo,
|
|
15
|
-
#
|
|
14
|
+
# renders the default footer (the site name, the logo, the legal links the host
|
|
15
|
+
# has routes for, and the Marketing Kit link where the page is drawn), and `config.site_footer = false` turns it off. The
|
|
16
16
|
# booking helpers render NOTHING without Studio.booking_url, and the map
|
|
17
17
|
# nothing without an address, so a layout or a shared view can call any of
|
|
18
18
|
# them before an app has declared anything.
|
|
@@ -70,6 +70,19 @@ module Studio
|
|
|
70
70
|
zoom: options[:zoom], controls: options.fetch(:controls, true)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# The kit /marketing-kit prints (Studio.marketing_kit_for). A callable that
|
|
74
|
+
# raises costs the app's own facts, not the page: in production the error is
|
|
75
|
+
# logged once and the page shows what derives from the theme. In development
|
|
76
|
+
# and test it raises.
|
|
77
|
+
def studio_marketing_kit
|
|
78
|
+
Studio.marketing_kit_for(self)
|
|
79
|
+
rescue StandardError => error
|
|
80
|
+
raise if studio_site_footer_raise?
|
|
81
|
+
|
|
82
|
+
studio_site_footer_report(error)
|
|
83
|
+
Studio.marketing_kit_for(self, declared: nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
# ---- booking ------------------------------------------------------------
|
|
74
87
|
|
|
75
88
|
def studio_booking? = Studio.booking_url.present?
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// copy-text: one click puts a value on the clipboard, and says so. It sits on the
|
|
2
|
+
// element around the control (studio/marketing_kit/_kit):
|
|
3
|
+
//
|
|
4
|
+
// <div data-studio-controller="copy-text" data-copy-text-text-value="#8E82FE">
|
|
5
|
+
// <button type="button" data-studio-action="click->copy-text#copy">...</button>
|
|
6
|
+
// <span role="status" data-copy-text-target="status"></span>
|
|
7
|
+
// </div>
|
|
8
|
+
//
|
|
9
|
+
// The status is written only after the browser answers, so "Copied" is never
|
|
10
|
+
// shown for a copy that failed. navigator.clipboard exists only in a secure
|
|
11
|
+
// context and may refuse; the older selection copy then gets one try.
|
|
12
|
+
import { Controller } from "@hotwired/stimulus"
|
|
13
|
+
|
|
14
|
+
const SHOWN_MS = 1600
|
|
15
|
+
|
|
16
|
+
// True when `text` reached the clipboard. It never rejects.
|
|
17
|
+
export async function copyToClipboard(text, nav = navigator, doc = document) {
|
|
18
|
+
try {
|
|
19
|
+
await nav.clipboard.writeText(text)
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return selectionCopy(text, doc)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selectionCopy(text, doc) {
|
|
27
|
+
const field = doc.createElement("textarea")
|
|
28
|
+
field.value = text
|
|
29
|
+
field.setAttribute("readonly", "")
|
|
30
|
+
field.style.position = "fixed"
|
|
31
|
+
field.style.opacity = "0"
|
|
32
|
+
doc.body.appendChild(field)
|
|
33
|
+
try {
|
|
34
|
+
field.select()
|
|
35
|
+
return doc.execCommand("copy")
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
} finally {
|
|
39
|
+
field.remove()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default class extends Controller {
|
|
44
|
+
static targets = ["status"]
|
|
45
|
+
static values = {
|
|
46
|
+
text: String,
|
|
47
|
+
copied: { type: String, default: "Copied" },
|
|
48
|
+
failed: { type: String, default: "Could not copy" }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async copy() {
|
|
52
|
+
const copied = await copyToClipboard(this.textValue)
|
|
53
|
+
if (!this.hasStatusTarget) return
|
|
54
|
+
|
|
55
|
+
this.statusTarget.textContent = copied ? this.copiedValue : this.failedValue
|
|
56
|
+
clearTimeout(this.timer)
|
|
57
|
+
this.timer = setTimeout(() => { this.statusTarget.textContent = "" }, SHOWN_MS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disconnect() {
|
|
61
|
+
clearTimeout(this.timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// page-header: says when studio/_page_header is stuck, marks which ends of its
|
|
2
|
+
// tabs hide a tab, and shows the open tab on a narrow screen. It sits on the row's
|
|
3
|
+
// sentinel, so the row keeps its data-studio-controller for the page's own.
|
|
4
|
+
//
|
|
5
|
+
// <div class="page-header-sentinel" data-studio-controller="page-header"></div>
|
|
6
|
+
// <nav class="page-header sticky" data-pin="page">...</nav>
|
|
7
|
+
//
|
|
8
|
+
// A lazy controller (studio/stimulus): only a page with the row fetches it. The
|
|
9
|
+
// row's tabs are links, so they work without it. If it fails to load, the sentinel
|
|
10
|
+
// carries data-studio-controller-failed and engine.css gives the row its bar at
|
|
11
|
+
// all times, so a stuck row never lays a transparent title over the page.
|
|
12
|
+
import { Controller } from "@hotwired/stimulus"
|
|
13
|
+
import { hiddenEnds, isStuck, revealBy } from "studio/page_header"
|
|
14
|
+
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
connect() {
|
|
17
|
+
this.row = this.element.nextElementSibling
|
|
18
|
+
if (!this.row) return
|
|
19
|
+
this.tabs = this.row.querySelector(".page-header-tabs")
|
|
20
|
+
this.schedule = this.schedule.bind(this)
|
|
21
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
22
|
+
window.addEventListener("resize", this.schedule, { passive: true })
|
|
23
|
+
this.tabs?.addEventListener("scroll", this.schedule, { passive: true })
|
|
24
|
+
this.revealOpenTab()
|
|
25
|
+
this.sync()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
disconnect() {
|
|
29
|
+
window.removeEventListener("scroll", this.schedule)
|
|
30
|
+
window.removeEventListener("resize", this.schedule)
|
|
31
|
+
this.tabs?.removeEventListener("scroll", this.schedule)
|
|
32
|
+
if (this.frame) cancelAnimationFrame(this.frame)
|
|
33
|
+
this.frame = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
schedule() {
|
|
37
|
+
if (this.frame) return
|
|
38
|
+
this.frame = requestAnimationFrame(() => {
|
|
39
|
+
this.frame = null
|
|
40
|
+
this.sync()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sync() {
|
|
45
|
+
const stuck = isStuck(this.row.getBoundingClientRect().top, this.element.getBoundingClientRect().top)
|
|
46
|
+
const ends = this.tabs ? hiddenEnds(this.tabs) : null
|
|
47
|
+
|
|
48
|
+
this.row.toggleAttribute("data-stuck", stuck)
|
|
49
|
+
if (!ends) return
|
|
50
|
+
this.tabs.toggleAttribute("data-fade-start", ends.start)
|
|
51
|
+
this.tabs.toggleAttribute("data-fade-end", ends.end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Moves the tabs only: scrollIntoView would also move the page.
|
|
55
|
+
revealOpenTab() {
|
|
56
|
+
const open = this.tabs?.querySelector("[aria-current]")
|
|
57
|
+
if (!open) return
|
|
58
|
+
this.tabs.scrollLeft += revealBy(this.tabs.getBoundingClientRect(), open.getBoundingClientRect())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// studio/page_header: the page header's two measurements, as pure functions. It
|
|
2
|
+
// imports nothing; test/javascript/page_header.test.mjs loads it as a data: module.
|
|
3
|
+
|
|
4
|
+
// A sticky row is stuck once it has left its place in the flow. restTop is that
|
|
5
|
+
// place, measured by the sentinel beside it. The half pixel is rounding: at rest
|
|
6
|
+
// the two tops differ by a fraction at most, and a stuck row by at least a pixel.
|
|
7
|
+
export function isStuck(rowTop, restTop) {
|
|
8
|
+
return rowTop - restTop > 0.5
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Which ends of the tabs hide a tab, so a fade marks only an end with more to see.
|
|
12
|
+
export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
|
|
13
|
+
return {
|
|
14
|
+
start: scrollLeft > 1,
|
|
15
|
+
end: scrollLeft + clientWidth < scrollWidth - 1
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How far the tabs must scroll to show a tab, clear of the fade. 0 if it shows.
|
|
20
|
+
export function revealBy(box, tab, margin = 32) {
|
|
21
|
+
if (tab.left < box.left) return tab.left - box.left - margin
|
|
22
|
+
if (tab.right > box.right) return tab.right - box.right + margin
|
|
23
|
+
return 0
|
|
24
|
+
}
|