studio-engine 0.126.0 → 0.127.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b282dc9ef2736f70ae7d985d1617ad22f61f25b6bba0a2b9391804e68c01069d
4
- data.tar.gz: 3a0234c85465610eb31351583c0b0d743cf1a42f7db193d762869b040255143a
3
+ metadata.gz: 22ef2ab3094c82f6f447ee86455dac3e2c70b759ec3d58f2f1a887a347c4b1ad
4
+ data.tar.gz: 92c5bc2d6c5f53914e33d7c50623a076791ef50faff385b4e63218e3773eb6a6
5
5
  SHA512:
6
- metadata.gz: ddf281dc8349069d3c2e8aea1a5084dd5030f0b927675ce20fd3a139565ca862e3d52faaa2e6d27a1d76aed9a9e6b566fb1174da3e6a73ffe559466118c914d9
7
- data.tar.gz: 2d2f261cedcef59c97ce8f4af60b2942b4cfce84d3355fdd7f8604e52193f08c98c73b14592eabcfaf1e284f4b049aea7e7bbac51c3c5c600f745724822255fa
6
+ metadata.gz: 1420fb65a41535cc4c4ae4a1466a83fe46d02eca34c17ed26b1ef1fdfc1cf6ca934776f70a843380410593c7932bbc23c53fe2fe6f0b992e3bf5a19206b5e7f3
7
+ data.tar.gz: c88656c2c2363917fff41839b65c00ccc0f499d3c30c1a216ae783081f6712b44c200ef93500fceae04d99f6719db344ed0b213b742e104d632d9a0d063fc4d3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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
+
7
29
  ## 0.126.0 — 2026-11-04
8
30
 
9
31
  ### Changed
