studio-engine 0.124.0 → 0.126.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 +63 -0
- data/README.md +1 -1
- data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
- data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
- data/app/assets/tailwind/studio_engine/engine.css +69 -1
- data/app/helpers/studio_sidebar_helper.rb +2 -2
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +4 -2
- data/app/javascript/studio/link_sidebar.js +40 -4
- data/app/javascript/studio/page_transitions.js +89 -7
- data/app/javascript/studio/transition_snapshot.js +123 -0
- data/app/views/components/_geo_signpost.html.erb +22 -17
- data/app/views/components/_link_sidebar.html.erb +53 -27
- data/app/views/layouts/studio/_head.html.erb +15 -2
- data/app/views/schema/index.html.erb +1 -1
- data/lib/studio/engine.rb +5 -2
- data/lib/studio/sidebar_sections.rb +4 -2
- data/lib/studio/ui_primitives.rb +3 -0
- data/lib/studio/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b282dc9ef2736f70ae7d985d1617ad22f61f25b6bba0a2b9391804e68c01069d
|
|
4
|
+
data.tar.gz: 3a0234c85465610eb31351583c0b0d743cf1a42f7db193d762869b040255143a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddf281dc8349069d3c2e8aea1a5084dd5030f0b927675ce20fd3a139565ca862e3d52faaa2e6d27a1d76aed9a9e6b566fb1174da3e6a73ffe559466118c914d9
|
|
7
|
+
data.tar.gz: 2d2f261cedcef59c97ce8f4af60b2942b4cfce84d3355fdd7f8604e52193f08c98c73b14592eabcfaf1e284f4b049aea7e7bbac51c3c5c600f745724822255fa
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.126.0 — 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Link sidebar: no section labels, a calmer title, one line a row at rest.** No section shows
|
|
12
|
+
a label word or the ADMIN chip at any level; a subtle divider separates the groups, and each
|
|
13
|
+
group keeps its words as its accessible name. Every row is one line and one height at rest. A
|
|
14
|
+
link's `desc:` fades in on hover or keyboard focus with no layout change: 200ms after 150ms in,
|
|
15
|
+
140ms out, the label lifting 8px by a transform. A touch screen shows none, and reduced motion
|
|
16
|
+
slides nothing. The engine's Profile row no longer passes a `desc:`. The Geo rows behave the
|
|
17
|
+
same; the row that cannot link holds only the string to copy, reveals it on a tap as well, and
|
|
18
|
+
keeps the long form in its title. A plain emoji now sits in the swap's box
|
|
19
|
+
(`.studio-emoji-box`), so a mixed list is one height. The title is `type-subhead`: signed in, admin included, the name the navbar shows
|
|
20
|
+
(`Studio.navbar_user_name`); signed out, Good morning, Good afternoon or Good evening by the
|
|
21
|
+
browser's local time, written by `studio/link_sidebar`. The panel's accessible name stays
|
|
22
|
+
"Admin menu" or "Links menu". The navbar baseline is regenerated for it.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A panel link followed by Back no longer leaves the panel off-screen for good.** Turbo
|
|
27
|
+
clones the page one tick after `turbo:before-cache`, so a response inside a
|
|
28
|
+
`components/_sidebar_panel`'s 200ms leave filed the panel with Alpine's phase classes on it.
|
|
29
|
+
A hovered link has a prefetched response, so this reached production too: after Back the
|
|
30
|
+
gear set the flag, and the panel sat one width past the viewport, until a full load. The new
|
|
31
|
+
`studio/transition_snapshot` finishes every running x-transition before the clone, and
|
|
32
|
+
strips a whole phase's named classes from a restored page. Hosts pick it up with the gem: it
|
|
33
|
+
covers their own panels on the shared partial and their other `x-show` transitions. A host
|
|
34
|
+
that overrides `layouts/studio/_head` gets it from `studio/application`.
|
|
35
|
+
|
|
36
|
+
- `/admin/schema`: a database with an expression index renders. Rails returns such an index's
|
|
37
|
+
columns as a String, and the page called `join` on it, which answered 500.
|
|
38
|
+
|
|
39
|
+
## 0.125.0 — 2026-11-04
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Page transitions across full page loads.** With `Studio.page_transition_direction` set,
|
|
44
|
+
a same-origin full load plays the app's direction through the browser's cross-document
|
|
45
|
+
view transition: the same pair of styles, keyframes and timing as a Turbo visit, with the
|
|
46
|
+
navbar and banners held still. This reaches strict-policy pages, which must stay their
|
|
47
|
+
own documents. New files, linked only with a direction: `studio/page_transition_full_load.css`
|
|
48
|
+
and the classic `studio/page_transition_reveal.js`, which carries the request's nonce. A
|
|
49
|
+
reload, a same-path load, reduced motion and a browser without support load plainly. A full
|
|
50
|
+
load has no morph, and it cannot wait for images, so the 500ms image cap does not apply.
|
|
51
|
+
With no direction, a host renders no new tag.
|
|
52
|
+
[`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **A Turbo visit answered by `turbo-visit-control: reload` no longer plays the engine's fade**
|
|
57
|
+
before the full load. Turbo opens its view transition before it reads the meta, so that
|
|
58
|
+
transition now draws nothing.
|
|
59
|
+
- **The navbar no longer draws twice during a page transition** when the old header is taller
|
|
60
|
+
than the new one, as on Back to a scrolled page. This changes Turbo visits too: only the new
|
|
61
|
+
navbar draws.
|
|
62
|
+
|
|
63
|
+
- Link sidebar: a one-line row centers its icon on its label. It sat 3px low. A row with a
|
|
64
|
+
description keeps its icon at the top. The navbar baseline is regenerated for it.
|
|
65
|
+
- Page transitions: an advance visit to the same path keeps a shared `data-vt-morph` name, so
|
|
66
|
+
a launch panel still morphs into the chat at its own address. A refresh stays silent.
|
|
67
|
+
- Page transitions: a GET that redirects moves the page once. It played the move twice, because
|
|
68
|
+
Turbo renders the response and then visits the final address with that same response.
|
|
69
|
+
|
|
7
70
|
## 0.124.0 — 2026-11-04
|
|
8
71
|
|
|
9
72
|
### Added
|
data/README.md
CHANGED
|
@@ -41,7 +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
|
+
- **App-to-app page transitions**: `config.page_transition_direction` (`:left`, `:right`, `:top`, `:bottom`) moves an app's pages one way on every page change, full page loads included, with the navbar and banners held still. It turns smooth-load on. See [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
|
|
45
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).
|
|
46
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).
|
|
47
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).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// studio/page_transition_reveal: the arriving half of a full-load page transition
|
|
2
|
+
// (docs/PAGE_TRANSITIONS.md, "Full page loads"). Loaded only with
|
|
3
|
+
// Studio.page_transition_direction set.
|
|
4
|
+
//
|
|
5
|
+
// CLASSIC AND SYNCHRONOUS ON PURPOSE. pagereveal fires before the first render, and a listener
|
|
6
|
+
// bound after that misses it for good. A module is deferred, so it can be too late.
|
|
7
|
+
//
|
|
8
|
+
// It holds no table of styles. The leaving page drew the style with studio/page_transitions
|
|
9
|
+
// at pageswap and left it here. No style means the leaving page did not plan this one (its
|
|
10
|
+
// module had not run, or it was the same path), so the load is plain, not the browser's fade.
|
|
11
|
+
(function () {
|
|
12
|
+
var STYLE_KEY = "studio-page-transition-style"
|
|
13
|
+
var HOLD_CAP_MS = 3000
|
|
14
|
+
|
|
15
|
+
// Holds the first render to the end of parsing, on an arrival only (nothing has this id). A
|
|
16
|
+
// render of half a body would give the new picture no navbar or stack to hold still. The
|
|
17
|
+
// browser sets no limit on this hold, so the timer does: a slow body paints at the cap. The
|
|
18
|
+
// cap sits inside the browser's own limit on the transition, so the motion still plays.
|
|
19
|
+
function holdForTheWholePage() {
|
|
20
|
+
try {
|
|
21
|
+
if (!window.sessionStorage.getItem(STYLE_KEY)) return
|
|
22
|
+
} catch (_error) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
var link = document.createElement("link")
|
|
26
|
+
link.rel = "expect"
|
|
27
|
+
link.href = "#studio-page-transition-end-of-parse"
|
|
28
|
+
link.setAttribute("blocking", "render")
|
|
29
|
+
document.head.appendChild(link)
|
|
30
|
+
window.setTimeout(function () { link.remove() }, HOLD_CAP_MS)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function takeStyle() {
|
|
34
|
+
try {
|
|
35
|
+
var style = window.sessionStorage.getItem(STYLE_KEY)
|
|
36
|
+
window.sessionStorage.removeItem(STYLE_KEY)
|
|
37
|
+
return style
|
|
38
|
+
} catch (_error) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
holdForTheWholePage()
|
|
44
|
+
|
|
45
|
+
window.addEventListener("pagereveal", function (event) {
|
|
46
|
+
var root = document.documentElement
|
|
47
|
+
// A page back from the back/forward cache still wears what it had on when it left.
|
|
48
|
+
delete root.dataset.pageTransition
|
|
49
|
+
delete root.dataset.pageTransitionVisit
|
|
50
|
+
|
|
51
|
+
var style = takeStyle()
|
|
52
|
+
var transition = event.viewTransition
|
|
53
|
+
if (!transition) return
|
|
54
|
+
var direction = document.querySelector('meta[name="page-transition-direction"]')
|
|
55
|
+
if (!style || !direction) {
|
|
56
|
+
transition.skipTransition()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// engine.css reads these three. The new page's picture comes after this listener, so the
|
|
61
|
+
// held stack and every morph name are settled in time.
|
|
62
|
+
root.dataset.pageTransitionDirection = direction.content
|
|
63
|
+
root.dataset.pageTransition = style
|
|
64
|
+
root.dataset.pageTransitionVisit = ""
|
|
65
|
+
var stack = document.querySelector("[data-vt-held], .studio-bar-stack")
|
|
66
|
+
if (stack) stack.setAttribute("data-vt-held", "")
|
|
67
|
+
|
|
68
|
+
var done = function () {
|
|
69
|
+
if (root.dataset.pageTransition !== style) return
|
|
70
|
+
delete root.dataset.pageTransition
|
|
71
|
+
delete root.dataset.pageTransitionVisit
|
|
72
|
+
}
|
|
73
|
+
transition.finished.then(done, done)
|
|
74
|
+
})
|
|
75
|
+
})()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Opts a document in to the browser's cross-document view transition. A separate file,
|
|
2
|
+
because this at-rule takes no selector: in the engine's bundle it would act on every
|
|
3
|
+
host. layouts/studio/_head links it only with Studio.page_transition_direction set.
|
|
4
|
+
Both documents must carry it. Reduced motion gets no transition at all.
|
|
5
|
+
docs/PAGE_TRANSITIONS.md, "Full page loads". */
|
|
6
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
7
|
+
@view-transition {
|
|
8
|
+
navigation: auto;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -674,7 +674,7 @@ html[data-page-transition]::view-transition-new(root) {
|
|
|
674
674
|
backface-visibility: hidden;
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
-
/* A same-page visit (a refresh, a poll) draws nothing. */
|
|
677
|
+
/* A same-page visit (a refresh, a poll, the second half of a redirect) draws nothing. */
|
|
678
678
|
html[data-page-transition="still"]::view-transition-old(root),
|
|
679
679
|
html[data-page-transition="still"]::view-transition-new(root) {
|
|
680
680
|
animation: none;
|
|
@@ -695,6 +695,12 @@ html[data-page-transition-direction][data-page-transition-visit] [data-vt-held]
|
|
|
695
695
|
animation: none;
|
|
696
696
|
}
|
|
697
697
|
|
|
698
|
+
/* A taller old navbar would show under the new one for the whole motion, then pop off.
|
|
699
|
+
Only the new navbar draws. Where the two match, the new one covers the same pixels. */
|
|
700
|
+
html[data-page-transition]:not([data-page-transition="still"])::view-transition-old(studio-header) {
|
|
701
|
+
opacity: 0;
|
|
702
|
+
}
|
|
703
|
+
|
|
698
704
|
/* A named element moves with the page, unless both pages share its name. The
|
|
699
705
|
script decides that before the old page's picture, and only then is the name let
|
|
700
706
|
through, so the morph stands. data-vt-morph carries the name it marks. */
|
|
@@ -1078,6 +1084,68 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
|
|
|
1078
1084
|
font-size: calc(var(--nav-avatar) / 3 / 0.7);
|
|
1079
1085
|
}
|
|
1080
1086
|
}
|
|
1087
|
+
|
|
1088
|
+
/* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest
|
|
1089
|
+
a row with a description is one line, the height of any other row. On hover or
|
|
1090
|
+
keyboard focus the description fades in under the label, and no layout changes:
|
|
1091
|
+
it is absolute, inside the row's padding, and the label lifts by a transform.
|
|
1092
|
+
|
|
1093
|
+
THE LIFT is half the description's 16px line, which centres the two lines on the
|
|
1094
|
+
row. It comes in over 200ms after 150ms, so a pointer crossing the list leaves no
|
|
1095
|
+
partial lift on the rows it crosses, and it goes in 140ms.
|
|
1096
|
+
|
|
1097
|
+
THE ROW THAT CANNOT LINK (aria-disabled) reveals on :focus as well, because a tap
|
|
1098
|
+
focuses it and matches no :focus-visible. Its string stays selectable.
|
|
1099
|
+
|
|
1100
|
+
POINTING NEEDS A POINTER: :hover sits inside @media (hover: hover), and the focus
|
|
1101
|
+
rules stay outside it, as in THE ACCOUNT GROUP. Under reduced motion nothing
|
|
1102
|
+
slides: the description fades in where it will sit and the label moves at once. */
|
|
1103
|
+
[data-sidebar-row-desc] {
|
|
1104
|
+
position: absolute;
|
|
1105
|
+
inset-inline: 0;
|
|
1106
|
+
top: 100%;
|
|
1107
|
+
opacity: 0;
|
|
1108
|
+
transform: translateY(-0.25rem);
|
|
1109
|
+
pointer-events: none;
|
|
1110
|
+
transition: opacity 140ms ease-in, transform 140ms ease-in;
|
|
1111
|
+
}
|
|
1112
|
+
[data-sidebar-row-described][aria-disabled="true"] [data-sidebar-row-desc] { pointer-events: auto; }
|
|
1113
|
+
[data-sidebar-row-label] { transition: transform 140ms ease-in; }
|
|
1114
|
+
[data-sidebar-row-described]:focus-visible [data-sidebar-row-label],
|
|
1115
|
+
[data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-label] {
|
|
1116
|
+
transform: translateY(-0.5rem);
|
|
1117
|
+
transition: transform 200ms ease-out 150ms;
|
|
1118
|
+
}
|
|
1119
|
+
[data-sidebar-row-described]:focus-visible [data-sidebar-row-desc],
|
|
1120
|
+
[data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-desc] {
|
|
1121
|
+
opacity: 1;
|
|
1122
|
+
transform: translateY(-0.5rem);
|
|
1123
|
+
transition: opacity 200ms ease-out 150ms, transform 200ms ease-out 150ms;
|
|
1124
|
+
}
|
|
1125
|
+
@media (hover: hover) {
|
|
1126
|
+
[data-sidebar-row-described]:hover [data-sidebar-row-label] {
|
|
1127
|
+
transform: translateY(-0.5rem);
|
|
1128
|
+
transition: transform 200ms ease-out 150ms;
|
|
1129
|
+
}
|
|
1130
|
+
[data-sidebar-row-described]:hover [data-sidebar-row-desc] {
|
|
1131
|
+
opacity: 1;
|
|
1132
|
+
transform: translateY(-0.5rem);
|
|
1133
|
+
transition: opacity 200ms ease-out 150ms, transform 200ms ease-out 150ms;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1137
|
+
[data-sidebar-row-label],
|
|
1138
|
+
[data-sidebar-row-described]:hover [data-sidebar-row-label],
|
|
1139
|
+
[data-sidebar-row-described]:focus-visible [data-sidebar-row-label],
|
|
1140
|
+
[data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-label] { transition: none; }
|
|
1141
|
+
[data-sidebar-row-desc],
|
|
1142
|
+
[data-sidebar-row-described]:hover [data-sidebar-row-desc],
|
|
1143
|
+
[data-sidebar-row-described]:focus-visible [data-sidebar-row-desc],
|
|
1144
|
+
[data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-desc] {
|
|
1145
|
+
transform: translateY(-0.5rem);
|
|
1146
|
+
transition: opacity 200ms ease-out 150ms;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1081
1149
|
}
|
|
1082
1150
|
|
|
1083
1151
|
/* THE SCROLL-DRIVEN COLLAPSE (opt-in: config.navbar_scroll_timeline, which puts
|
|
@@ -12,7 +12,7 @@ module StudioSidebarHelper
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# The navbar's name and avatar open the sidebar wherever it renders. The
|
|
15
|
-
# sidebar heads with the engine's own
|
|
15
|
+
# sidebar heads with the engine's own Profile row
|
|
16
16
|
# (Studio::SidebarSections prepends it whenever /profile is drawn), so the
|
|
17
17
|
# account page stays one click away (components/_user_nav). A host may
|
|
18
18
|
# define its own to say otherwise.
|
|
@@ -22,7 +22,7 @@ module StudioSidebarHelper
|
|
|
22
22
|
|
|
23
23
|
# The admin dropdown and the sidebar trigger share the cog glyph — showing
|
|
24
24
|
# both reads as a double gear. When the viewer's resolved sections carry an
|
|
25
|
-
# admin-flagged entry, the sidebar IS the admin menu (its
|
|
25
|
+
# admin-flagged entry, the sidebar IS the admin menu (its accessible name says so) and
|
|
26
26
|
# the engine components skip the dropdown. Declaring only public sections
|
|
27
27
|
# keeps the dropdown, so admins never lose Theme/Navbar/Error Logs.
|
|
28
28
|
def studio_sidebar_replaces_admin_menu?
|
|
@@ -14,6 +14,7 @@ import NavCollapseController from "studio/controllers/nav_collapse_controller"
|
|
|
14
14
|
import ModalHostController from "studio/controllers/modal_host_controller"
|
|
15
15
|
import ToastController from "studio/controllers/toast_controller"
|
|
16
16
|
import LinkSidebarController from "studio/controllers/link_sidebar_controller"
|
|
17
|
+
import "studio/transition_snapshot"
|
|
17
18
|
import IdentityMiniController from "studio/controllers/identity_mini_controller"
|
|
18
19
|
import { startPinnedStack } from "studio/pinned_stack"
|
|
19
20
|
import { installAlpineShims } from "studio/alpine_shims"
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The open flag and the document handlers are studio/link_sidebar, which
|
|
8
8
|
// registers $store.sidebars.linkTreeOpen on alpine:init and before a Turbo
|
|
9
|
-
// render. connect() registers it too,
|
|
9
|
+
// render. connect() registers it too, and writes the panel's greeting, for a
|
|
10
|
+
// sidebar that arrived any other way.
|
|
10
11
|
// Registered by studio/application.
|
|
11
12
|
import { Controller } from "@hotwired/stimulus"
|
|
12
|
-
import { ensureLinkSidebarStore } from "studio/link_sidebar"
|
|
13
|
+
import { ensureLinkSidebarStore, paintGreetings } from "studio/link_sidebar"
|
|
13
14
|
|
|
14
15
|
export default class extends Controller {
|
|
15
16
|
connect() {
|
|
16
17
|
ensureLinkSidebarStore(window.Alpine)
|
|
18
|
+
paintGreetings(document)
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// A host with its own `sidebars` store keeps it and gains the flag
|
|
22
22
|
// (turf-monster's holds gearOpen).
|
|
23
23
|
//
|
|
24
|
+
// THE GREETING. A signed-out panel's title is a greeting by the browser's local
|
|
25
|
+
// time. components/_link_sidebar renders a default; this writes the right word
|
|
26
|
+
// over it on load, after a Turbo visit or snapshot restore (turbo:load), on a bfcache
|
|
27
|
+
// restore, and as the trigger opens the panel. A panel left open across a boundary
|
|
28
|
+
// keeps its word until it next opens.
|
|
29
|
+
//
|
|
24
30
|
// THE DOCUMENT HANDLERS run in the capture phase, so the trigger works for a
|
|
25
31
|
// click that lands before Alpine has bound it. They are installed once, on
|
|
26
32
|
// every page, and do nothing where no link sidebar is rendered.
|
|
@@ -37,6 +43,25 @@ export const LINK_SIDEBAR_PANELS = "#studio-link-sidebar, #studio-link-sidebar-m
|
|
|
37
43
|
export const LINK_SIDEBAR_TRIGGERS = "[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]"
|
|
38
44
|
const CONTROLS_PANEL = '[aria-controls~="studio-link-sidebar"]'
|
|
39
45
|
const CLOSE_BUTTON = "[data-link-sidebar-close]"
|
|
46
|
+
const GREETING = "[data-link-sidebar-greeting]"
|
|
47
|
+
|
|
48
|
+
// The three words, by the hour of `now` in the browser's own time zone.
|
|
49
|
+
export function greetingFor(now) {
|
|
50
|
+
const hour = now.getHours()
|
|
51
|
+
if (hour >= 5 && hour < 12) return "Good morning"
|
|
52
|
+
if (hour >= 12 && hour < 17) return "Good afternoon"
|
|
53
|
+
return "Good evening"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Writes the word into every greeting under `root`, the desktop and the phone panel
|
|
57
|
+
// both. A signed-in panel holds no greeting, so its name is left alone.
|
|
58
|
+
export function paintGreetings(root, now = new Date()) {
|
|
59
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
60
|
+
const word = greetingFor(now)
|
|
61
|
+
root.querySelectorAll(GREETING).forEach((node) => {
|
|
62
|
+
if (node.textContent !== word) node.textContent = word
|
|
63
|
+
})
|
|
64
|
+
}
|
|
40
65
|
|
|
41
66
|
// Registers the flag: a new `sidebars` store, or one more key on the host's.
|
|
42
67
|
// Null before Alpine exists.
|
|
@@ -130,14 +155,18 @@ export function handleLinkSidebarKeydown(event, Alpine) {
|
|
|
130
155
|
const installed = new WeakSet()
|
|
131
156
|
|
|
132
157
|
// Once per window: a second call adds no second listener.
|
|
133
|
-
export function installLinkSidebar({ win, doc } = {}) {
|
|
158
|
+
export function installLinkSidebar({ win, doc, now = () => new Date() } = {}) {
|
|
134
159
|
const host = win || globalThis
|
|
135
160
|
const root = doc || host.document
|
|
136
161
|
if (!root || typeof root.addEventListener !== "function") return false
|
|
137
162
|
if (installed.has(host)) return false
|
|
138
163
|
installed.add(host)
|
|
139
164
|
|
|
140
|
-
const
|
|
165
|
+
const paint = (target = root) => paintGreetings(target, now())
|
|
166
|
+
const register = () => {
|
|
167
|
+
registerLinkSidebar(host.Alpine, root)
|
|
168
|
+
paint()
|
|
169
|
+
}
|
|
141
170
|
root.addEventListener("alpine:init", register)
|
|
142
171
|
root.addEventListener("turbo:load", register)
|
|
143
172
|
root.addEventListener("turbo:before-render", (event) => {
|
|
@@ -145,12 +174,19 @@ export function installLinkSidebar({ win, doc } = {}) {
|
|
|
145
174
|
})
|
|
146
175
|
// A page restored from Turbo's cache or the bfcache comes back closed.
|
|
147
176
|
root.addEventListener("turbo:before-cache", () => closeLinkSidebar(host.Alpine))
|
|
148
|
-
host.addEventListener("pageshow", (event) => {
|
|
149
|
-
|
|
177
|
+
host.addEventListener("pageshow", (event) => {
|
|
178
|
+
if (!event.persisted) return
|
|
179
|
+
closeLinkSidebar(host.Alpine)
|
|
180
|
+
paint()
|
|
181
|
+
})
|
|
182
|
+
root.addEventListener("click", (event) => {
|
|
183
|
+
if (handleLinkSidebarClick(event, host.Alpine) === "toggle") paint()
|
|
184
|
+
}, true)
|
|
150
185
|
root.addEventListener("keydown", (event) => handleLinkSidebarKeydown(event, host.Alpine), true)
|
|
151
186
|
|
|
152
187
|
// Alpine already started (a host that loads it ahead of the module tags).
|
|
153
188
|
if (host.Alpine && host.Alpine.version) register()
|
|
189
|
+
else paint()
|
|
154
190
|
return true
|
|
155
191
|
}
|
|
156
192
|
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// settled ahead of the visit: the held banner stack and the morph names are decided here on
|
|
8
8
|
// turbo:visit and from the response, never first at before-render.
|
|
9
9
|
//
|
|
10
|
+
// A full load is the other path to the same look. The new document draws the motion, so the old
|
|
11
|
+
// one only plans it: at pageswap it names what it holds, draws the style here, and leaves it for
|
|
12
|
+
// studio/page_transition_reveal, the classic script that applies it at pagereveal.
|
|
13
|
+
//
|
|
10
14
|
// The effects are from the Codrops Page Transitions demo; the keyframes were written fresh.
|
|
11
15
|
|
|
12
16
|
export const DIRECTIONS = Object.freeze({
|
|
@@ -16,9 +20,12 @@ export const DIRECTIONS = Object.freeze({
|
|
|
16
20
|
bottom: Object.freeze(["fade-bottom", "unfold-top"]),
|
|
17
21
|
})
|
|
18
22
|
|
|
19
|
-
// A same-page visit (a refresh, a poll): the transition still
|
|
23
|
+
// A same-page visit (a refresh, a poll, the second half of a redirect): the transition still
|
|
24
|
+
// runs, and draws nothing.
|
|
20
25
|
export const STILL = "still"
|
|
21
26
|
export const READY_CAP_MS = 500
|
|
27
|
+
// Read by app/assets/javascripts/studio/page_transition_reveal.js too.
|
|
28
|
+
export const FULL_LOAD_STYLE_KEY = "studio-page-transition-style"
|
|
22
29
|
const MAX_READY_IMAGES = 12
|
|
23
30
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
24
31
|
|
|
@@ -29,13 +36,37 @@ export function pickStyle(direction, random = Math.random) {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
|
|
32
|
-
// and a morph between two pages stands).
|
|
33
|
-
|
|
39
|
+
// and a morph between two pages stands). An advance visit to the same path can bring a new
|
|
40
|
+
// page, as a launch panel does when it gives way to the chat at its own address, so a name
|
|
41
|
+
// the two share still morphs. A refresh replaces, and stays silent.
|
|
42
|
+
export function planVisit({ direction, from, to, transitions, sharedName, advance }, random = Math.random) {
|
|
34
43
|
if (!transitions || !from || !to || !Object.hasOwn(DIRECTIONS, direction)) return null
|
|
35
|
-
if (from === to) return STILL
|
|
44
|
+
if (from === to) return advance && sharedName ? null : STILL
|
|
36
45
|
return sharedName ? null : pickStyle(direction, random)
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
// The style a full load should play, or null for a plain load: planVisit's own rule, so the
|
|
49
|
+
// two paths cannot differ. No morph, since the old page cannot read the new page's names
|
|
50
|
+
// before its picture, and a browser with no activation entry has nothing to compare.
|
|
51
|
+
export function planFullLoad({ direction, from, activation }, random = Math.random) {
|
|
52
|
+
const url = activation && activation.entry && activation.entry.url
|
|
53
|
+
if (!url) return null
|
|
54
|
+
const to = new URL(url).pathname
|
|
55
|
+
const style = planVisit({ direction, from, to, transitions: true, sharedName: false }, random)
|
|
56
|
+
return style === STILL ? null : style
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Hands the style to the next document. Storage can refuse, and then the load is plain.
|
|
60
|
+
export function leaveStyle(win, style) {
|
|
61
|
+
try {
|
|
62
|
+
if (style) win.sessionStorage.setItem(FULL_LOAD_STYLE_KEY, style)
|
|
63
|
+
else win.sessionStorage.removeItem(FULL_LOAD_STYLE_KEY)
|
|
64
|
+
return !!style
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
// A slow image must not hold a click: past the cap the swap goes ahead.
|
|
40
71
|
export function withCap(promise, ms, timer = setTimeout) {
|
|
41
72
|
return Promise.race([promise, new Promise((done) => timer(done, ms))])
|
|
@@ -109,12 +140,14 @@ export function installPageTransitions(doc) {
|
|
|
109
140
|
const known = new Map()
|
|
110
141
|
let from = win.location.pathname
|
|
111
142
|
let target = null
|
|
143
|
+
let advance = false
|
|
144
|
+
let rendered = false
|
|
112
145
|
|
|
113
146
|
const nameTheMorph = (shared) => {
|
|
114
147
|
if (shared) root.dataset.pageTransitionMorph = ""
|
|
115
148
|
else delete root.dataset.pageTransitionMorph
|
|
116
149
|
}
|
|
117
|
-
const predict = (names) => nameTheMorph(target !== from && shareName(morphNames(doc), names))
|
|
150
|
+
const predict = (names) => nameTheMorph((target !== from || advance) && shareName(morphNames(doc), names))
|
|
118
151
|
|
|
119
152
|
root.dataset.pageTransitionDirection = readDirection(doc)
|
|
120
153
|
markHeld(doc)
|
|
@@ -122,7 +155,17 @@ export function installPageTransitions(doc) {
|
|
|
122
155
|
|
|
123
156
|
doc.addEventListener("turbo:visit", (event) => {
|
|
124
157
|
root.dataset.pageTransitionVisit = ""
|
|
158
|
+
// Turbo opens its view transition before it reads turbo-visit-control. A response it will
|
|
159
|
+
// not render gets no turbo:before-render, so this stands, and that transition draws nothing.
|
|
160
|
+
// The full load after it plays the one motion. Left alone while a motion is still running.
|
|
161
|
+
if (!root.dataset.pageTransition) root.dataset.pageTransition = STILL
|
|
125
162
|
target = new URL(event.detail.url).pathname
|
|
163
|
+
// A GET that redirects: Turbo renders the response, then replaces to the final address
|
|
164
|
+
// with that same response, all before turbo:load. The page has landed, so nothing moves.
|
|
165
|
+
const action = event.detail.action
|
|
166
|
+
if (rendered && action === "replace") from = target
|
|
167
|
+
rendered = false
|
|
168
|
+
advance = action === "advance"
|
|
126
169
|
predict(known.get(target))
|
|
127
170
|
})
|
|
128
171
|
|
|
@@ -154,6 +197,7 @@ export function installPageTransitions(doc) {
|
|
|
154
197
|
to: target,
|
|
155
198
|
transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
|
|
156
199
|
sharedName,
|
|
200
|
+
advance,
|
|
157
201
|
})
|
|
158
202
|
nameTheMorph(sharedName && style !== STILL)
|
|
159
203
|
if (style) root.dataset.pageTransition = style
|
|
@@ -161,11 +205,20 @@ export function installPageTransitions(doc) {
|
|
|
161
205
|
if (style && style !== STILL) holdRender(event, () => readyForSwap(doc, newBody))
|
|
162
206
|
}, { capture: true })
|
|
163
207
|
|
|
164
|
-
//
|
|
208
|
+
// A refused form renders with no visit and fires no turbo:load, so only a render inside a
|
|
209
|
+
// visit counts. Otherwise the flag would outlive that render and eat the next page's move.
|
|
210
|
+
doc.addEventListener("turbo:render", () => { rendered = !!target })
|
|
211
|
+
|
|
212
|
+
// Turbo fires this after the transition has finished. With no visit, it is this document's
|
|
213
|
+
// own first load, and a full load's motion can still be playing: its script cleans up.
|
|
165
214
|
doc.addEventListener("turbo:load", () => {
|
|
215
|
+
const visited = target !== null
|
|
166
216
|
from = win.location.pathname
|
|
167
217
|
target = null
|
|
218
|
+
advance = false
|
|
219
|
+
rendered = false
|
|
168
220
|
known.set(from, morphNames(doc))
|
|
221
|
+
if (!visited) return
|
|
169
222
|
delete root.dataset.pageTransition
|
|
170
223
|
delete root.dataset.pageTransitionVisit
|
|
171
224
|
nameTheMorph(false)
|
|
@@ -173,8 +226,37 @@ export function installPageTransitions(doc) {
|
|
|
173
226
|
|
|
174
227
|
// A visit that never renders: the stack must not keep its name at rest.
|
|
175
228
|
for (const name of ["turbo:fetch-request-error", "turbo:reload"]) {
|
|
176
|
-
doc.addEventListener(name, () => {
|
|
229
|
+
doc.addEventListener(name, () => {
|
|
230
|
+
delete root.dataset.pageTransitionVisit
|
|
231
|
+
if (name !== "turbo:reload") delete root.dataset.pageTransition
|
|
232
|
+
})
|
|
177
233
|
}
|
|
234
|
+
|
|
235
|
+
// The last moment on the old document. The browser takes its picture after this.
|
|
236
|
+
win.addEventListener("pageswap", (event) => {
|
|
237
|
+
const transition = event.viewTransition
|
|
238
|
+
const style = transition && planFullLoad({ direction: readDirection(doc), from, activation: event.activation })
|
|
239
|
+
const left = leaveStyle(win, style)
|
|
240
|
+
if (!transition) return
|
|
241
|
+
if (!left) {
|
|
242
|
+
transition.skipTransition()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
markHeld(doc)
|
|
246
|
+
root.dataset.pageTransitionVisit = ""
|
|
247
|
+
nameTheMorph(false)
|
|
248
|
+
// The loader settles at pagehide, which comes after the picture.
|
|
249
|
+
if (win.StudioLoader) win.StudioLoader.hide()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Back from the back/forward cache: a visit that left by a full load is over.
|
|
253
|
+
win.addEventListener("pageshow", (event) => {
|
|
254
|
+
if (!event.persisted) return
|
|
255
|
+
from = win.location.pathname
|
|
256
|
+
target = null
|
|
257
|
+
advance = false
|
|
258
|
+
rendered = false
|
|
259
|
+
})
|
|
178
260
|
return true
|
|
179
261
|
}
|
|
180
262
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// studio/transition_snapshot: Turbo's snapshot never keeps an Alpine transition's
|
|
2
|
+
// mid-slide state, and a restored page recovers from one that did.
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. Turbo clones the live page one tick after turbo:before-cache. A link
|
|
5
|
+
// in a components/_sidebar_panel closes the panel on its click, so a response that
|
|
6
|
+
// arrives inside the 200ms leave clones the panel with Alpine's phase classes on it
|
|
7
|
+
// (`transition ease-in duration-200 translate-x-full`). A hovered link has a
|
|
8
|
+
// prefetched response, so this is ordinary in production as well as on localhost.
|
|
9
|
+
// The clone has no running transition to remove them. Back then restores it, and the
|
|
10
|
+
// panel sits one width off-screen for good: Alpine's transition removes only the
|
|
11
|
+
// classes it added itself, so it never removes a class it found already there, and
|
|
12
|
+
// closing and reopening changes nothing. Only a full load clears it.
|
|
13
|
+
//
|
|
14
|
+
// THE TWO HALVES.
|
|
15
|
+
//
|
|
16
|
+
// 1. Before the clone, finish every running transition with Alpine's own finish().
|
|
17
|
+
// Alpine removes exactly what it added, and a leave goes on to its display:none.
|
|
18
|
+
// This also covers an x-transition.opacity, which writes inline style and names
|
|
19
|
+
// no class.
|
|
20
|
+
// 2. On turbo:before-render, strip the phase classes from the incoming body before
|
|
21
|
+
// Alpine sees it, for a snapshot that holds them anyway.
|
|
22
|
+
//
|
|
23
|
+
// WHEN "BEFORE THE CLONE" IS. Two handlers, because nothing orders Turbo's clone and
|
|
24
|
+
// its swap. turbo:before-cache cannot finish anything itself: the event is what closes
|
|
25
|
+
// a panel (link_sidebar's handler, a panel's @turbo:before-cache.window), and that
|
|
26
|
+
// close starts its leave in a microtask after the handlers return.
|
|
27
|
+
//
|
|
28
|
+
// turbo:before-render the plain path, measured: the swap runs in microtasks, ahead
|
|
29
|
+
// of the clone. This is the last moment the panel is connected,
|
|
30
|
+
// and Alpine skips a finished transition's cleanup on a
|
|
31
|
+
// disconnected element.
|
|
32
|
+
// a zero timeout set the path where the clone comes first (a view transition or a
|
|
33
|
+
// in before-cache held render puts the swap later). It fires ahead of the one
|
|
34
|
+
// Turbo sets right after the event for its clone: timers of one
|
|
35
|
+
// delay fire in order.
|
|
36
|
+
//
|
|
37
|
+
// Neither waits for an animation, so nothing here depends on how long a slide takes.
|
|
38
|
+
// The timeout also strips the old body, for a transition something started after
|
|
39
|
+
// before-render on a body that has gone. If Turbo ever changes this order, the second
|
|
40
|
+
// half still recovers the page.
|
|
41
|
+
//
|
|
42
|
+
// THE STRIP'S RULE. It removes only classes named in the element's own x-transition
|
|
43
|
+
// attributes, and only when the element holds a whole phase: all of a direction's
|
|
44
|
+
// `during` classes plus all of its `-start` or all of its `-end`. A panel at rest
|
|
45
|
+
// never holds that. The cost: the strip runs on every Turbo render, not only a restore,
|
|
46
|
+
// so an element that keeps a whole phase at rest loses those classes. No view in the engine, the
|
|
47
|
+
// hub or turf-monster names one (scanned 2026-11-04).
|
|
48
|
+
//
|
|
49
|
+
// Nothing here sets `display`: studio/link_sidebar's contract stands, and x-show
|
|
50
|
+
// still owns it. WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports it by
|
|
51
|
+
// its own nonced module tag as well as through the boot graph, so a failed boot
|
|
52
|
+
// leaves a page this. It imports nothing.
|
|
53
|
+
|
|
54
|
+
const RUNS_A_TRANSITION = "[x-show], [x-transition\\:enter], [x-transition\\:leave]"
|
|
55
|
+
const NAMES_CLASSES = "[x-transition\\:enter], [x-transition\\:leave]"
|
|
56
|
+
const DIRECTIONS = ["enter", "leave"]
|
|
57
|
+
|
|
58
|
+
function named(el, part) {
|
|
59
|
+
return (el.getAttribute(`x-transition:${part}`) || "").split(/\s+/).filter(Boolean)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function holdsAll(el, classes) {
|
|
63
|
+
return classes.length > 0 && classes.every((name) => el.classList.contains(name))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The classes to remove from `el`: empty unless it holds a whole phase.
|
|
67
|
+
export function midSlideClasses(el) {
|
|
68
|
+
const classes = new Set()
|
|
69
|
+
for (const direction of DIRECTIONS) {
|
|
70
|
+
const during = named(el, direction)
|
|
71
|
+
const start = named(el, `${direction}-start`)
|
|
72
|
+
const end = named(el, `${direction}-end`)
|
|
73
|
+
if (holdsAll(el, during) && (holdsAll(el, start) || holdsAll(el, end))) {
|
|
74
|
+
for (const name of [...during, ...start, ...end]) classes.add(name)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...classes]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The second half. Null-safe: turbo:before-render may carry no body.
|
|
81
|
+
export function stripMidSlideClasses(root) {
|
|
82
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
83
|
+
root.querySelectorAll(NAMES_CLASSES).forEach((el) => {
|
|
84
|
+
const classes = midSlideClasses(el)
|
|
85
|
+
if (classes.length) el.classList.remove(...classes)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The first half. finish() is what Alpine's own timer calls when a slide ends, and
|
|
90
|
+
// it runs once, so that timer does nothing afterward. An Alpine with no such handle
|
|
91
|
+
// or no finish() finishes nothing here, and must not throw: the strip runs next.
|
|
92
|
+
export function finishTransitions(root) {
|
|
93
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
94
|
+
root.querySelectorAll(RUNS_A_TRANSITION).forEach((el) => {
|
|
95
|
+
if (typeof el._x_transitioning?.finish === "function") el._x_transitioning.finish()
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installed = new WeakSet()
|
|
100
|
+
|
|
101
|
+
// Once per window: a second call adds no second listener.
|
|
102
|
+
export function installTransitionSnapshot({ win, doc } = {}) {
|
|
103
|
+
const host = win || globalThis
|
|
104
|
+
const root = doc || host.document
|
|
105
|
+
if (!root || typeof root.addEventListener !== "function") return false
|
|
106
|
+
if (installed.has(host)) return false
|
|
107
|
+
installed.add(host)
|
|
108
|
+
|
|
109
|
+
root.addEventListener("turbo:before-cache", () => {
|
|
110
|
+
const body = root.body
|
|
111
|
+
host.setTimeout(() => {
|
|
112
|
+
finishTransitions(body)
|
|
113
|
+
stripMidSlideClasses(body)
|
|
114
|
+
}, 0)
|
|
115
|
+
})
|
|
116
|
+
root.addEventListener("turbo:before-render", (event) => {
|
|
117
|
+
finishTransitions(root.body)
|
|
118
|
+
stripMidSlideClasses(event.detail && event.detail.newBody)
|
|
119
|
+
})
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof window !== "undefined") installTransitionSnapshot({ win: window, doc: document })
|
|
@@ -82,33 +82,38 @@
|
|
|
82
82
|
`nil.to_sym` raised NoMethodError inside admin chrome on every page. %>
|
|
83
83
|
<% if local_assigns[:variant].presence&.to_sym == :sidebar %>
|
|
84
84
|
<%# Sidebar variant: the wide emoji row the link sidebar's own sections use,
|
|
85
|
-
so an engine-owned row is indistinguishable from a host-declared one
|
|
85
|
+
so an engine-owned row is indistinguishable from a host-declared one: one
|
|
86
|
+
line at rest, its description on hover or focus. %>
|
|
86
87
|
<% if geo_live %>
|
|
87
88
|
<%= link_to admin_geo_path,
|
|
88
|
-
class: "group flex items-
|
|
89
|
+
class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
90
|
+
"data-sidebar-row-described": "",
|
|
89
91
|
"@click": geo_close do %>
|
|
90
|
-
<span class="text-lg leading-none flex-shrink-0">🌍</span>
|
|
91
|
-
<span class="min-w-0">
|
|
92
|
-
<span class="block font-medium truncate">Geo</span>
|
|
93
|
-
<span class="block text-xs text-muted
|
|
92
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
|
|
93
|
+
<span class="relative min-w-0 flex-1">
|
|
94
|
+
<span class="block font-medium truncate" data-sidebar-row-label>Geo</span>
|
|
95
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc>Country and subdivision blocking</span>
|
|
94
96
|
</span>
|
|
95
97
|
<% end %>
|
|
96
98
|
<% else %>
|
|
97
|
-
<%#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
<%# The row that cannot link holds the string to copy as its description. A title
|
|
100
|
+
alone failed on touch (THE HINT, above), so tabindex lets a tap and a key
|
|
101
|
+
focus the row, and engine.css reveals it on :focus. The long form stays in
|
|
102
|
+
the title. No `select-none` here, and that is the fix rather than an
|
|
103
|
+
omission: the row's whole job in this state is to hand over the string, and
|
|
104
|
+
select-none made it unselectable. role/aria-disabled give a screen reader
|
|
105
|
+
the "unavailable destination" this paints for a sighted eye. %>
|
|
106
|
+
<span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset"
|
|
102
107
|
role="link"
|
|
103
108
|
aria-disabled="true"
|
|
109
|
+
tabindex="0"
|
|
104
110
|
data-test="geo-signpost-disabled"
|
|
111
|
+
data-sidebar-row-described
|
|
105
112
|
title="<%= geo_title %>">
|
|
106
|
-
<span class="text-lg leading-none flex-shrink-0">🌍</span>
|
|
107
|
-
<span class="min-w-0">
|
|
108
|
-
<span class="block font-medium truncate">Geo</span>
|
|
109
|
-
<span class="block text-xs text-muted
|
|
110
|
-
<code class="font-mono break-all"><%= geo_code %></code>
|
|
111
|
-
</span>
|
|
113
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
|
|
114
|
+
<span class="relative min-w-0 flex-1">
|
|
115
|
+
<span class="block font-medium truncate" data-sidebar-row-label>Geo</span>
|
|
116
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc><code class="font-mono"><%= geo_code %></code></span>
|
|
112
117
|
</span>
|
|
113
118
|
</span>
|
|
114
119
|
<% end %>
|
|
@@ -6,9 +6,37 @@
|
|
|
6
6
|
this automatically when sections are declared. %>
|
|
7
7
|
<% sections = studio_sidebar_sections %>
|
|
8
8
|
<% sidebar_admin = respond_to?(:admin?) && admin? %>
|
|
9
|
-
<%#
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
<%# THE PANEL'S TITLE. Signed in, admin included: the name the navbar shows, from the
|
|
10
|
+
same call. Signed out: a greeting by the browser's local time, which the server
|
|
11
|
+
cannot know, so studio/link_sidebar writes the word over this default before the
|
|
12
|
+
panel opens (greetingFor). The panel is hidden until then, so nothing flashes.
|
|
13
|
+
|
|
14
|
+
It goes in the panel's lead slot (title_html), not its title. A title names the
|
|
15
|
+
panel through aria-labelledby, and "Good morning" or "Alex" is no name for a
|
|
16
|
+
menu: the panel keeps "Admin menu" or "Links menu" as its aria-label. %>
|
|
17
|
+
<% sidebar_signed_in = respond_to?(:logged_in?) && logged_in? %>
|
|
18
|
+
<% sidebar_user = sidebar_signed_in && respond_to?(:current_user) ? current_user : nil %>
|
|
19
|
+
<% sidebar_title_class = "type-subhead text-heading truncate pr-3" %>
|
|
20
|
+
<% sidebar_title_html =
|
|
21
|
+
if sidebar_user
|
|
22
|
+
sidebar_name = Studio.navbar_user_name_for(sidebar_user, self).to_s.squish
|
|
23
|
+
tag.p(sidebar_name, class: sidebar_title_class, title: sidebar_name, data: { link_sidebar_title: "" })
|
|
24
|
+
elsif !sidebar_signed_in
|
|
25
|
+
tag.p("Good morning", class: sidebar_title_class, data: { link_sidebar_title: "", link_sidebar_greeting: "" })
|
|
26
|
+
end %>
|
|
27
|
+
<%# THE GROUPS. No section shows a label word and none shows the admin chip: a divider
|
|
28
|
+
separates the groups. The words stay as each group's accessible name, so a screen
|
|
29
|
+
reader still announces them. An empty section draws no group, or its two dividers
|
|
30
|
+
would touch.
|
|
31
|
+
|
|
32
|
+
A ROW IS ONE LINE AT REST, with or without a desc:, so the spacing never changes. A
|
|
33
|
+
desc: fades in on hover or keyboard focus, and nothing moves to make room: it sits
|
|
34
|
+
absolutely in the row's padding as the label lifts by a transform. engine.css, THE
|
|
35
|
+
SIDEBAR ROW'S DESCRIPTION, holds the rules. A row with no desc: gains no attribute. %>
|
|
36
|
+
<% sidebar_group = lambda do |title:, admin:, first:, &block|
|
|
37
|
+
name = [title.presence, ("admin" if admin && !title.to_s.match?(/\badmin\b/i))].compact.join(", ").presence
|
|
38
|
+
tag.div(role: "group", aria: { label: name }, class: ["py-1", ("border-t border-subtle" unless first)], &block)
|
|
39
|
+
end %>
|
|
12
40
|
<% close_action = "$store.sidebars.linkTreeOpen = false" %>
|
|
13
41
|
|
|
14
42
|
<style>
|
|
@@ -25,21 +53,16 @@
|
|
|
25
53
|
<template data-studio-controller="link-sidebar"></template>
|
|
26
54
|
|
|
27
55
|
<% body_html = capture do %>
|
|
28
|
-
<nav class="flex-1 min-h-0 overflow-y-auto py-
|
|
29
|
-
<% sections.
|
|
30
|
-
|
|
31
|
-
<span><%= section[:title] %></span>
|
|
32
|
-
<% if section[:admin] %>
|
|
33
|
-
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
|
|
34
|
-
<% end %>
|
|
35
|
-
</div>
|
|
36
|
-
|
|
56
|
+
<nav class="flex-1 min-h-0 overflow-y-auto py-1" aria-label="Studio links">
|
|
57
|
+
<% sections.select { |section| section[:links].any? }.each_with_index do |section, index| %>
|
|
58
|
+
<%= sidebar_group.call(title: section[:title], admin: section[:admin], first: index.zero?) do %>
|
|
37
59
|
<% section[:links].each do |link| %>
|
|
38
60
|
<% target = link[:target].presence %>
|
|
39
61
|
<%= link_to link[:href],
|
|
40
|
-
class: "group flex items-
|
|
62
|
+
class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
41
63
|
target: target,
|
|
42
64
|
rel: target == "_blank" ? "noopener" : nil,
|
|
65
|
+
"data-sidebar-row-described": (link[:desc].present? ? "" : nil),
|
|
43
66
|
"@click": close_action do %>
|
|
44
67
|
<% if link[:hover_emoji].present? %>
|
|
45
68
|
<%= render "components/emoji_swap",
|
|
@@ -47,16 +70,21 @@
|
|
|
47
70
|
hover: link[:hover_emoji],
|
|
48
71
|
class_name: "text-lg leading-none" %>
|
|
49
72
|
<% else %>
|
|
50
|
-
<span class="text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
|
|
73
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% if link[:desc].present? %>
|
|
76
|
+
<span class="relative min-w-0 flex-1">
|
|
77
|
+
<span class="block font-medium truncate" data-sidebar-row-label><%= link[:label] %></span>
|
|
78
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc><%= link[:desc] %></span>
|
|
79
|
+
</span>
|
|
80
|
+
<% else %>
|
|
81
|
+
<span class="min-w-0">
|
|
82
|
+
<span class="block font-medium truncate"><%= link[:label] %></span>
|
|
83
|
+
</span>
|
|
51
84
|
<% end %>
|
|
52
|
-
<span class="min-w-0">
|
|
53
|
-
<span class="block font-medium truncate"><%= link[:label] %></span>
|
|
54
|
-
<% if link[:desc].present? %>
|
|
55
|
-
<span class="block text-xs text-muted leading-snug mt-0.5"><%= link[:desc] %></span>
|
|
56
|
-
<% end %>
|
|
57
|
-
</span>
|
|
58
85
|
<% end %>
|
|
59
86
|
<% end %>
|
|
87
|
+
<% end %>
|
|
60
88
|
<% end %>
|
|
61
89
|
|
|
62
90
|
<%# THE GEO SIGNPOST — how a sidebar-chrome app gets geo signage at all.
|
|
@@ -93,11 +121,9 @@
|
|
|
93
121
|
bare call would raise for them. The two must stay the same question —
|
|
94
122
|
if the helper's rule changes, this changes with it. %>
|
|
95
123
|
<% if sidebar_admin && sections.any? { |section| section[:admin] } %>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
</div>
|
|
100
|
-
<%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
|
|
124
|
+
<%= sidebar_group.call(title: "Geo", admin: true, first: false) do %>
|
|
125
|
+
<%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
|
|
126
|
+
<% end %>
|
|
101
127
|
<% end %>
|
|
102
128
|
</nav>
|
|
103
129
|
<% end %>
|
|
@@ -116,7 +142,7 @@
|
|
|
116
142
|
|
|
117
143
|
<%= render "components/sidebar_panel",
|
|
118
144
|
id: "studio-link-sidebar",
|
|
119
|
-
|
|
145
|
+
title_html: sidebar_title_html,
|
|
120
146
|
open: "$store.sidebars.linkTreeOpen",
|
|
121
147
|
body_html: body_html,
|
|
122
148
|
footer_html: footer_html,
|
|
@@ -129,7 +155,7 @@
|
|
|
129
155
|
|
|
130
156
|
<%= render "components/sidebar_panel",
|
|
131
157
|
id: "studio-link-sidebar-mobile",
|
|
132
|
-
|
|
158
|
+
title_html: sidebar_title_html,
|
|
133
159
|
open: "$store.sidebars.linkTreeOpen",
|
|
134
160
|
body_html: body_html,
|
|
135
161
|
footer_html: footer_html,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
<%# The session-drift stamp + browser store. Renders nothing unless the controller
|
|
31
31
|
includes Studio::ErrorHandling. See studio/_session_stamp.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
AN ENGINE SCRIPT THAT STAYS CLASSIC, AND SYNCHRONOUS. Host code
|
|
34
34
|
registers identity sources with window.StudioSession as its own modules
|
|
35
35
|
EVALUATE (turf-monster registers its identity source from a module at
|
|
36
36
|
import; CHANGELOG, "Held"), and the host's entry point is the module tag
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
silently be null. It becomes a module when those consumers wait for it. %>
|
|
40
40
|
<%= render "studio/session_stamp" %>
|
|
41
41
|
<%= render "layouts/studio/smooth_load" %>
|
|
42
|
+
<%# A FULL PAGE LOAD PLAYS THE SAME TRANSITION, only with the setting on: an app without it
|
|
43
|
+
gets neither tag. The sheet carries the at-rule that takes no selector, so it cannot sit in
|
|
44
|
+
the engine's bundle. The script is classic and synchronous, the second one here, because
|
|
45
|
+
pagereveal fires before the first render, ahead of every module. docs/PAGE_TRANSITIONS.md. %>
|
|
46
|
+
<% if @studio_page_transition_direction || Studio.page_transition_direction %>
|
|
47
|
+
<%= stylesheet_link_tag "studio/page_transition_full_load" %>
|
|
48
|
+
<%= javascript_include_tag "studio/page_transition_reveal", nonce: studio_script_nonce %>
|
|
49
|
+
<% end %>
|
|
42
50
|
<%# The favicon's URL carries its content version (Studio::PublicFileVersion),
|
|
43
51
|
so a redrawn favicon is a new URL no browser has cached. %>
|
|
44
52
|
<link rel="icon" type="image/png" href="<%= Studio::PublicFileVersion.path("/favicon.png") %>">
|
|
@@ -197,7 +205,8 @@
|
|
|
197
205
|
take every bare specifier on the page down with it. The ones below that load by
|
|
198
206
|
URL (type="module" with a src) are external files, not inline scripts: they
|
|
199
207
|
carry no body and need no nonce under a policy that allows 'self'. The session
|
|
200
|
-
store (studio/_session_stamp, above)
|
|
208
|
+
store (studio/_session_stamp, above) and the full-load script above are the two
|
|
209
|
+
classic scripts, and each says why.
|
|
201
210
|
A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
|
|
202
211
|
also sends a `Link: rel=modulepreload` response header, which the browser
|
|
203
212
|
acts on before it parses this map, so a bare import in that module can
|
|
@@ -246,6 +255,10 @@
|
|
|
246
255
|
the document click and Escape handlers. It imports nothing, and the
|
|
247
256
|
link-sidebar controller imports it. %>
|
|
248
257
|
<%= javascript_import_module_tag "studio/link_sidebar" %>
|
|
258
|
+
<%# THE SNAPSHOT CLEANUP, BY ITS OWN DOOR, for the same reason. studio/transition_snapshot
|
|
259
|
+
keeps Turbo from filing a panel mid-slide, and recovers a page restored from a
|
|
260
|
+
snapshot that holds one. It imports nothing. %>
|
|
261
|
+
<%= javascript_import_module_tag "studio/transition_snapshot" %>
|
|
249
262
|
<%# THE X-DATA FACTORIES, BY THEIR OWN DOOR, for the same reason.
|
|
250
263
|
studio/alpine_scopes publishes the factories a page binds by name
|
|
251
264
|
(cropPhotoModal, imageUploadHost, submitFormWithProgress), each from the
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
<p class="text-muted text-xs font-medium mb-1">Indexes</p>
|
|
66
66
|
<% table[:indexes].each do |idx| %>
|
|
67
67
|
<div class="flex items-center gap-2 text-xs py-0.5">
|
|
68
|
-
<span class="text-secondary font-mono truncate flex-1"><%= idx.columns.join(", ") %></span>
|
|
68
|
+
<span class="text-secondary font-mono truncate flex-1"><%= Array(idx.columns).join(", ") %></span>
|
|
69
69
|
<% if idx.unique %>
|
|
70
70
|
<span class="text-primary font-medium shrink-0">UNIQUE</span>
|
|
71
71
|
<% end %>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -89,8 +89,9 @@ module Studio
|
|
|
89
89
|
# loaded as an ES module (Alpine by the head's type="module" tag,
|
|
90
90
|
# canvas-confetti and SortableJS through their importmap pins, Leaflet by
|
|
91
91
|
# studio/footer_map's import()), and studio/session.js, the one classic
|
|
92
|
-
# script (layouts/studio/_head says why)
|
|
93
|
-
# under app/javascript,
|
|
92
|
+
# script (layouts/studio/_head says why), with studio/page_transition_reveal.js,
|
|
93
|
+
# the other classic one. The engine's own modules are under app/javascript,
|
|
94
|
+
# enumerated below.
|
|
94
95
|
app.config.assets.precompile += %w[
|
|
95
96
|
studio/sticky_table_header.css
|
|
96
97
|
studio/alpine.js
|
|
@@ -99,6 +100,8 @@ module Studio
|
|
|
99
100
|
studio/leaflet.js
|
|
100
101
|
studio/leaflet.css
|
|
101
102
|
studio/session.js
|
|
103
|
+
studio/page_transition_full_load.css
|
|
104
|
+
studio/page_transition_reveal.js
|
|
102
105
|
studio/montserrat-latin.woff2
|
|
103
106
|
studio/montserrat-latin-ext.woff2
|
|
104
107
|
]
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
# trigger and panel stay invisible to everyone else even when the host
|
|
17
17
|
# declares nothing but admin links.
|
|
18
18
|
#
|
|
19
|
+
# A section's title is its group's accessible name; the panel shows no label word. A
|
|
20
|
+
# link's desc is optional and shows only on hover or keyboard focus.
|
|
21
|
+
#
|
|
19
22
|
# THE ENGINE PREPENDS ONE SECTION OF ITS OWN — "You", linking to /profile. The
|
|
20
23
|
# engine ships that page, so it ships the way in rather than asking five apps to
|
|
21
24
|
# declare the same entry and watch them drift. See `standard` below for the two
|
|
@@ -57,8 +60,7 @@ module Studio
|
|
|
57
60
|
return [] unless profile_link?(view)
|
|
58
61
|
|
|
59
62
|
[{ title: "You", links: [
|
|
60
|
-
{ label: "Profile", href: view.profile_path, emoji: "👤"
|
|
61
|
-
desc: "Your name, email and photo" }
|
|
63
|
+
{ label: "Profile", href: view.profile_path, emoji: "👤" }
|
|
62
64
|
] }]
|
|
63
65
|
end
|
|
64
66
|
|
data/lib/studio/ui_primitives.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Studio
|
|
4
4
|
module UiPrimitives
|
|
5
5
|
EMOJI_SWAP_CSS = <<~CSS
|
|
6
|
+
/* .studio-emoji-box is a plain emoji in the swap's box, so a row with a plain
|
|
7
|
+
emoji and a row with a swap are one height and their labels line up. */
|
|
8
|
+
.studio-emoji-box,
|
|
6
9
|
.studio-emoji-swap,
|
|
7
10
|
.nav-emoji-swap {
|
|
8
11
|
position: relative;
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.126.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -424,9 +424,11 @@ files:
|
|
|
424
424
|
- app/assets/javascripts/studio/alpine.js
|
|
425
425
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
426
426
|
- app/assets/javascripts/studio/leaflet.js
|
|
427
|
+
- app/assets/javascripts/studio/page_transition_reveal.js
|
|
427
428
|
- app/assets/javascripts/studio/session.js
|
|
428
429
|
- app/assets/javascripts/studio/sortable.js
|
|
429
430
|
- app/assets/stylesheets/studio/leaflet.css
|
|
431
|
+
- app/assets/stylesheets/studio/page_transition_full_load.css
|
|
430
432
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
431
433
|
- app/assets/tailwind/studio_engine/engine-motion.css
|
|
432
434
|
- app/assets/tailwind/studio_engine/engine.css
|
|
@@ -561,6 +563,7 @@ files:
|
|
|
561
563
|
- app/javascript/studio/survey.js
|
|
562
564
|
- app/javascript/studio/theme_editor.js
|
|
563
565
|
- app/javascript/studio/toast.js
|
|
566
|
+
- app/javascript/studio/transition_snapshot.js
|
|
564
567
|
- app/javascript/studio/vendor/stimulus.js
|
|
565
568
|
- app/javascript/studio/voice_profile.js
|
|
566
569
|
- app/javascript/studio/voice_recorder.js
|