Skip to content

Fix: operation pill no longer overlaps UI at high zoom / on phones (#216) - #218

Open
DeLo1585 wants to merge 9 commits into
StudioNirin:mainfrom
DeLo1585:fix/pill-layout-216
Open

DeLo1585 wants to merge 9 commits into
StudioNirin:mainfrom
DeLo1585:fix/pill-layout-216

Conversation

@DeLo1585

@DeLo1585 DeLo1585 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #216

Problem

#global-operation-banner was position: absolute (z-index 9999) and centred on the page, independent of the .page-header flex row. At higher zoom, in narrow windows or while a job is running it covered the title or the Run Now button, and on phones it scrolled over the fixed mobile header and the open sidebar (hamburger, theme toggle and the first menu entry became unclickable).

Change

  • The pill keeps its Dynamic-Island position (absolute, centred, expands in place) whenever there is room.
  • base.html: a small fit check compares the banner's rect with the page title, the action buttons and the theme toggle. On a collision it adds .di-flow, which turns the banner into a full-width row of the page header, ordered after the buttons. It is measured with the class removed each time, so the pill moves back up as soon as there is room. Re-checked on ResizeObserver (header), MutationObserver (banner content incl. diTriggerRun() innerHTML, data-di-expanded after the collapse animation) and document.fonts.ready. Small hysteresis (16px in, 40px out) avoids flip-flopping at the edge.
  • custom.css: banner z-index 9999 -> 100 (below theme toggle 101, sidebar 160, mobile header 200). In fit mode the expanded pill stays an overlay, so hovering does not reflow the page. In fit mode the light "Updated / Refresh" row on Dashboard/Maintenance gets a slightly tighter gap below the pill.

Testing

  • Desktop browser at several zoom levels (sidebar open and collapsed) and on a phone: no overlap, hamburger / theme toggle / first menu entry stay clickable, nothing shifts on hover. [adjust: list your phone browser(s)]
  • Headless Chromium on all page headers (Dashboard, Cached Files, Storage, Priorities, Recently Added, Settings, Logs, Maintenance), 390 to 1900px, sidebar open and collapsed: no overlap, no horizontal scrolling (112 checks). Window resize without reload moves the pill down and back up; replacing the banner content via innerHTML re-runs the check; no layout shift on hover in fit mode.
  • Not tested: Safari / iOS.

Notes

  • Prepared with Claude Sonnet 5.5.
  • Switch points on the Dashboard (idle pill): about 1380px with the sidebar open, about 1240px collapsed. While a job is running the pill is wider and drops below the buttons at any width. Other pages keep the pill on top longer.
Screenshot 2026-10-01 000441 Screenshot 2026-10-01 000422 Screenshot 2026-10-01 000346 Screenshot 2026-10-01 000333

Fix: embed operation pill in page header so it no longer overlaps UI (StudioNirin#216)

The banner was absolutely positioned and independent of the header flex
row, so it covered buttons at higher zoom and the mobile header/sidebar
on phones. It is now a flex item of .page-header (moved by a small script
in base.html), wraps below the title/buttons row at <=1400px and keeps
the expanded pill as an overlay with a lower z-index.
Fix: embed operation pill in page header so it no longer overlaps UI (StudioNirin#216)

The banner was absolutely positioned and independent of the header flex
row, so it covered buttons at higher zoom and the mobile header/sidebar
on phones. It is now a flex item of .page-header (moved by a small script
in base.html), wraps below the title/buttons row at <=1400px and keeps
the expanded pill as an overlay with a lower z-index.
@DeLo1585

DeLo1585 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

@StudioNirin @Brandon-Haney

it would be a pleasure to put your eyes on this. I'm running exactly these two files (custom.css + base.html) in my container on top of the #215 state and they behave as shown in the screenshots.

Things I'd especially like a second opinion on:

  • Moving the banner into .page-header with a small script in base.html (vs. an include in each page template).
  • The 1400px breakpoint where the pill drops below the title/buttons row (measured against the Dashboard header, the widest one).
  • z-index 100 for the banner, so it stays below the theme toggle, sidebar and mobile header.

Happy to change anything you'd prefer done differently. Thanks!

@Brandon-Haney

Copy link
Copy Markdown
Collaborator

Thanks for this, the root cause analysis is spot on. Dropping the z-index below the mobile header and sidebar is the right fix for the phone issue, so I'd keep that as is.

For context on the design: the pill is meant to work like a Dynamic Island. It sits at the top of the page, centred, stays compact, and expands in place when you hover or click it. So ideally it keeps that position whenever there's room, and only moves when it would actually collide with something.

That's why I'd suggest a different approach to the 1400px breakpoint. It kicks in much earlier than needed. At 1280px on the Dashboard there's around 300px free between the title and the buttons, but the pill still drops to its own row. And since it's measured against one page, it can't account for other titles, button counts or a collapsed sidebar.

What I have in mind:

  • Keep the banner absolutely positioned and centred like it is on main.
  • Add a small fit check in your base.html script that compares the pill's rect against the page title and the action buttons. If they overlap, add a class that puts the pill in the header flow below the buttons (ordered after them, full width). If not, remove it.
  • Re-run the check when the header resizes (ResizeObserver), when the banner content changes (MutationObserver on the banner, since diTriggerRun() sets innerHTML directly and won't fire htmx:afterSwap), and once document.fonts.ready resolves.
  • Measure with the class removed each time, so it moves back up as soon as there's room again.

With that, the .stats-header margin tweak shouldn't be needed either, and the wide-screen layout stays exactly as it is now while the broken cases get fixed.

That said, I'm open to other approaches if you find something that works better on your setup. Once it's updated I'll pull the branch and try it myself, and we can go with whatever works best for everyone.

Fix: fit check moves operation pill below header only on collision (StudioNirin#216)

The pill stays centred like before. A small fit check in base.html compares
it with the page title, action buttons and theme toggle and adds .di-flow on
a collision, which turns the banner into a full-width row of the page header
after the buttons. Re-checked on header resize, banner content changes and
font load. Banner z-index lowered to 100 so it stays below the mobile header
and sidebar.
Fix: fit check moves operation pill below header only on collision (StudioNirin#216)

The pill stays centred like before. A small fit check in base.html compares
it with the page title, action buttons and theme toggle and adds .di-flow on
a collision, which turns the banner into a full-width row of the page header
after the buttons. Re-checked on header resize, banner content changes and
font load. Banner z-index lowered to 100 so it stays below the mobile header
and sidebar.
Fix: fit check moves operation pill below header only on collision (StudioNirin#216)

The pill stays centred like before. A small fit check in base.html compares
it with the page title, action buttons and theme toggle and adds .di-flow on
a collision, which turns the banner into a full-width row of the page header
after the buttons. Re-checked on header resize, banner content changes and
font load. Banner z-index lowered to 100 so it stays below the mobile header
and sidebar.
Fix: fit check moves operation pill below header only on collision (StudioNirin#216)

The pill stays centred like before. A small fit check in base.html compares
it with the page title, action buttons and theme toggle and adds .di-flow on
a collision, which turns the banner into a full-width row of the page header
after the buttons. Re-checked on header resize, banner content changes and
font load. Banner z-index lowered to 100 so it stays below the mobile header
and sidebar.
@DeLo1585

DeLo1585 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks a lot @Brandon-Haney , that makes sense. I reworked it along your lines and pushed the update to the branch.

What changed:

  • The banner stays absolutely positioned and centred like on main (z-index lowered to 100 as before).
  • The script in base.html now does the fit check you described: it compares the banner's rect with the page title, the action buttons and the theme toggle. If they collide, the banner gets .di-flow and becomes a full-width row of the header, ordered after the buttons. It is measured with the class removed each time, so it moves back up as soon as there is room again.
  • Re-checked on ResizeObserver (header), MutationObserver (banner content, so diTriggerRun()'s innerHTML is covered, plus the data-di-expanded change, re-checked after the collapse animation) and document.fonts.ready. Content swaps are handled synchronously, so the pill doesn't flash in the wrong place on load.
  • The fixed 1400px breakpoint is gone.

Two small things I added on purpose:

  • A bit of hysteresis (16px to enter fit mode, 40px to leave it) so the pill doesn't flip back and forth at the edge, for example when the page height changes and a scrollbar appears or disappears.
  • In fit mode the expanded pill stays an overlay inside the row, so hovering never pushes the page content around.

Results from my tests (headless Chromium, all page headers, sidebar open and collapsed, 390 to 2336px, plus a phone): no overlap and no horizontal scrolling in 112 checks, and the pill moves back up when the window is widened again without a reload. Note that on the Dashboard the centred pill still collides with the buttons earlier than the free gap suggests, because it is centred on the page and not in the gap: it switches at about 1380px with the sidebar open and about 1240px collapsed. The other pages keep the pill on top much longer (Settings, Logs, Cached Files and Storage stay up at 1280px). While a job is running the pill is wider (about 360px), so on the Dashboard it drops below the buttons at any width.

Regarding the .stats-header margin tweak: I kept a small version of it, but only in fit mode (.content-wrapper:has(#global-operation-banner.di-flow) .stats-header), because the light Updated/Refresh row looked a bit loose under the pill compared with the card pages. Happy to drop it if you prefer.

Brandon-Haney added a commit to Brandon-Haney/PlexCache-R that referenced this pull request Oct 2, 2026
@Brandon-Haney

Copy link
Copy Markdown
Collaborator

Thanks for the update, I pulled it and it does what we discussed. I couldn't get the pill to overlap anything at any width, idle or running. One small thing: the MutationObserver has subtree: true, so it re-measures on every change inside the pill. Watching the banner's direct children should be enough.

Your note about the Dashboard colliding early got me looking at that header, and I'd like both your opinions on a follow-up before I build it. The Verbose / Run Now / Dry Run controls there duplicate what the pill already has, and the header copy has a few issues of its own: the buttons only get disabled when the page loads, so they go stale during or after a run; Dry Run looks clickable when it's disabled; and the header Verbose toggle saves to a different setting than the pill's checkbox, so the two Run Now buttons can run with different verbosity.

What I have in mind: remove those three controls from the Dashboard header, and give the idle pill a small play button so Run Now is still one click (Next run 32m ▶). Hovering the play button keeps the pill compact so it can be clicked; hovering or tapping the rest of the pill opens it with Verbose, Run Now and Dry Run like it does now. The pill would also become keyboard accessible. In my mockup that keeps the pill on top of the Dashboard down to about 1100px when idle and 1280px when running, so the page no longer jumps when a run starts.

Mockups, built on top of this branch:

Dashboard header today vs mockup, dark theme

Light theme, Cached Files page and 900px

Phone: idle, tapped open, running, light

The trade-off is that Run Now becomes an icon rather than a labelled button, and Dry Run needs one more click. @StudioNirin would you be OK with that change? If so I'd do it as a separate PR after this one merges, so it doesn't hold this up. @DeLo1585 curious what you think too, since you've been living with the header at high zoom.

@StudioNirin

Copy link
Copy Markdown
Owner

@Brandon-Haney

Seems logical and fine to me, my one question might be - would there not be room the simple fit the word "run" next to it still? Seems like enough information for users and shorter than "run now". It just looked like there might be space for it. Solves both problems.

@DeLo1585

DeLo1585 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Brandon-Haney, thanks for pulling and testing it!

I really like your idea. Taking the three controls out of the header and giving the pill a single button looks cleaner, more intuitive and more modern, and it fixes the stale buttons and the two different Verbose settings.

Icon or "Run" label doesn't matter to me, the operation is the same, so I'm happy with whatever @StudioNirin prefers.

I've used Dry Run exactly once, right after the first setup, so one extra click is fine. High zoom and phone are where I noticed all of this in the first place, so it would be a nice improvement for me.

You're right about the observer, watching the direct children should be enough. I had Sonnet 5.5 look at it in case it helps: the banner content is swapped via htmx every 2 to 10 seconds and by diTriggerRun(), which direct children plus data-di-expanded cover. With subtree: true it also re-measures on the in-place text updates and the per-second countdown, which isn't needed. The only edge case is a text change making the compact pill a few pixels wider, and a ResizeObserver on the banner would catch that. I can switch it in this PR if you like.

A few small thoughts on the header idea, just as input (Sonnet 5.5 looked at this part too):

  • On the phone there is no hover, but I don't think that is a problem: tapping the button starts the run, tapping anywhere else on the pill opens it, and Verbose can be toggled in the opened state. A "Run" label would be slightly easier to hit than a small play icon; either way a comfortable touch target and a tooltip / aria-label for the icon case would be nice.

  • The current header copy is rendered server-side, so "disabled" is only evaluated at page load, which matches the stale state you describe. The header Verbose toggle also uses a different localStorage key (plexcache_dashboard_verbose) than the pill (verbose_mode), so the two Run Now buttons can indeed start with different verbosity. With the header controls gone, using only the pill's key would resolve that, and the run button would use whatever was last set in the opened pill.

Really looking forward to it, thanks again!

Brandon-Haney added a commit to Brandon-Haney/PlexCache-R that referenced this pull request Oct 2, 2026
@Brandon-Haney

Copy link
Copy Markdown
Collaborator

@StudioNirin good call, it fits. I tried ▶ Run as a small labelled button in the pill: it adds about 35px, the idle pill still stays on top of the Dashboard down to roughly 1060px, and the running pill isn't affected since it doesn't have the button. It's clearer than a bare icon and a bigger target on touch screens, so I'll go with that.

Icon-only vs labelled Run button, dark, light and phone

@DeLo1585 yes please, switch the observer to direct children plus data-di-expanded in this PR. I'd skip the extra ResizeObserver on the banner: the fit check already keeps a 16px margin before it counts as a collision, so a few pixels from the countdown text can't cause an overlap, and the pill re-renders every few seconds anyway, which re-runs the check.

Once that's in, I'm happy for this to merge, and I'll open the header follow-up as a separate PR on top of it.

Brandon-Haney added a commit to Brandon-Haney/PlexCache-R that referenced this pull request Oct 2, 2026
Watch the banner's direct children plus data-di-expanded instead of the whole
subtree. The pill is replaced via htmx / diTriggerRun(), which changes the
direct children, so in-place text updates and the countdown no longer trigger
a re-measure.
@DeLo1585

DeLo1585 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Brandon-Haney,

the observer now only watches the banner's direct children plus data-di-expanded (86d362f), and I left out the extra ResizeObserver as you suggested. Resizing, expanding/collapsing and the pill being swapped behave as before (headless check with Sonnet 5.5, and in my container). In that check the fit check no longer re-measures on in-place text updates in the pill (0 instead of 6 for five updates). Thanks for the quick review, looking forward to your follow-up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Next-run pill overlaps UI elements at browser zoom above 80% / in smartphone view

3 participants