data/README.md CHANGED
@@ -1092,9 +1092,13 @@ config.navbar_account_sidebar = { flag: "gearOpen", controls: %w[gear-sidebar ge
1092
1092
 
1093
1093
  ### Sidebar panel lead and close hook
1094
1094
 
1095
- `components/_sidebar_panel` takes two opt-in locals; unset, the panel renders
1095
+ `components/_sidebar_panel` takes three opt-in locals; unset, the panel renders
1096
1096
  as before:
1097
1097
 
1098
+ - `edge: true` draws a soft blurred band along the panel's inner edge from `md`
1099
+ up, through a `data-sidebar-edge` hook and engine.css. It takes no clicks. The
1100
+ link sidebar passes it. It softens the page beside the panel, so leave it off
1101
+ a panel kept open beside live content.
1098
1102
  - `title_html:` HTML in the title's place, for a panel whose header leads with
1099
1103
  a live status line. It is content, not a name: it never feeds
1100
1104
  `aria-labelledby` or `aria-label`, so a caller that passes it instead of
@@ -229,12 +229,19 @@ body.modal-open .studio-app-banner {
229
229
  the pinned stack has nothing to republish. The layout is owned here: the
230
230
  engine's views depend on no responsive utility a host may not have emitted.
231
231
  The fade is owned too, because .fade-edge-x is in the opt-in motion layer, and
232
- it marks only an end that hides a tab (studio/controllers/page_header). */
232
+ it marks only an end that hides a tab (studio/controllers/page_header).
233
+
234
+ A LONG TITLE: the row wraps. The tabs' flex-basis is their floor, so a title that
235
+ leaves them less than that takes the first line and the tabs take the next, at
236
+ full width. A title too long for its line wraps, then stops at three lines. A short
237
+ title never reaches the floor, and its row is one line as before. */
233
238
 
234
239
  @utility page-header {
235
240
  display: flex;
241
+ flex-wrap: wrap;
236
242
  align-items: center;
237
243
  gap: 0.75rem;
244
+ min-width: 0;
238
245
  padding-block: 0.5rem;
239
246
  border-block: 1px solid transparent;
240
247
  background-color: transparent;
@@ -256,7 +263,7 @@ body.modal-open .studio-app-banner {
256
263
  display: flex;
257
264
  align-items: baseline;
258
265
  gap: 0.5rem;
259
- flex-shrink: 0;
266
+ flex: 0 1 auto;
260
267
  min-width: 0;
261
268
  }
262
269
 
@@ -265,6 +272,14 @@ body.modal-open .studio-app-banner {
265
272
  font-size: calc(var(--type-base, 16px) * 1.5);
266
273
  font-weight: var(--type-display-weight, 800);
267
274
  line-height: 1.25;
275
+ min-width: 0;
276
+ overflow-wrap: anywhere;
277
+ display: -webkit-box;
278
+ -webkit-box-orient: vertical;
279
+ -webkit-line-clamp: 3;
280
+ /* Up and down only. A hidden x would shave a glyph's overhang off a short title. */
281
+ overflow-x: visible;
282
+ overflow-y: clip;
268
283
  }
269
284
 
270
285
  @utility page-header-meta {
@@ -278,7 +293,7 @@ body.modal-open .studio-app-banner {
278
293
  @utility page-header-tabs {
279
294
  --page-header-fade-start: 0px;
280
295
  --page-header-fade-end: 0px;
281
- flex: 1 1 0%;
296
+ flex: 1 1 6rem;
282
297
  min-width: 0;
283
298
  overflow-x: auto;
284
299
  scrollbar-width: none;
@@ -1085,6 +1100,35 @@ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-n
1085
1100
  }
1086
1101
  }
1087
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
+
1088
1132
  /* THE SIDEBAR ROW'S DESCRIPTION (components/_link_sidebar, _geo_signpost). At rest
1089
1133
  a row with a description is one line, the height of any other row. On hover or
1090
1134
  keyboard focus the description fades in under the label, and no layout changes:
@@ -10,7 +10,7 @@
10
10
  // carries data-studio-controller-failed and engine.css gives the row its bar at
11
11
  // all times, so a stuck row never lays a transparent title over the page.
12
12
  import { Controller } from "@hotwired/stimulus"
13
- import { hiddenEnds, isStuck, revealBy } from "studio/page_header"
13
+ import { hiddenEnds, isStuck, revealBy, sentinelMarksThePlace } from "studio/page_header"
14
14
 
15
15
  export default class extends Controller {
16
16
  connect() {
@@ -42,7 +42,7 @@ export default class extends Controller {
42
42
  }
43
43
 
44
44
  sync() {
45
- const stuck = isStuck(this.row.getBoundingClientRect().top, this.element.getBoundingClientRect().top)
45
+ const stuck = isStuck(this.row.getBoundingClientRect().top, this.restTop())
46
46
  const ends = this.tabs ? hiddenEnds(this.tabs) : null
47
47
 
48
48
  this.row.toggleAttribute("data-stuck", stuck)
@@ -51,6 +51,20 @@ export default class extends Controller {
51
51
  this.tabs.toggleAttribute("data-fade-end", ends.end)
52
52
  }
53
53
 
54
+ // The top of the row's place in the flow. In a flex or grid parent only the row
55
+ // can say, so it is unstuck for one read. No frame paints in between, and the box
56
+ // keeps its size, so nothing moves and the stack republishes nothing.
57
+ restTop() {
58
+ if (sentinelMarksThePlace(getComputedStyle(this.element.parentElement).display)) {
59
+ return this.element.getBoundingClientRect().top
60
+ }
61
+
62
+ this.row.style.position = "static"
63
+ const top = this.row.getBoundingClientRect().top
64
+ this.row.style.position = ""
65
+ return top
66
+ }
67
+
54
68
  // Moves the tabs only: scrollIntoView would also move the page.
55
69
  revealOpenTab() {
56
70
  const open = this.tabs?.querySelector("[aria-current]")
@@ -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 {
@@ -150,6 +150,7 @@
150
150
  outside_action: close_action,
151
151
  escape_action: close_action,
152
152
  display_class: "hidden md:flex",
153
+ edge: true,
153
154
  role: "navigation",
154
155
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
155
156
 
@@ -164,5 +165,6 @@
164
165
  escape_action: close_action,
165
166
  display_class: "flex md:hidden",
166
167
  width_class: "w-full",
168
+ edge: true,
167
169
  role: "navigation",
168
170
  aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
@@ -19,13 +19,17 @@
19
19
  would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
20
20
  TIMELINE) moves it with the header on the scroll timeline instead.
21
21
 
22
- Two opt-in locals (unset, the panel renders as it did in 0.113.0):
22
+ Three opt-in locals (unset, the panel renders as it did in 0.113.0):
23
23
  title_html the header's LEAD SLOT: HTML in the title's place, for a panel
24
24
  whose lead is a live status line rather than a label. It is
25
25
  content, NOT a name: it never feeds aria-labelledby or
26
26
  aria-label, so a caller that passes it instead of `title` must
27
27
  pass aria_label, or the panel has no accessible name. `title`
28
28
  wins when both are passed. Escaped unless html_safe.
29
+ edge true stamps data-sidebar-edge, the soft blurred band along the
30
+ panel's inner edge, drawn by engine.css (THE SIDEBAR'S EDGE) from md
31
+ up. It softens the page beside the panel, so it suits a menu that
32
+ opens over the page, not a panel kept open beside live content.
29
33
  close_hook true stamps the close button data-sidebar-panel-close="<id>"
30
34
  too, so a host's own document handler can match the button
31
35
  to its panel by id. Needs `id`; without one it adds nothing.
@@ -49,6 +53,7 @@
49
53
  <% aria_label = local_assigns.fetch(:aria_label, title) %>
50
54
  <% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
51
55
  <% close_hook = local_assigns.fetch(:close_hook, false) && id.present? %>
56
+ <% edge = local_assigns.fetch(:edge, false) %>
52
57
 
53
58
  <aside
54
59
  <% if id.present? %>id="<%= id %>"<% end %>
@@ -76,6 +81,9 @@
76
81
  class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
77
82
  style="top:var(--nav-bottom, var(--nav-h, 6rem));height:calc(100% - var(--nav-bottom, var(--nav-h, 6rem)))"
78
83
  data-pin-edge
84
+ <% if edge %>
85
+ data-sidebar-edge
86
+ <% end %>
79
87
  role="<%= role %>"
80
88
  <% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
81
89
  <div class="flex flex-col h-full">
@@ -33,9 +33,11 @@
33
33
 
34
34
  THE SENTINEL stays at the row's place in the flow, and the row's distance from
35
35
  it is what "stuck" means, on every kind of header. It is absolutely positioned,
36
- so a space-y parent adds nothing for it. It carries the controller, so the row
37
- keeps its data-studio-controller for the page's own. So the row carries no top
38
- margin: the space above it belongs to its container. %>
36
+ so a space-y parent adds nothing for it. In a flex or grid parent it sits at the
37
+ parent's start, so the controller measures the row's place from the row. It
38
+ carries the controller, so the row keeps its data-studio-controller for the
39
+ page's own. So the row carries no top margin: the space above it belongs to its
40
+ container. %>
39
41
  <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
40
42
  <%= content_tag(tabs.any? ? :nav : :div,
41
43
  class: "page-header sticky #{row_class}".strip,
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.126.0"
2
+ VERSION = "0.127.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.126.0
4
+ version: 0.127.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-04 00:00:00.000000000 Z
11
+ date: 2026-11-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails