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 +4 -4
- data/CHANGELOG.md +32 -0
- data/app/assets/tailwind/studio_engine/engine.css +62 -0
- data/app/helpers/studio_sidebar_helper.rb +2 -2
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +4 -2
- data/app/javascript/studio/link_sidebar.js +40 -4
- data/app/javascript/studio/transition_snapshot.js +123 -0
- data/app/views/components/_geo_signpost.html.erb +22 -17
- data/app/views/components/_link_sidebar.html.erb +53 -27
- data/app/views/layouts/studio/_head.html.erb +4 -0
- data/app/views/schema/index.html.erb +1 -1
- data/lib/studio/sidebar_sections.rb +4 -2
- data/lib/studio/ui_primitives.rb +3 -0
- data/lib/studio/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b282dc9ef2736f70ae7d985d1617ad22f61f25b6bba0a2b9391804e68c01069d
|
|
4
|
+
data.tar.gz: 3a0234c85465610eb31351583c0b0d743cf1a42f7db193d762869b040255143a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddf281dc8349069d3c2e8aea1a5084dd5030f0b927675ce20fd3a139565ca862e3d52faaa2e6d27a1d76aed9a9e6b566fb1174da3e6a73ffe559466118c914d9
|
|
7
|
+
data.tar.gz: 2d2f261cedcef59c97ce8f4af60b2942b4cfce84d3355fdd7f8604e52193f08c98c73b14592eabcfaf1e284f4b049aea7e7bbac51c3c5c600f745724822255fa
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,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
|
|
15
|
+
# sidebar heads with the engine's own Profile row
|
|
16
16
|
# (Studio::SidebarSections prepends it whenever /profile is drawn), so the
|
|
17
17
|
# account page stays one click away (components/_user_nav). A host may
|
|
18
18
|
# define its own to say otherwise.
|
|
@@ -22,7 +22,7 @@ module StudioSidebarHelper
|
|
|
22
22
|
|
|
23
23
|
# The admin dropdown and the sidebar trigger share the cog glyph — showing
|
|
24
24
|
# both reads as a double gear. When the viewer's resolved sections carry an
|
|
25
|
-
# admin-flagged entry, the sidebar IS the admin menu (its
|
|
25
|
+
# admin-flagged entry, the sidebar IS the admin menu (its accessible name says so) and
|
|
26
26
|
# the engine components skip the dropdown. Declaring only public sections
|
|
27
27
|
# keeps the dropdown, so admins never lose Theme/Navbar/Error Logs.
|
|
28
28
|
def studio_sidebar_replaces_admin_menu?
|
|
@@ -14,6 +14,7 @@ import NavCollapseController from "studio/controllers/nav_collapse_controller"
|
|
|
14
14
|
import ModalHostController from "studio/controllers/modal_host_controller"
|
|
15
15
|
import ToastController from "studio/controllers/toast_controller"
|
|
16
16
|
import LinkSidebarController from "studio/controllers/link_sidebar_controller"
|
|
17
|
+
import "studio/transition_snapshot"
|
|
17
18
|
import IdentityMiniController from "studio/controllers/identity_mini_controller"
|
|
18
19
|
import { startPinnedStack } from "studio/pinned_stack"
|
|
19
20
|
import { installAlpineShims } from "studio/alpine_shims"
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The open flag and the document handlers are studio/link_sidebar, which
|
|
8
8
|
// registers $store.sidebars.linkTreeOpen on alpine:init and before a Turbo
|
|
9
|
-
// render. connect() registers it too,
|
|
9
|
+
// render. connect() registers it too, and writes the panel's greeting, for a
|
|
10
|
+
// sidebar that arrived any other way.
|
|
10
11
|
// Registered by studio/application.
|
|
11
12
|
import { Controller } from "@hotwired/stimulus"
|
|
12
|
-
import { ensureLinkSidebarStore } from "studio/link_sidebar"
|
|
13
|
+
import { ensureLinkSidebarStore, paintGreetings } from "studio/link_sidebar"
|
|
13
14
|
|
|
14
15
|
export default class extends Controller {
|
|
15
16
|
connect() {
|
|
16
17
|
ensureLinkSidebarStore(window.Alpine)
|
|
18
|
+
paintGreetings(document)
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// A host with its own `sidebars` store keeps it and gains the flag
|
|
22
22
|
// (turf-monster's holds gearOpen).
|
|
23
23
|
//
|
|
24
|
+
// THE GREETING. A signed-out panel's title is a greeting by the browser's local
|
|
25
|
+
// time. components/_link_sidebar renders a default; this writes the right word
|
|
26
|
+
// over it on load, after a Turbo visit or snapshot restore (turbo:load), on a bfcache
|
|
27
|
+
// restore, and as the trigger opens the panel. A panel left open across a boundary
|
|
28
|
+
// keeps its word until it next opens.
|
|
29
|
+
//
|
|
24
30
|
// THE DOCUMENT HANDLERS run in the capture phase, so the trigger works for a
|
|
25
31
|
// click that lands before Alpine has bound it. They are installed once, on
|
|
26
32
|
// every page, and do nothing where no link sidebar is rendered.
|
|
@@ -37,6 +43,25 @@ export const LINK_SIDEBAR_PANELS = "#studio-link-sidebar, #studio-link-sidebar-m
|
|
|
37
43
|
export const LINK_SIDEBAR_TRIGGERS = "[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]"
|
|
38
44
|
const CONTROLS_PANEL = '[aria-controls~="studio-link-sidebar"]'
|
|
39
45
|
const CLOSE_BUTTON = "[data-link-sidebar-close]"
|
|
46
|
+
const GREETING = "[data-link-sidebar-greeting]"
|
|
47
|
+
|
|
48
|
+
// The three words, by the hour of `now` in the browser's own time zone.
|
|
49
|
+
export function greetingFor(now) {
|
|
50
|
+
const hour = now.getHours()
|
|
51
|
+
if (hour >= 5 && hour < 12) return "Good morning"
|
|
52
|
+
if (hour >= 12 && hour < 17) return "Good afternoon"
|
|
53
|
+
return "Good evening"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Writes the word into every greeting under `root`, the desktop and the phone panel
|
|
57
|
+
// both. A signed-in panel holds no greeting, so its name is left alone.
|
|
58
|
+
export function paintGreetings(root, now = new Date()) {
|
|
59
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
60
|
+
const word = greetingFor(now)
|
|
61
|
+
root.querySelectorAll(GREETING).forEach((node) => {
|
|
62
|
+
if (node.textContent !== word) node.textContent = word
|
|
63
|
+
})
|
|
64
|
+
}
|
|
40
65
|
|
|
41
66
|
// Registers the flag: a new `sidebars` store, or one more key on the host's.
|
|
42
67
|
// Null before Alpine exists.
|
|
@@ -130,14 +155,18 @@ export function handleLinkSidebarKeydown(event, Alpine) {
|
|
|
130
155
|
const installed = new WeakSet()
|
|
131
156
|
|
|
132
157
|
// Once per window: a second call adds no second listener.
|
|
133
|
-
export function installLinkSidebar({ win, doc } = {}) {
|
|
158
|
+
export function installLinkSidebar({ win, doc, now = () => new Date() } = {}) {
|
|
134
159
|
const host = win || globalThis
|
|
135
160
|
const root = doc || host.document
|
|
136
161
|
if (!root || typeof root.addEventListener !== "function") return false
|
|
137
162
|
if (installed.has(host)) return false
|
|
138
163
|
installed.add(host)
|
|
139
164
|
|
|
140
|
-
const
|
|
165
|
+
const paint = (target = root) => paintGreetings(target, now())
|
|
166
|
+
const register = () => {
|
|
167
|
+
registerLinkSidebar(host.Alpine, root)
|
|
168
|
+
paint()
|
|
169
|
+
}
|
|
141
170
|
root.addEventListener("alpine:init", register)
|
|
142
171
|
root.addEventListener("turbo:load", register)
|
|
143
172
|
root.addEventListener("turbo:before-render", (event) => {
|
|
@@ -145,12 +174,19 @@ export function installLinkSidebar({ win, doc } = {}) {
|
|
|
145
174
|
})
|
|
146
175
|
// A page restored from Turbo's cache or the bfcache comes back closed.
|
|
147
176
|
root.addEventListener("turbo:before-cache", () => closeLinkSidebar(host.Alpine))
|
|
148
|
-
host.addEventListener("pageshow", (event) => {
|
|
149
|
-
|
|
177
|
+
host.addEventListener("pageshow", (event) => {
|
|
178
|
+
if (!event.persisted) return
|
|
179
|
+
closeLinkSidebar(host.Alpine)
|
|
180
|
+
paint()
|
|
181
|
+
})
|
|
182
|
+
root.addEventListener("click", (event) => {
|
|
183
|
+
if (handleLinkSidebarClick(event, host.Alpine) === "toggle") paint()
|
|
184
|
+
}, true)
|
|
150
185
|
root.addEventListener("keydown", (event) => handleLinkSidebarKeydown(event, host.Alpine), true)
|
|
151
186
|
|
|
152
187
|
// Alpine already started (a host that loads it ahead of the module tags).
|
|
153
188
|
if (host.Alpine && host.Alpine.version) register()
|
|
189
|
+
else paint()
|
|
154
190
|
return true
|
|
155
191
|
}
|
|
156
192
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// studio/transition_snapshot: Turbo's snapshot never keeps an Alpine transition's
|
|
2
|
+
// mid-slide state, and a restored page recovers from one that did.
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. Turbo clones the live page one tick after turbo:before-cache. A link
|
|
5
|
+
// in a components/_sidebar_panel closes the panel on its click, so a response that
|
|
6
|
+
// arrives inside the 200ms leave clones the panel with Alpine's phase classes on it
|
|
7
|
+
// (`transition ease-in duration-200 translate-x-full`). A hovered link has a
|
|
8
|
+
// prefetched response, so this is ordinary in production as well as on localhost.
|
|
9
|
+
// The clone has no running transition to remove them. Back then restores it, and the
|
|
10
|
+
// panel sits one width off-screen for good: Alpine's transition removes only the
|
|
11
|
+
// classes it added itself, so it never removes a class it found already there, and
|
|
12
|
+
// closing and reopening changes nothing. Only a full load clears it.
|
|
13
|
+
//
|
|
14
|
+
// THE TWO HALVES.
|
|
15
|
+
//
|
|
16
|
+
// 1. Before the clone, finish every running transition with Alpine's own finish().
|
|
17
|
+
// Alpine removes exactly what it added, and a leave goes on to its display:none.
|
|
18
|
+
// This also covers an x-transition.opacity, which writes inline style and names
|
|
19
|
+
// no class.
|
|
20
|
+
// 2. On turbo:before-render, strip the phase classes from the incoming body before
|
|
21
|
+
// Alpine sees it, for a snapshot that holds them anyway.
|
|
22
|
+
//
|
|
23
|
+
// WHEN "BEFORE THE CLONE" IS. Two handlers, because nothing orders Turbo's clone and
|
|
24
|
+
// its swap. turbo:before-cache cannot finish anything itself: the event is what closes
|
|
25
|
+
// a panel (link_sidebar's handler, a panel's @turbo:before-cache.window), and that
|
|
26
|
+
// close starts its leave in a microtask after the handlers return.
|
|
27
|
+
//
|
|
28
|
+
// turbo:before-render the plain path, measured: the swap runs in microtasks, ahead
|
|
29
|
+
// of the clone. This is the last moment the panel is connected,
|
|
30
|
+
// and Alpine skips a finished transition's cleanup on a
|
|
31
|
+
// disconnected element.
|
|
32
|
+
// a zero timeout set the path where the clone comes first (a view transition or a
|
|
33
|
+
// in before-cache held render puts the swap later). It fires ahead of the one
|
|
34
|
+
// Turbo sets right after the event for its clone: timers of one
|
|
35
|
+
// delay fire in order.
|
|
36
|
+
//
|
|
37
|
+
// Neither waits for an animation, so nothing here depends on how long a slide takes.
|
|
38
|
+
// The timeout also strips the old body, for a transition something started after
|
|
39
|
+
// before-render on a body that has gone. If Turbo ever changes this order, the second
|
|
40
|
+
// half still recovers the page.
|
|
41
|
+
//
|
|
42
|
+
// THE STRIP'S RULE. It removes only classes named in the element's own x-transition
|
|
43
|
+
// attributes, and only when the element holds a whole phase: all of a direction's
|
|
44
|
+
// `during` classes plus all of its `-start` or all of its `-end`. A panel at rest
|
|
45
|
+
// never holds that. The cost: the strip runs on every Turbo render, not only a restore,
|
|
46
|
+
// so an element that keeps a whole phase at rest loses those classes. No view in the engine, the
|
|
47
|
+
// hub or turf-monster names one (scanned 2026-11-04).
|
|
48
|
+
//
|
|
49
|
+
// Nothing here sets `display`: studio/link_sidebar's contract stands, and x-show
|
|
50
|
+
// still owns it. WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports it by
|
|
51
|
+
// its own nonced module tag as well as through the boot graph, so a failed boot
|
|
52
|
+
// leaves a page this. It imports nothing.
|
|
53
|
+
|
|
54
|
+
const RUNS_A_TRANSITION = "[x-show], [x-transition\\:enter], [x-transition\\:leave]"
|
|
55
|
+
const NAMES_CLASSES = "[x-transition\\:enter], [x-transition\\:leave]"
|
|
56
|
+
const DIRECTIONS = ["enter", "leave"]
|
|
57
|
+
|
|
58
|
+
function named(el, part) {
|
|
59
|
+
return (el.getAttribute(`x-transition:${part}`) || "").split(/\s+/).filter(Boolean)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function holdsAll(el, classes) {
|
|
63
|
+
return classes.length > 0 && classes.every((name) => el.classList.contains(name))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The classes to remove from `el`: empty unless it holds a whole phase.
|
|
67
|
+
export function midSlideClasses(el) {
|
|
68
|
+
const classes = new Set()
|
|
69
|
+
for (const direction of DIRECTIONS) {
|
|
70
|
+
const during = named(el, direction)
|
|
71
|
+
const start = named(el, `${direction}-start`)
|
|
72
|
+
const end = named(el, `${direction}-end`)
|
|
73
|
+
if (holdsAll(el, during) && (holdsAll(el, start) || holdsAll(el, end))) {
|
|
74
|
+
for (const name of [...during, ...start, ...end]) classes.add(name)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...classes]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The second half. Null-safe: turbo:before-render may carry no body.
|
|
81
|
+
export function stripMidSlideClasses(root) {
|
|
82
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
83
|
+
root.querySelectorAll(NAMES_CLASSES).forEach((el) => {
|
|
84
|
+
const classes = midSlideClasses(el)
|
|
85
|
+
if (classes.length) el.classList.remove(...classes)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The first half. finish() is what Alpine's own timer calls when a slide ends, and
|
|
90
|
+
// it runs once, so that timer does nothing afterward. An Alpine with no such handle
|
|
91
|
+
// or no finish() finishes nothing here, and must not throw: the strip runs next.
|
|
92
|
+
export function finishTransitions(root) {
|
|
93
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
94
|
+
root.querySelectorAll(RUNS_A_TRANSITION).forEach((el) => {
|
|
95
|
+
if (typeof el._x_transitioning?.finish === "function") el._x_transitioning.finish()
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installed = new WeakSet()
|
|
100
|
+
|
|
101
|
+
// Once per window: a second call adds no second listener.
|
|
102
|
+
export function installTransitionSnapshot({ win, doc } = {}) {
|
|
103
|
+
const host = win || globalThis
|
|
104
|
+
const root = doc || host.document
|
|
105
|
+
if (!root || typeof root.addEventListener !== "function") return false
|
|
106
|
+
if (installed.has(host)) return false
|
|
107
|
+
installed.add(host)
|
|
108
|
+
|
|
109
|
+
root.addEventListener("turbo:before-cache", () => {
|
|
110
|
+
const body = root.body
|
|
111
|
+
host.setTimeout(() => {
|
|
112
|
+
finishTransitions(body)
|
|
113
|
+
stripMidSlideClasses(body)
|
|
114
|
+
}, 0)
|
|
115
|
+
})
|
|
116
|
+
root.addEventListener("turbo:before-render", (event) => {
|
|
117
|
+
finishTransitions(root.body)
|
|
118
|
+
stripMidSlideClasses(event.detail && event.detail.newBody)
|
|
119
|
+
})
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof window !== "undefined") installTransitionSnapshot({ win: window, doc: document })
|
|
@@ -82,33 +82,38 @@
|
|
|
82
82
|
`nil.to_sym` raised NoMethodError inside admin chrome on every page. %>
|
|
83
83
|
<% if local_assigns[:variant].presence&.to_sym == :sidebar %>
|
|
84
84
|
<%# Sidebar variant: the wide emoji row the link sidebar's own sections use,
|
|
85
|
-
so an engine-owned row is indistinguishable from a host-declared one
|
|
85
|
+
so an engine-owned row is indistinguishable from a host-declared one: one
|
|
86
|
+
line at rest, its description on hover or focus. %>
|
|
86
87
|
<% if geo_live %>
|
|
87
88
|
<%= link_to admin_geo_path,
|
|
88
|
-
class: "group flex items-
|
|
89
|
+
class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
90
|
+
"data-sidebar-row-described": "",
|
|
89
91
|
"@click": geo_close do %>
|
|
90
|
-
<span class="text-lg leading-none flex-shrink-0">🌍</span>
|
|
91
|
-
<span class="min-w-0">
|
|
92
|
-
<span class="block font-medium truncate">Geo</span>
|
|
93
|
-
<span class="block text-xs text-muted
|
|
92
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
|
|
93
|
+
<span class="relative min-w-0 flex-1">
|
|
94
|
+
<span class="block font-medium truncate" data-sidebar-row-label>Geo</span>
|
|
95
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc>Country and subdivision blocking</span>
|
|
94
96
|
</span>
|
|
95
97
|
<% end %>
|
|
96
98
|
<% else %>
|
|
97
|
-
<%#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
<%# The row that cannot link holds the string to copy as its description. A title
|
|
100
|
+
alone failed on touch (THE HINT, above), so tabindex lets a tap and a key
|
|
101
|
+
focus the row, and engine.css reveals it on :focus. The long form stays in
|
|
102
|
+
the title. No `select-none` here, and that is the fix rather than an
|
|
103
|
+
omission: the row's whole job in this state is to hand over the string, and
|
|
104
|
+
select-none made it unselectable. role/aria-disabled give a screen reader
|
|
105
|
+
the "unavailable destination" this paints for a sighted eye. %>
|
|
106
|
+
<span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset"
|
|
102
107
|
role="link"
|
|
103
108
|
aria-disabled="true"
|
|
109
|
+
tabindex="0"
|
|
104
110
|
data-test="geo-signpost-disabled"
|
|
111
|
+
data-sidebar-row-described
|
|
105
112
|
title="<%= geo_title %>">
|
|
106
|
-
<span class="text-lg leading-none flex-shrink-0">🌍</span>
|
|
107
|
-
<span class="min-w-0">
|
|
108
|
-
<span class="block font-medium truncate">Geo</span>
|
|
109
|
-
<span class="block text-xs text-muted
|
|
110
|
-
<code class="font-mono break-all"><%= geo_code %></code>
|
|
111
|
-
</span>
|
|
113
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
|
|
114
|
+
<span class="relative min-w-0 flex-1">
|
|
115
|
+
<span class="block font-medium truncate" data-sidebar-row-label>Geo</span>
|
|
116
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc><code class="font-mono"><%= geo_code %></code></span>
|
|
112
117
|
</span>
|
|
113
118
|
</span>
|
|
114
119
|
<% end %>
|
|
@@ -6,9 +6,37 @@
|
|
|
6
6
|
this automatically when sections are declared. %>
|
|
7
7
|
<% sections = studio_sidebar_sections %>
|
|
8
8
|
<% sidebar_admin = respond_to?(:admin?) && admin? %>
|
|
9
|
-
<%#
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
<%# THE PANEL'S TITLE. Signed in, admin included: the name the navbar shows, from the
|
|
10
|
+
same call. Signed out: a greeting by the browser's local time, which the server
|
|
11
|
+
cannot know, so studio/link_sidebar writes the word over this default before the
|
|
12
|
+
panel opens (greetingFor). The panel is hidden until then, so nothing flashes.
|
|
13
|
+
|
|
14
|
+
It goes in the panel's lead slot (title_html), not its title. A title names the
|
|
15
|
+
panel through aria-labelledby, and "Good morning" or "Alex" is no name for a
|
|
16
|
+
menu: the panel keeps "Admin menu" or "Links menu" as its aria-label. %>
|
|
17
|
+
<% sidebar_signed_in = respond_to?(:logged_in?) && logged_in? %>
|
|
18
|
+
<% sidebar_user = sidebar_signed_in && respond_to?(:current_user) ? current_user : nil %>
|
|
19
|
+
<% sidebar_title_class = "type-subhead text-heading truncate pr-3" %>
|
|
20
|
+
<% sidebar_title_html =
|
|
21
|
+
if sidebar_user
|
|
22
|
+
sidebar_name = Studio.navbar_user_name_for(sidebar_user, self).to_s.squish
|
|
23
|
+
tag.p(sidebar_name, class: sidebar_title_class, title: sidebar_name, data: { link_sidebar_title: "" })
|
|
24
|
+
elsif !sidebar_signed_in
|
|
25
|
+
tag.p("Good morning", class: sidebar_title_class, data: { link_sidebar_title: "", link_sidebar_greeting: "" })
|
|
26
|
+
end %>
|
|
27
|
+
<%# THE GROUPS. No section shows a label word and none shows the admin chip: a divider
|
|
28
|
+
separates the groups. The words stay as each group's accessible name, so a screen
|
|
29
|
+
reader still announces them. An empty section draws no group, or its two dividers
|
|
30
|
+
would touch.
|
|
31
|
+
|
|
32
|
+
A ROW IS ONE LINE AT REST, with or without a desc:, so the spacing never changes. A
|
|
33
|
+
desc: fades in on hover or keyboard focus, and nothing moves to make room: it sits
|
|
34
|
+
absolutely in the row's padding as the label lifts by a transform. engine.css, THE
|
|
35
|
+
SIDEBAR ROW'S DESCRIPTION, holds the rules. A row with no desc: gains no attribute. %>
|
|
36
|
+
<% sidebar_group = lambda do |title:, admin:, first:, &block|
|
|
37
|
+
name = [title.presence, ("admin" if admin && !title.to_s.match?(/\badmin\b/i))].compact.join(", ").presence
|
|
38
|
+
tag.div(role: "group", aria: { label: name }, class: ["py-1", ("border-t border-subtle" unless first)], &block)
|
|
39
|
+
end %>
|
|
12
40
|
<% close_action = "$store.sidebars.linkTreeOpen = false" %>
|
|
13
41
|
|
|
14
42
|
<style>
|
|
@@ -25,21 +53,16 @@
|
|
|
25
53
|
<template data-studio-controller="link-sidebar"></template>
|
|
26
54
|
|
|
27
55
|
<% body_html = capture do %>
|
|
28
|
-
<nav class="flex-1 min-h-0 overflow-y-auto py-
|
|
29
|
-
<% sections.
|
|
30
|
-
|
|
31
|
-
<span><%= section[:title] %></span>
|
|
32
|
-
<% if section[:admin] %>
|
|
33
|
-
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
|
|
34
|
-
<% end %>
|
|
35
|
-
</div>
|
|
36
|
-
|
|
56
|
+
<nav class="flex-1 min-h-0 overflow-y-auto py-1" aria-label="Studio links">
|
|
57
|
+
<% sections.select { |section| section[:links].any? }.each_with_index do |section, index| %>
|
|
58
|
+
<%= sidebar_group.call(title: section[:title], admin: section[:admin], first: index.zero?) do %>
|
|
37
59
|
<% section[:links].each do |link| %>
|
|
38
60
|
<% target = link[:target].presence %>
|
|
39
61
|
<%= link_to link[:href],
|
|
40
|
-
class: "group flex
|
|
62
|
+
class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
41
63
|
target: target,
|
|
42
64
|
rel: target == "_blank" ? "noopener" : nil,
|
|
65
|
+
"data-sidebar-row-described": (link[:desc].present? ? "" : nil),
|
|
43
66
|
"@click": close_action do %>
|
|
44
67
|
<% if link[:hover_emoji].present? %>
|
|
45
68
|
<%= render "components/emoji_swap",
|
|
@@ -47,16 +70,21 @@
|
|
|
47
70
|
hover: link[:hover_emoji],
|
|
48
71
|
class_name: "text-lg leading-none" %>
|
|
49
72
|
<% else %>
|
|
50
|
-
<span class="text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
|
|
73
|
+
<span class="studio-emoji-box text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% if link[:desc].present? %>
|
|
76
|
+
<span class="relative min-w-0 flex-1">
|
|
77
|
+
<span class="block font-medium truncate" data-sidebar-row-label><%= link[:label] %></span>
|
|
78
|
+
<span class="block truncate text-xs text-muted" data-sidebar-row-desc><%= link[:desc] %></span>
|
|
79
|
+
</span>
|
|
80
|
+
<% else %>
|
|
81
|
+
<span class="min-w-0">
|
|
82
|
+
<span class="block font-medium truncate"><%= link[:label] %></span>
|
|
83
|
+
</span>
|
|
51
84
|
<% end %>
|
|
52
|
-
<span class="min-w-0">
|
|
53
|
-
<span class="block font-medium truncate"><%= link[:label] %></span>
|
|
54
|
-
<% if link[:desc].present? %>
|
|
55
|
-
<span class="block text-xs text-muted leading-snug mt-0.5"><%= link[:desc] %></span>
|
|
56
|
-
<% end %>
|
|
57
|
-
</span>
|
|
58
85
|
<% end %>
|
|
59
86
|
<% end %>
|
|
87
|
+
<% end %>
|
|
60
88
|
<% end %>
|
|
61
89
|
|
|
62
90
|
<%# THE GEO SIGNPOST — how a sidebar-chrome app gets geo signage at all.
|
|
@@ -93,11 +121,9 @@
|
|
|
93
121
|
bare call would raise for them. The two must stay the same question —
|
|
94
122
|
if the helper's rule changes, this changes with it. %>
|
|
95
123
|
<% if sidebar_admin && sections.any? { |section| section[:admin] } %>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
</div>
|
|
100
|
-
<%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
|
|
124
|
+
<%= sidebar_group.call(title: "Geo", admin: true, first: false) do %>
|
|
125
|
+
<%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
|
|
126
|
+
<% end %>
|
|
101
127
|
<% end %>
|
|
102
128
|
</nav>
|
|
103
129
|
<% end %>
|
|
@@ -116,7 +142,7 @@
|
|
|
116
142
|
|
|
117
143
|
<%= render "components/sidebar_panel",
|
|
118
144
|
id: "studio-link-sidebar",
|
|
119
|
-
|
|
145
|
+
title_html: sidebar_title_html,
|
|
120
146
|
open: "$store.sidebars.linkTreeOpen",
|
|
121
147
|
body_html: body_html,
|
|
122
148
|
footer_html: footer_html,
|
|
@@ -129,7 +155,7 @@
|
|
|
129
155
|
|
|
130
156
|
<%= render "components/sidebar_panel",
|
|
131
157
|
id: "studio-link-sidebar-mobile",
|
|
132
|
-
|
|
158
|
+
title_html: sidebar_title_html,
|
|
133
159
|
open: "$store.sidebars.linkTreeOpen",
|
|
134
160
|
body_html: body_html,
|
|
135
161
|
footer_html: footer_html,
|
|
@@ -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
|
|
data/lib/studio/ui_primitives.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Studio
|
|
4
4
|
module UiPrimitives
|
|
5
5
|
EMOJI_SWAP_CSS = <<~CSS
|
|
6
|
+
/* .studio-emoji-box is a plain emoji in the swap's box, so a row with a plain
|
|
7
|
+
emoji and a row with a swap are one height and their labels line up. */
|
|
8
|
+
.studio-emoji-box,
|
|
6
9
|
.studio-emoji-swap,
|
|
7
10
|
.nav-emoji-swap {
|
|
8
11
|
position: relative;
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.126.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -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
|