From 273bbcf9279f6228219325cd2726371c1a2fc852 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 03:04:55 +0000 Subject: [PATCH] Fix scroll jank, which got worse the bigger the window Two CSS effects were repainting the whole viewport on every scroll frame, on a CPU: the app disables hardware acceleration on purpose, since it shares a machine and a GPU with OBS and a game. Cost scales with area, so the bigger the window the worse it got -- exactly the complaint. Measured on the settings page at 2560x1440 with software rasterisation, scrolling 135 frames: as shipped p95 54ms max 221ms 80/135 frames dropped without the backdrop blur p95 31ms max 45ms without the fixed bg p95 52ms max 96ms without both p95 17ms max 17ms Neither alone was enough, which is why this reads as "just slow" rather than as one bad effect. `backdrop-filter: blur(14px)` on every .card meant re-reading and re-blurring the backdrop every frame the card moved, for an effect that is nearly invisible over a dark, low-contrast gradient. The card fill carries the panel instead (--glass-card). The deck header's blur goes too: the feed scrolls in its own container below it, so there was never anything moving behind it to blur. `background-attachment: fixed` cannot be scrolled as a layer, so the gradient was recomputed across the viewport every frame. It moves to its own fixed layer, which looks identical and paints once. That layer is opt-in (``) rather than automatic, and the direction matters: the overlay is a transparent guest on someone else's live stream, and its `background: transparent !important` would not have saved it -- a pseudo-element is not the body's background. A page that forgets the class looks flat; one that inherited it by default could paint over a broadcast. Verified the overlay has no such layer and still composites transparent. Deck feed and settings now hold a 17ms frame at 2560x1440. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LWa54BF5RjHWC5GFaENZMM --- DESIGN.md | 13 ++++++++++++- public/dashboard.html | 6 ++++-- public/settings.html | 2 +- public/setup.html | 2 +- public/theme.css | 34 +++++++++++++++++++++++++++++----- 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 1925b63..d86f1f7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -72,10 +72,21 @@ stage presence, and the streamer is the director. The visuals moved with the met 12. **Nothing in the app is only a color.** Status carries a label as well as a hue (the orbs keep their text at every width), because a row of unlabeled dots is decoration, not status. +13. **Paint is not free.** The app runs with hardware acceleration off on purpose — it shares + a machine, and a GPU, with OBS and a game — so every pixel is rasterised on the CPU and the + cost grows with the window. Effects that force a repaint of a large area on every frame are + therefore banned on anything that scrolls or sits over something that scrolls: + **no `backdrop-filter`** on cards, feeds or bars, and **no `background-attachment: fixed`**. + A non-scrolling background belongs on its own fixed layer (`.studio::before`). Blur is + allowed only on a transient overlay that appears above a still page, like the command + palette. This is not a micro-optimisation: at fullscreen these two rules were the difference + between a 17ms frame and a 220ms one. + ## Tokens ([public/theme.css](public/theme.css)) - **Surfaces:** `--bg` `--bg-2` (base gradient) · `--panel` (solid) · `--glass` `--glass-2` - (translucent fills) · `--border` `--border-strong`. + (translucent fills) · `--glass-card` (card fill; carries the panel on its own, since nothing + blurs behind it) · `--border` `--border-strong`. - **Text:** `--text` `--dim` `--faint`. - **Accent (the human):** `--accent` `--accent-2` `--on-accent` `--accent-soft` `--accent-line`. Themeable at runtime (violet/cyan/emerald/amber/magenta) by overriding these diff --git a/public/dashboard.html b/public/dashboard.html index 59236fa..d80bf7d 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -16,7 +16,9 @@ header { display: flex; align-items: center; gap: 14px; flex: none; padding: 10px 18px; border-bottom: 1px solid var(--border); - background: rgba(10,10,16,.5); backdrop-filter: blur(12px); + /* No blur: the feed scrolls in its own container below this bar, so there + is never anything moving behind it to blur — only cost. */ + background: rgba(10,10,16,.72); flex-wrap: wrap; row-gap: 8px; } header .brand { display: flex; align-items: center; gap: 8px; padding-right: 14px; border-right: 1px solid var(--border); } @@ -226,7 +228,7 @@ #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } - +
👻Hype Ghost
00:00:00
diff --git a/public/settings.html b/public/settings.html index c741634..f2a28bf 100644 --- a/public/settings.html +++ b/public/settings.html @@ -101,7 +101,7 @@ } - +

⚙ Settings

diff --git a/public/setup.html b/public/setup.html index eeb9b3e..425b9e4 100644 --- a/public/setup.html +++ b/public/setup.html @@ -88,7 +88,7 @@ #energyName { font-weight: 700; font-size: var(--fs-lg); } - +
👻 Hype Ghost 3.0
diff --git a/public/theme.css b/public/theme.css index 103fdda..ac27585 100644 --- a/public/theme.css +++ b/public/theme.css @@ -15,6 +15,7 @@ --panel: #14141d; /* solid card fallback */ --glass: rgba(28, 28, 40, 0.62); /* translucent card fill */ --glass-2: rgba(20, 20, 30, 0.5); /* recessed wells */ + --glass-card: rgba(26, 26, 37, 0.88); /* card fill: opaque enough to read as a panel with no blur behind it */ --border: rgba(255, 255, 255, 0.09); --border-strong: rgba(255, 255, 255, 0.16); @@ -70,12 +71,33 @@ body { margin: 0; color: var(--text); font: var(--fs-base)/1.55 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +/* The studio gradient, on its own fixed layer. + It used to be a background-attachment: fixed on , which looks the same + and scrolls appallingly: a fixed attachment cannot be scrolled as a layer, so + the browser repaints the whole viewport's gradient every frame. The app runs + with hardware acceleration off on purpose (it shares a GPU with OBS and a + game), so that repaint is on the CPU, and its cost grows with the window — + which is why it was worst exactly where it hurts, fullscreen. + + Opt-in rather than automatic, and that direction is deliberate: the overlay + is a transparent guest on someone's live stream, and a page that forgets + this class merely looks flat, where an automatic layer would paint over the + broadcast. `background: transparent` on would not save it — a + pseudo-element is not the body's background. */ +.studio::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; background: radial-gradient(1100px 620px at 82% -8%, rgba(167, 139, 250, 0.12), transparent 60%), radial-gradient(900px 560px at 6% 108%, rgba(90, 209, 255, 0.09), transparent 55%), linear-gradient(180deg, var(--bg-2), var(--bg)); - background-attachment: fixed; - -webkit-font-smoothing: antialiased; } /* ---------- typography ---------- */ @@ -146,9 +168,11 @@ input:focus:not(:focus-visible), textarea:focus:not(:focus-visible), select:focu /* ---------- components ---------- */ .card { - background: var(--glass); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); + /* No backdrop blur. Blurring a backdrop means re-reading and re-blurring it + every frame the card moves, and on a CPU rasteriser that was the single + biggest cause of scroll jank — for an effect that is nearly invisible over + a dark, low-contrast gradient. The fill carries the panel instead. */ + background: var(--glass-card); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 20px 24px;