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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7f68b7fc8c9d951dfa0f8069db58d4d819a9e5c785a35c5319c00d1a7450fab
4
- data.tar.gz: 3674ddc7b3c29a7f58e06ee8c928bf63223a52388a191f819a90919986355e79
3
+ metadata.gz: b282dc9ef2736f70ae7d985d1617ad22f61f25b6bba0a2b9391804e68c01069d
4
+ data.tar.gz: 3a0234c85465610eb31351583c0b0d743cf1a42f7db193d762869b040255143a
5
5
  SHA512:
6
- metadata.gz: 1b8755f0f24f02d33aaf1f91bac610325e988a5cf9633ec74b31227b14f9f8582293ff0bd225b38efdc62e468b433dabb66fa14590e785355073f70d339add32
7
- data.tar.gz: '083f8e499dedaa8efb934df433a3cd99c196828d7625e77e0cd4cd3f5003d5ab57a26990eee8c031a803d4b80fb7777ce05133cb4fda5bd616a38b448a288dc6'
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 "You > Profile" section
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 title says so) and
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, for a sidebar that arrived any other way.
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 register = () => registerLinkSidebar(host.Alpine, root)
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) => { if (event.persisted) closeLinkSidebar(host.Alpine) })
149
- root.addEventListener("click", (event) => handleLinkSidebarClick(event, host.Alpine), true)
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 runs, and draws nothing.
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
- export function planVisit({ direction, from, to, transitions, sharedName }, random = Math.random) {
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
- // Turbo fires this after the transition has finished.
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, () => { delete root.dataset.pageTransitionVisit })
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-start 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",
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 leading-snug mt-0.5">Country and subdivision blocking</span>
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
- <%# No `select-none` here, and that is the fix rather than an omission: the
98
- row's whole job in this state is to hand over the string below, and
99
- select-none made it unselectable. role/aria-disabled give a screen
100
- reader the "unavailable destination" this paints for a sighted eye. %>
101
- <span class="flex items-start gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed"
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 leading-snug mt-0.5"><%= geo_lead %>
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
- <%# A signed-out visitor's panel has no title: its links say what it is. It keeps its
10
- accessible name (aria_label) and its close button. %>
11
- <% sidebar_title = if sidebar_admin then "Admin Menu" elsif respond_to?(:logged_in?) && logged_in? then "Links" end %>
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-2" aria-label="Studio links">
29
- <% sections.each do |section| %>
30
- <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
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-start 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",
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
- <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
97
- <span>Geo</span>
98
- <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>
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
- title: sidebar_title,
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
- title: sidebar_title,
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
- THE ONE ENGINE SCRIPT THAT STAYS CLASSIC, AND SYNCHRONOUS. Host code
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) is the one classic script, and says why.
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). The engine's own modules are
93
- # under app/javascript, enumerated below.
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
 
@@ -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;
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.124.0"
2
+ VERSION = "0.126.0"
3
3
  end
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.124.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