studio-engine 0.125.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: 13bb9cfe1f6491a4dd2a199c16f734f466edad8fb38beae903033305e8fd91da
4
- data.tar.gz: 20d1fec190a9bfe2391e4e4121f2fa11dc35729abd54a8df47e0a19346611715
3
+ metadata.gz: b282dc9ef2736f70ae7d985d1617ad22f61f25b6bba0a2b9391804e68c01069d
4
+ data.tar.gz: 3a0234c85465610eb31351583c0b0d743cf1a42f7db193d762869b040255143a
5
5
  SHA512:
6
- metadata.gz: 3687dd8d85fffecbb4c1ab84177bcdb9dee0bfd0aa87dac7dbad61a81a4921d920f9c2f1cda8cfc3bff378c4a12d3528ab015e14085b447204c745e69c84155e
7
- data.tar.gz: 2d135f12456ce99328d5422df362a83271cd9075b9f618c80b0cf3053edccb3a383164a0c23fdc6cced5e5e4514467e90b637d5beb976f052b2b742cfd7a8b18
6
+ metadata.gz: ddf281dc8349069d3c2e8aea1a5084dd5030f0b927675ce20fd3a139565ca862e3d52faaa2e6d27a1d76aed9a9e6b566fb1174da3e6a73ffe559466118c914d9
7
+ data.tar.gz: 2d2f261cedcef59c97ce8f4af60b2942b4cfce84d3355fdd7f8604e52193f08c98c73b14592eabcfaf1e284f4b049aea7e7bbac51c3c5c600f745724822255fa
data/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ 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
+
7
39
  ## 0.125.0 — 2026-11-04
8
40
 
9
41
  ### Added
@@ -1084,6 +1084,68 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
1084
1084
  font-size: calc(var(--nav-avatar) / 3 / 0.7);
1085
1085
  }
1086
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
+ }
1087
1149
  }
1088
1150
 
1089
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
 
@@ -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,
@@ -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,
@@ -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 %>
@@ -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.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.125.0
4
+ version: 0.126.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -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