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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +5 -1
- data/app/assets/tailwind/studio_engine/engine.css +109 -3
- 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/controllers/page_header_controller.js +16 -2
- data/app/javascript/studio/link_sidebar.js +40 -4
- data/app/javascript/studio/page_header.js +7 -0
- 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 +55 -27
- data/app/views/components/_sidebar_panel.html.erb +9 -1
- data/app/views/layouts/studio/_head.html.erb +4 -0
- data/app/views/schema/index.html.erb +1 -1
- data/app/views/studio/_page_header.html.erb +5 -3
- data/lib/studio/sidebar_sections.rb +4 -2
- data/lib/studio/ui_primitives.rb +3 -0
- data/lib/studio/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22ef2ab3094c82f6f447ee86455dac3e2c70b759ec3d58f2f1a887a347c4b1ad
|
|
4
|
+
data.tar.gz: 92c5bc2d6c5f53914e33d7c50623a076791ef50faff385b4e63218e3773eb6a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -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.
|
|
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
|
|
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
|
|
|
@@ -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-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 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-
|
|
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
|