studio-engine 0.125.0 → 0.127.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: 13bb9cfe1f6491a4dd2a199c16f734f466edad8fb38beae903033305e8fd91da
4
- data.tar.gz: 20d1fec190a9bfe2391e4e4121f2fa11dc35729abd54a8df47e0a19346611715
3
+ metadata.gz: 22ef2ab3094c82f6f447ee86455dac3e2c70b759ec3d58f2f1a887a347c4b1ad
4
+ data.tar.gz: 92c5bc2d6c5f53914e33d7c50623a076791ef50faff385b4e63218e3773eb6a6
5
5
  SHA512:
6
- metadata.gz: 3687dd8d85fffecbb4c1ab84177bcdb9dee0bfd0aa87dac7dbad61a81a4921d920f9c2f1cda8cfc3bff378c4a12d3528ab015e14085b447204c745e69c84155e
7
- data.tar.gz: 2d135f12456ce99328d5422df362a83271cd9075b9f618c80b0cf3053edccb3a383164a0c23fdc6cced5e5e4514467e90b637d5beb976f052b2b742cfd7a8b18
6
+ metadata.gz: 1420fb65a41535cc4c4ae4a1466a83fe46d02eca34c17ed26b1ef1fdfc1cf6ca934776f70a843380410593c7932bbc23c53fe2fe6f0b992e3bf5a19206b5e7f3
7
+ data.tar.gz: c88656c2c2363917fff41839b65c00ccc0f499d3c30c1a216ae783081f6712b44c200ef93500fceae04d99f6719db344ed0b213b742e104d632d9a0d063fc4d3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.127.0 — 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **Link sidebar: a soft blurred edge.** `components/_sidebar_panel` takes `edge:`, default
12
+ false, and `components/_link_sidebar` passes `edge: true`. From `md` up, the panel draws a band
13
+ along its inner edge: 40px wide, a 6px blur, fading out into the page. It is a pseudo-element
14
+ of the panel, so it slides with it, leaves with it and takes no clicks. It adds no scrim and no
15
+ darkening. A browser with no `backdrop-filter` shows the panel plain. A host panel keeps its
16
+ plain edge unless it opts in. The navbar baseline is regenerated for the link sidebar's
17
+ `data-sidebar-edge` attribute.
18
+
19
+ ### Fixed
20
+
21
+ - **`studio/_page_header` takes a long title, and any parent.** A long title used to squeeze
22
+ the tabs out and overflow the page. It now wraps and stops at three lines, and the tabs move
23
+ to their own line when it leaves them less than 6rem, so a page has no sideways overflow at
24
+ any width. A short title's row is unchanged. In a flex or grid parent with a sibling above
25
+ it, the row no longer shows its bar at rest: the controller measures the row's place from
26
+ the row there, because the sentinel sits at the parent's start. `docs/PAGE_HEADER.md` loses
27
+ both limits.
28
+
29
+ ## 0.126.0 — 2026-11-04
30
+
31
+ ### Changed
32
+
33
+ - **Link sidebar: no section labels, a calmer title, one line a row at rest.** No section shows
34
+ a label word or the ADMIN chip at any level; a subtle divider separates the groups, and each
35
+ group keeps its words as its accessible name. Every row is one line and one height at rest. A
36
+ link's `desc:` fades in on hover or keyboard focus with no layout change: 200ms after 150ms in,
37
+ 140ms out, the label lifting 8px by a transform. A touch screen shows none, and reduced motion
38
+ slides nothing. The engine's Profile row no longer passes a `desc:`. The Geo rows behave the
39
+ same; the row that cannot link holds only the string to copy, reveals it on a tap as well, and
40
+ keeps the long form in its title. A plain emoji now sits in the swap's box
41
+ (`.studio-emoji-box`), so a mixed list is one height. The title is `type-subhead`: signed in, admin included, the name the navbar shows
42
+ (`Studio.navbar_user_name`); signed out, Good morning, Good afternoon or Good evening by the
43
+ browser's local time, written by `studio/link_sidebar`. The panel's accessible name stays
44
+ "Admin menu" or "Links menu". The navbar baseline is regenerated for it.
45
+
46
+ ### Fixed
47
+
48
+ - **A panel link followed by Back no longer leaves the panel off-screen for good.** Turbo
49
+ clones the page one tick after `turbo:before-cache`, so a response inside a
50
+ `components/_sidebar_panel`'s 200ms leave filed the panel with Alpine's phase classes on it.
51
+ A hovered link has a prefetched response, so this reached production too: after Back the
52
+ gear set the flag, and the panel sat one width past the viewport, until a full load. The new
53
+ `studio/transition_snapshot` finishes every running x-transition before the clone, and
54
+ strips a whole phase's named classes from a restored page. Hosts pick it up with the gem: it
55
+ covers their own panels on the shared partial and their other `x-show` transitions. A host
56
+ that overrides `layouts/studio/_head` gets it from `studio/application`.
57
+
58
+ - `/admin/schema`: a database with an expression index renders. Rails returns such an index's
59
+ columns as a String, and the page called `join` on it, which answered 500.
60
+
7
61
  ## 0.125.0 — 2026-11-04
8
62
 
9
63
  ### Added
data/README.md CHANGED
@@ -1092,9 +1092,13 @@ config.navbar_account_sidebar = { flag: "gearOpen", controls: %w[gear-sidebar ge
1092
1092
 
1093
1093
  ### Sidebar panel lead and close hook
1094
1094
 
1095
- `components/_sidebar_panel` takes two opt-in locals; unset, the panel renders
1095
+ `components/_sidebar_panel` takes three opt-in locals; unset, the panel renders
1096
1096
  as before:
1097
1097
 
1098
+ - `edge: true` draws a soft blurred band along the panel's inner edge from `md`
1099
+ up, through a `data-sidebar-edge` hook and engine.css. It takes no clicks. The
1100
+ link sidebar passes it. It softens the page beside the panel, so leave it off
1101
+ a panel kept open beside live content.
1098
1102
  - `title_html:` HTML in the title's place, for a panel whose header leads with
1099
1103
  a live status line. It is content, not a name: it never feeds
1100
1104
  `aria-labelledby` or `aria-label`, so a caller that passes it instead of
@@ -229,12 +229,19 @@ body.modal-open .studio-app-banner {
229
229
  the pinned stack has nothing to republish. The layout is owned here: the
230
230
  engine's views depend on no responsive utility a host may not have emitted.
231
231
  The fade is owned too, because .fade-edge-x is in the opt-in motion layer, and
232
- it marks only an end that hides a tab (studio/controllers/page_header). */
232
+ it marks only an end that hides a tab (studio/controllers/page_header).
233
+
234
+ A LONG TITLE: the row wraps. The tabs' flex-basis is their floor, so a title that
235
+ leaves them less than that takes the first line and the tabs take the next, at
236
+ full width. A title too long for its line wraps, then stops at three lines. A short
237
+ title never reaches the floor, and its row is one line as before. */
233
238
 
234
239
  @utility page-header {
235
240
  display: flex;
241
+ flex-wrap: wrap;
236
242
  align-items: center;
237
243
  gap: 0.75rem;
244
+ min-width: 0;
238
245
  padding-block: 0.5rem;
239
246
  border-block: 1px solid transparent;
240
247
  background-color: transparent;
@@ -256,7 +263,7 @@ body.modal-open .studio-app-banner {
256
263
  display: flex;
257
264
  align-items: baseline;
258
265
  gap: 0.5rem;
259
- flex-shrink: 0;
266
+ flex: 0 1 auto;
260
267
  min-width: 0;
261
268
  }
262
269
 
@@ -265,6 +272,14 @@ body.modal-open .studio-app-banner {
265
272
  font-size: calc(var(--type-base, 16px) * 1.5);
266
273
  font-weight: var(--type-display-weight, 800);
267
274
  line-height: 1.25;
275
+ min-width: 0;
276
+ overflow-wrap: anywhere;
277
+ display: -webkit-box;
278
+ -webkit-box-orient: vertical;
279
+ -webkit-line-clamp: 3;
280
+ /* Up and down only. A hidden x would shave a glyph's overhang off a short title. */
281
+ overflow-x: visible;
282
+ overflow-y: clip;
268
283
  }
269
284
 
270
285
  @utility page-header-meta {
@@ -278,7 +293,7 @@ body.modal-open .studio-app-banner {
278
293
  @utility page-header-tabs {
279
294
  --page-header-fade-start: 0px;
280
295
  --page-header-fade-end: 0px;
281
- flex: 1 1 0%;
296
+ flex: 1 1 6rem;
282
297
  min-width: 0;
283
298
  overflow-x: auto;
284
299
  scrollbar-width: none;
@@ -1084,6 +1099,97 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
1084
1099
  font-size: calc(var(--nav-avatar) / 3 / 0.7);
1085
1100
  }
1086
1101
  }
1102
+
1103
+ /* THE SIDEBAR'S EDGE (components/_sidebar_panel, edge: true). A soft band along the panel's
1104
+ inner edge, where the page behind is blurred and fades out to sharp, so the panel
1105
+ stands off a busy page with no scrim and no darkening.
1106
+
1107
+ It is a pseudo-element of the panel: it moves with the slide, it leaves with the
1108
+ panel, and it takes no clicks, so an outside click still reaches the page. A
1109
+ transform on the panel does not start a new backdrop root, so it blurs the page.
1110
+ 40px wide, a 6px blur, held for the 10px nearest the panel, then fading to sharp.
1111
+ right: 100% measures from the padding box, so the band runs over the panel's 1px
1112
+ border. Stopping short of it left the border as a lit line beside the blur.
1113
+
1114
+ FROM md UP ONLY. Below that the link sidebar fills the width, so nothing sits
1115
+ beside it, and a phone does not pay for a backdrop filter through the slide. A
1116
+ browser with no backdrop-filter gets an empty box, so the panel is plain. */
1117
+ @media (min-width: 768px) {
1118
+ [data-sidebar-edge]::before {
1119
+ content: "";
1120
+ position: absolute;
1121
+ inset-block: 0;
1122
+ right: 100%;
1123
+ width: 2.5rem;
1124
+ pointer-events: none;
1125
+ -webkit-backdrop-filter: blur(6px);
1126
+ backdrop-filter: blur(6px);
1127
+ -webkit-mask-image: linear-gradient(to left, #000 25%, transparent);
1128
+ mask-image: linear-gradient(to left, #000 25%, transparent);
1129
+ }
1130
+ }
1131
+
1132
+ /* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest
1133
+ a row with a description is one line, the height of any other row. On hover or
1134
+ keyboard focus the description fades in under the label, and no layout changes:
1135
+ it is absolute, inside the row's padding, and the label lifts by a transform.
1136
+
1137
+ THE LIFT is half the description's 16px line, which centres the two lines on the
1138
+ row. It comes in over 200ms after 150ms, so a pointer crossing the list leaves no
1139
+ partial lift on the rows it crosses, and it goes in 140ms.
1140
+
1141
+ THE ROW THAT CANNOT LINK (aria-disabled) reveals on :focus as well, because a tap
1142
+ focuses it and matches no :focus-visible. Its string stays selectable.
1143
+
1144
+ POINTING NEEDS A POINTER: :hover sits inside @media (hover: hover), and the focus
1145
+ rules stay outside it, as in THE ACCOUNT GROUP. Under reduced motion nothing
1146
+ slides: the description fades in where it will sit and the label moves at once. */
1147
+ [data-sidebar-row-desc] {
1148
+ position: absolute;
1149
+ inset-inline: 0;
1150
+ top: 100%;
1151
+ opacity: 0;
1152
+ transform: translateY(-0.25rem);
1153
+ pointer-events: none;
1154
+ transition: opacity 140ms ease-in, transform 140ms ease-in;
1155
+ }
1156
+ [data-sidebar-row-described][aria-disabled="true"] [data-sidebar-row-desc] { pointer-events: auto; }
1157
+ [data-sidebar-row-label] { transition: transform 140ms ease-in; }
1158
+ [data-sidebar-row-described]:focus-visible [data-sidebar-row-label],
1159
+ [data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-label] {
1160
+ transform: translateY(-0.5rem);
1161
+ transition: transform 200ms ease-out 150ms;
1162
+ }
1163
+ [data-sidebar-row-described]:focus-visible [data-sidebar-row-desc],
1164
+ [data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-desc] {
1165
+ opacity: 1;
1166
+ transform: translateY(-0.5rem);
1167
+ transition: opacity 200ms ease-out 150ms, transform 200ms ease-out 150ms;
1168
+ }
1169
+ @media (hover: hover) {
1170
+ [data-sidebar-row-described]:hover [data-sidebar-row-label] {
1171
+ transform: translateY(-0.5rem);
1172
+ transition: transform 200ms ease-out 150ms;
1173
+ }
1174
+ [data-sidebar-row-described]:hover [data-sidebar-row-desc] {
1175
+ opacity: 1;
1176
+ transform: translateY(-0.5rem);
1177
+ transition: opacity 200ms ease-out 150ms, transform 200ms ease-out 150ms;
1178
+ }
1179
+ }
1180
+ @media (prefers-reduced-motion: reduce) {
1181
+ [data-sidebar-row-label],
1182
+ [data-sidebar-row-described]:hover [data-sidebar-row-label],
1183
+ [data-sidebar-row-described]:focus-visible [data-sidebar-row-label],
1184
+ [data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-label] { transition: none; }
1185
+ [data-sidebar-row-desc],
1186
+ [data-sidebar-row-described]:hover [data-sidebar-row-desc],
1187
+ [data-sidebar-row-described]:focus-visible [data-sidebar-row-desc],
1188
+ [data-sidebar-row-described][aria-disabled="true"]:focus [data-sidebar-row-desc] {
1189
+ transform: translateY(-0.5rem);
1190
+ transition: opacity 200ms ease-out 150ms;
1191
+ }
1192
+ }
1087
1193
  }
1088
1194
 
1089
1195
  /* 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
  }
@@ -10,7 +10,7 @@
10
10
  // carries data-studio-controller-failed and engine.css gives the row its bar at
11
11
  // all times, so a stuck row never lays a transparent title over the page.
12
12
  import { Controller } from "@hotwired/stimulus"
13
- import { hiddenEnds, isStuck, revealBy } from "studio/page_header"
13
+ import { hiddenEnds, isStuck, revealBy, sentinelMarksThePlace } from "studio/page_header"
14
14
 
15
15
  export default class extends Controller {
16
16
  connect() {
@@ -42,7 +42,7 @@ export default class extends Controller {
42
42
  }
43
43
 
44
44
  sync() {
45
- const stuck = isStuck(this.row.getBoundingClientRect().top, this.element.getBoundingClientRect().top)
45
+ const stuck = isStuck(this.row.getBoundingClientRect().top, this.restTop())
46
46
  const ends = this.tabs ? hiddenEnds(this.tabs) : null
47
47
 
48
48
  this.row.toggleAttribute("data-stuck", stuck)
@@ -51,6 +51,20 @@ export default class extends Controller {
51
51
  this.tabs.toggleAttribute("data-fade-end", ends.end)
52
52
  }
53
53
 
54
+ // The top of the row's place in the flow. In a flex or grid parent only the row
55
+ // can say, so it is unstuck for one read. No frame paints in between, and the box
56
+ // keeps its size, so nothing moves and the stack republishes nothing.
57
+ restTop() {
58
+ if (sentinelMarksThePlace(getComputedStyle(this.element.parentElement).display)) {
59
+ return this.element.getBoundingClientRect().top
60
+ }
61
+
62
+ this.row.style.position = "static"
63
+ const top = this.row.getBoundingClientRect().top
64
+ this.row.style.position = ""
65
+ return top
66
+ }
67
+
54
68
  // Moves the tabs only: scrollIntoView would also move the page.
55
69
  revealOpenTab() {
56
70
  const open = this.tabs?.querySelector("[aria-current]")
@@ -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
 
@@ -8,6 +8,13 @@ export function isStuck(rowTop, restTop) {
8
8
  return rowTop - restTop > 0.5
9
9
  }
10
10
 
11
+ // Whether the sentinel marks the row's place. It is absolutely positioned, so in a
12
+ // block parent it sits where the row starts. In a flex or grid parent it sits at the
13
+ // parent's start instead, whatever is above the row.
14
+ export function sentinelMarksThePlace(parentDisplay) {
15
+ return !/flex|grid/.test(parentDisplay)
16
+ }
17
+
11
18
  // Which ends of the tabs hide a tab, so a fade marks only an end with more to see.
12
19
  export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
13
20
  return {
@@ -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 #{link[:desc].present? ? "items-start" : "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",
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,
@@ -124,12 +150,13 @@
124
150
  outside_action: close_action,
125
151
  escape_action: close_action,
126
152
  display_class: "hidden md:flex",
153
+ edge: true,
127
154
  role: "navigation",
128
155
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
129
156
 
130
157
  <%= render "components/sidebar_panel",
131
158
  id: "studio-link-sidebar-mobile",
132
- title: sidebar_title,
159
+ title_html: sidebar_title_html,
133
160
  open: "$store.sidebars.linkTreeOpen",
134
161
  body_html: body_html,
135
162
  footer_html: footer_html,
@@ -138,5 +165,6 @@
138
165
  escape_action: close_action,
139
166
  display_class: "flex md:hidden",
140
167
  width_class: "w-full",
168
+ edge: true,
141
169
  role: "navigation",
142
170
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
@@ -19,13 +19,17 @@
19
19
  would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
20
20
  TIMELINE) moves it with the header on the scroll timeline instead.
21
21
 
22
- Two opt-in locals (unset, the panel renders as it did in 0.113.0):
22
+ Three opt-in locals (unset, the panel renders as it did in 0.113.0):
23
23
  title_html the header's LEAD SLOT: HTML in the title's place, for a panel
24
24
  whose lead is a live status line rather than a label. It is
25
25
  content, NOT a name: it never feeds aria-labelledby or
26
26
  aria-label, so a caller that passes it instead of `title` must
27
27
  pass aria_label, or the panel has no accessible name. `title`
28
28
  wins when both are passed. Escaped unless html_safe.
29
+ edge true stamps data-sidebar-edge, the soft blurred band along the
30
+ panel's inner edge, drawn by engine.css (THE SIDEBAR'S EDGE) from md
31
+ up. It softens the page beside the panel, so it suits a menu that
32
+ opens over the page, not a panel kept open beside live content.
29
33
  close_hook true stamps the close button data-sidebar-panel-close="<id>"
30
34
  too, so a host's own document handler can match the button
31
35
  to its panel by id. Needs `id`; without one it adds nothing.
@@ -49,6 +53,7 @@
49
53
  <% aria_label = local_assigns.fetch(:aria_label, title) %>
50
54
  <% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
51
55
  <% close_hook = local_assigns.fetch(:close_hook, false) && id.present? %>
56
+ <% edge = local_assigns.fetch(:edge, false) %>
52
57
 
53
58
  <aside
54
59
  <% if id.present? %>id="<%= id %>"<% end %>
@@ -76,6 +81,9 @@
76
81
  class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
77
82
  style="top:var(--nav-bottom, var(--nav-h, 6rem));height:calc(100% - var(--nav-bottom, var(--nav-h, 6rem)))"
78
83
  data-pin-edge
84
+ <% if edge %>
85
+ data-sidebar-edge
86
+ <% end %>
79
87
  role="<%= role %>"
80
88
  <% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
81
89
  <div class="flex flex-col h-full">
@@ -255,6 +255,10 @@
255
255
  the document click and Escape handlers. It imports nothing, and the
256
256
  link-sidebar controller imports it. %>
257
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" %>
258
262
  <%# THE X-DATA FACTORIES, BY THEIR OWN DOOR, for the same reason.
259
263
  studio/alpine_scopes publishes the factories a page binds by name
260
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 %>
@@ -33,9 +33,11 @@
33
33
 
34
34
  THE SENTINEL stays at the row's place in the flow, and the row's distance from
35
35
  it is what "stuck" means, on every kind of header. It is absolutely positioned,
36
- so a space-y parent adds nothing for it. It carries the controller, so the row
37
- keeps its data-studio-controller for the page's own. So the row carries no top
38
- margin: the space above it belongs to its container. %>
36
+ so a space-y parent adds nothing for it. In a flex or grid parent it sits at the
37
+ parent's start, so the controller measures the row's place from the row. It
38
+ carries the controller, so the row keeps its data-studio-controller for the
39
+ page's own. So the row carries no top margin: the space above it belongs to its
40
+ container. %>
39
41
  <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
40
42
  <%= content_tag(tabs.any? ? :nav : :div,
41
43
  class: "page-header sticky #{row_class}".strip,
@@ -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.125.0"
2
+ VERSION = "0.127.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.125.0
4
+ version: 0.127.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-04 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -563,6 +563,7 @@ files:
563
563
  - app/javascript/studio/survey.js
564
564
  - app/javascript/studio/theme_editor.js
565
565
  - app/javascript/studio/toast.js
566
+ - app/javascript/studio/transition_snapshot.js
566
567
  - app/javascript/studio/vendor/stimulus.js
567
568
  - app/javascript/studio/voice_profile.js
568
569
  - app/javascript/studio/voice_recorder.js