From 9adeab4735e859f50d659d565dbe724d25dd35d8 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 22:22:34 +0200 Subject: [PATCH 1/4] fix: style button_to action items like the anchor ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rails renders button_to as form > input[type=submit] (or button), which the action-item button selectors missed — consumer apps re-hardcoded the 6px/10px padding to compensate, desyncing from $skinTitleBarButtonPaddingY/X on every change and leaving two button heights in one bar whenever the knobs are overridden. Cover the form-rendered submits in the same rule. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 1d266bc..b9fc42f 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -738,11 +738,17 @@ body.active_admin { // Space multiple action items apart (e.g. a Menu dropdown next to a New // button); the base leaves them flush on this side. .action_items > .action_item:not(:last-child):not(:empty) { margin-right: 10px; } + // The form selectors cover button_to action items: Rails renders them as + // form > input[type=submit] (or button), which the anchor selectors + // miss — consumer apps were re-hardcoding the 6px/10px padding to + // compensate, desyncing from the $skin* knobs on every change. // `> a` and `> .dropdown_menu > a`: an action item may hold a dropdown, and // a descendant selector here paints its menu entries with the button fill // too — with !important, so the entry rules below cannot undo it. .action_item > a, - .action_item > .dropdown_menu > a { + .action_item > .dropdown_menu > a, + .action_item > form > input[type="submit"], + .action_item > form > button { background-image: none; border: none; box-shadow: none; From 27cfb9a3e93fcb3096eef2c777ca2cef1e557287 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 22:23:33 +0200 Subject: [PATCH 2/4] perf: write --scrollbar-width on the bars, not on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom property changed on the root invalidates style for the entire document — one full recalc of a long index table per write — while only the three pinned bars consume the var. Write it on #header/#title_bar/ .flashes instead (idempotent per-element, re-applied on observer deliveries and turbo loads so late-parsed and swapped-in bars get the cached value), and drop the resize listener: the documentElement ResizeObserver already covers resizes post-layout, whereas the listener's clientWidth read forced a synchronous layout per event during a window drag. The listener plus eager read remain as the no-ResizeObserver fallback. Legacy consumer scripts that set the var on keep working through inheritance. --- .../javascripts/wigu/scrollbar_width.js | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js index b55af91..74bb762 100644 --- a/app/assets/javascripts/wigu/scrollbar_width.js +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -1,42 +1,72 @@ // Expose the vertical scrollbar width as a CSS custom property -// (--scrollbar-width) on . The sticky #header and #title_bar are pinned to -// the viewport's left edge and sized with 100vw, but 100vw INCLUDES the vertical -// scrollbar gutter — so when a page has a vertical scrollbar the bars are ~15px -// wider than the visible viewport and, clamped inside their full-content-width -// containing block, get pushed left by that amount at the far-right horizontal -// scroll position. Subtracting this variable (0 when there is no scrollbar) sizes -// them to the visible viewport instead, so they stay put. Optional: the CSS falls -// back to `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. +// (--scrollbar-width) on the pinned bars. The sticky #header, #title_bar and +// .flashes are pinned to the viewport's left edge and sized with 100vw, but +// 100vw INCLUDES the vertical scrollbar gutter — so when a page has a vertical +// scrollbar the bars are ~15px wider than the visible viewport and, clamped +// inside their full-content-width containing block, get pushed left by that +// amount at the far-right horizontal scroll position. Subtracting this +// variable (0 when there is no scrollbar) sizes them to the visible viewport +// instead, so they stay put. Optional: the CSS falls back to +// `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. +// +// The value is written on the three consuming bars, NOT on : a custom +// property changed on the root invalidates style for the entire document — +// one full recalc of a 500-row index table per write — while var() resolves +// from the element itself just as well, confining invalidation to three tiny +// subtrees. // // The property name is deliberately the bare --scrollbar-width, not --aa-*: -// consumer apps that already set the same variable with the same semantics from -// their own legacy script keep working before their manifest is updated, and -// the theme's CSS picks up either writer. The trade-off is a shared name — an -// app using --scrollbar-width for something else will collide with this -// script's inline writes on ; load one writer, not both. +// consumer apps that already set the same variable with the same semantics +// from their own legacy script keep working before their manifest is updated +// (a root-level legacy value still reaches the bars through inheritance when +// this script is absent). The trade-off is a shared name — load one writer, +// not both. (function () { - var last; + "use strict"; + + var BARS = "#header, #title_bar, .flashes"; + var last = null; + + // Idempotent and cheap: reads the inline style (no layout) and writes only + // real changes, so it is safe to call on every observer delivery — which is + // what picks up bars parsed after the value was first computed, and bars + // replaced by a Turbo/Turbolinks body swap. + function apply() { + if (last === null) return; + var val = last + "px"; + var bars = document.querySelectorAll(BARS); + for (var i = 0; i < bars.length; i++) { + if (bars[i].style.getPropertyValue("--scrollbar-width") !== val) { + bars[i].style.setProperty("--scrollbar-width", val); + } + } + } + function update() { var w = window.innerWidth - document.documentElement.clientWidth; if (w < 0) w = 0; - // The value only changes when the scrollbar toggles, but the callers fire - // constantly (resize + ResizeObserver both hit during a drag-resize, and - // every content growth pings the observer). Skip same-value writes: the - // property feeds width: calc(...) of the pinned bars, and redundant inline - // style writes on are restyle churn. - if (w === last) return; last = w; - document.documentElement.style.setProperty("--scrollbar-width", w + "px"); + apply(); } - window.addEventListener("resize", update); - // The presence of a vertical scrollbar can change after load (content grows/ - // shrinks) without firing resize. Observe the root element, not : - // documentElement exists at parse time (no DOMContentLoaded gate, so - // deferred/async loading works) and survives Turbo/Turbolinks body swaps, - // where a body observer would keep watching a detached node. Content growth - // resizes the root's client box too — exactly when the scrollbar toggles. + if (window.ResizeObserver) { + // The observer alone is enough: it delivers once right after observe() + // (post-layout, pre-paint, so the read forces nothing) and again whenever + // the root's client box changes — which covers window resizes AND content + // growth toggling the scrollbar. A resize listener on top of it would + // force a synchronous style+layout per event during a window drag (the + // clientWidth read lands before the frame's layout) for no extra + // information. documentElement exists at parse time and survives body + // swaps, so there is no DOMContentLoaded gate and nothing goes stale. new ResizeObserver(update).observe(document.documentElement); + } else { + // No ResizeObserver: fall back to resize events plus one eager read. + window.addEventListener("resize", update); + update(); } - update(); + // A body swap replaces the bars the value was written on without + // necessarily resizing the root; re-apply the cached value to the fresh + // elements. + document.addEventListener("turbo:load", apply); + document.addEventListener("turbolinks:load", apply); })(); From 3f221bcb99968fefa07c639f3a3b2c419496ceaa Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 22:24:25 +0200 Subject: [PATCH 3/4] fix: right-align title-bar dropdowns, guarding an AA offset bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AA 3.x's dropdown _position() sets inline left OR right per open and never clears the other; the wrapper persists across opens, so after a window resize between opens both offsets accumulate and the width:auto wrapper stretches from the stale left coordinate to the bar's right edge. Consumer apps carried exactly this left/right !important pair as a local hotfix and dropped it when adopting the theme-owned bar — own the guard where the bar is owned. Right alignment is also simply correct for items sitting at the bar's right edge. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index b9fc42f..4f4784e 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -765,6 +765,18 @@ body.active_admin { } div.dropdown_menu { div.dropdown_menu_list_wrapper { + // Always right-align title-bar dropdowns, overriding AA's inline + // positioning. AA 3.x's _position() sets inline `left` OR `right` + // per open depending on a window-geometry branch and never clears + // the other, and the wrapper persists across opens — after a resize + // between opens both offsets accumulate and the width:auto wrapper + // stretches from the stale left coordinate to the right edge. + // Title-bar action items sit at the bar's right edge, so pinning + // right is also simply the correct alignment. Consumer apps used to + // carry exactly this pair as a local hotfix; the theme owns the bar + // now, so it owns the guard. + left: auto !important; + right: 0 !important; background-color: var(--aa-elevated); border: 1px solid var(--aa-border); background-image: none; From e9d374cfd06d32295514edcafb8f538f0fbc3ba0 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 22:25:21 +0200 Subject: [PATCH 4/4] feat: add a wigu/all umbrella Sprockets require Consumers hand-maintain the four-script require list, and this round of adoptions showed the trap: theme CSS now depends on a JS-set property, so a missed require degrades visually with no error. Ship a one-line umbrella; each script stays individually requirable. --- README.md | 7 ++++--- app/assets/javascripts/wigu/all.js | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/wigu/all.js diff --git a/README.md b/README.md index b0cd218..d0ba999 100644 --- a/README.md +++ b/README.md @@ -261,9 +261,7 @@ import "@activeadmin-plugins/active_admin_theme/src/scrollbar_width"; ``` Without the script the CSS falls back to plain `100vw` — nothing breaks, you -just keep the small far-right shift. If your app already sets a -`--scrollbar-width` variable from its own script, the theme picks it up; load -one writer, not both. +just keep the small far-right shift. Index-table column headers also stick to the viewport top while a long listing scrolls vertically. This is on by default for ActiveAdmin index tables. AA 3.x @@ -278,6 +276,9 @@ For tables whose markup you render yourself, `skip-sticky-headers` on an `index_table` opts out, and `with-sticky-headers` opts any hand-built table in (single-row theads: only the first header row pins). +`//= require wigu/all` pulls all four shipped scripts at once; each stays +individually requirable. + ### Trix / ActionText editor Trix ships with ActionText (a Rails built-in), so any ActiveAdmin app can have a diff --git a/app/assets/javascripts/wigu/all.js b/app/assets/javascripts/wigu/all.js new file mode 100644 index 0000000..d613862 --- /dev/null +++ b/app/assets/javascripts/wigu/all.js @@ -0,0 +1,10 @@ +// Umbrella require for every script the theme ships, so a Sprockets manifest +// carries one line instead of a hand-maintained list that silently drifts: +// theme CSS can depend on a JS-set property (scrollbar_width feeds the pinned +// bars' width), and a missed require degrades visually with no error. Each +// script stays individually requirable for apps that want a subset; see the +// README for what each one does and how to opt out. +//= require wigu/theme_toggle +//= require wigu/server_clock +//= require wigu/scrollbar_width +//= require wigu/table_crisscross