Skip to content

feat(appearance): optional pixel shift against image persistence - #337

Merged
jherforth merged 6 commits into
jherforth:mainfrom
adamecker:feature/display-pixel-shift
Oct 26, 2026
Merged

jherforth merged 6 commits into
jherforth:mainfrom
adamecker:feature/display-pixel-shift

Conversation

@adamecker

@adamecker adamecker commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Adds a Pixel shift toggle to the Display dimming card (household default or per-display override, off by default). When enabled, the dashboard drifts a few pixels on a slow cycle, so static elements never sit on the exact same pixels — protection against image persistence on always-on wall displays.

Values follow the industry optimum: at most 4px, every 4 minutes. Samsung's signage manuals list 0-4px horizontal/vertical and 1-4 minutes, with 4px / 4 min as the optimum; Philips shifts up to 8px every 80 seconds, the same order of magnitude.

Implementation notes:

  • The shift uses relative left/top offsets on the app root element, with a slow eased drift — never a transform, so the fixed dock, screensaver and theme scene stay viewport-anchored and keep ranking above MUI dialogs. It is folded into the existing 1-second idle timer, so no second timer is created. Horizontal overflow is hidden on while the shift is on, so positive-x steps cannot widen the page, and switching the setting off resets the offsets, the transition, the marker and the overflow clamp at once.
  • The dimming wake shield lives on outside the root element, so it stays viewport-fixed and tap-to-wake is unaffected.
  • The setting is part of the dimming object, so it follows the same household/override rules as the rest of the card, and it works independently of whether dimming itself is enabled.

Screenshot of the new setting (test instance, Look tab): attached in the comments below.

Testing:

  • Client suite: 791/791, build clean, English/Spanish locale keys in parity.
  • New displayDim.pixelShift tests cover the offset cycle (max 4px, deterministic wrap) and the gating: no shift before the 4-minute cadence elapses, shift applied at the cadence when enabled, no shift when disabled.
  • Live test-instance check: the dimming card renders the Pixel shift switch with its help text, in both scopes (shown here following the household).

Generated with Muse AI 🤖

adamecker added 2 commits October 25, 2026 13:34
Adds a Pixel shift toggle to the dimming card (household or per-display, off by default). When enabled, the dashboard drifts a few pixels on a slow cycle so static elements never sit on the exact same pixels, helping prevent image persistence on always-on wall displays. Values follow the industry optimum: at most 4px, every 4 minutes (Samsung signage manuals list 0-4px and 1-4 min, optimum 4/4; Philips shifts up to 8px every 80 seconds). Translate-only on the app root, folded into the existing idle timer, so there are no scrollbars and taps still land.
@mrramam

mrramam commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Nice small addition. I ran it locally (demo mode, 1600x1000, Playwright clock to skip ahead), with the switch off as the control.

  1. The first shift pushes the dock off the screen. Any transform on #root, even translate(0px, 0px), makes #root the containing block for its position: fixed descendants. body has min-height: 100vh plus padding-bottom: 80px, so #root is taller than the viewport (1140px here). Before the first shift the dock sits at 922 to 984 in a 1000px viewport. After 4 minutes it sits at 1065 to 1127, fully below the screen. Scrolled to the bottom, it moves up with the page, and the Demo Mode banner scrolls off the top. With the switch off, both stay put. The same applies to the other fixed layers inside #root (ScreenSaver, VacationScreensaver, AmbienceLayer, SaveLayoutButton, UpdateIndicator), though I didn't measure those. From the code, the transform also gives #root its own stacking context, so the dock's and the screensaver's zIndex: 9999 no longer rank above MUI dialogs portaled to body. PrizeCelebration and ChoreCelebration already portal out of #root for this reason. Shifting a wrapper that holds only the dashboard content would avoid all of it.

  2. Turning it off leaves the last offset in place. After switching it off, #root kept translate(-3px, 4px) and data-pixel-shift="on", so point 1 persists until a reload. The new test clears root.style.transform itself before checking the off case. updateDisplayDimming could reset transform, transition and data-pixel-shift when pixelShift is false.

  3. A horizontal scrollbar on positive-x steps. At translate(4px, 3px) the page measured 1604px wide in a 1600px viewport. Nothing sets overflow-x: hidden.

  4. The screenshot is committed to the repo (screenshots/pixel-shift-setting.png). It could be attached to the PR instead, as with feat(appearance): configurable display dimming #334.

Small: the test sets globalThis.document and globalThis.window and never restores them.

Review feedback (mrramam): a transform on #root made it the containing block for its position:fixed descendants, pushing the dock off screen and dropping fixed layers below MUI dialogs portaled to body. The shift now uses relative left/top offsets on #root, which leave every fixed layer viewport-anchored and create no stacking context. Switching the setting off now resets position, left, top, transition, the marker and the body overflow-x clamp at once (previously the last offset persisted until reload). Horizontal overflow is hidden on <body> while the shift is on, so positive-x steps cannot widen the page. The pixel-shift test no longer clears the offset itself before the off case, so it covers the reset, and it restores the document/window globals it stubs. The setting screenshot is no longer committed; it is attached to the PR instead.
@adamecker

adamecker commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for testing this so thoroughly — all five points addressed in c6c0594:

  1. Fixed layers no longer move. I didn't go with a transformed wrapper: a transformed wrapper would still capture the fixed theme scene rendered inside WidgetContainer. Instead the shift now uses relative left/top offsets on #root, which don't make it a containing block for fixed descendants and don't create a stacking context — so the dock, screensaver, theme scene, SaveLayoutButton and UpdateIndicator all stay viewport-anchored, and their z-index still ranks above MUI dialogs portaled to body. Verified at 1600×1000: the dock stays put under relative offsets, while the old transform moves it to 1065–1127, your exact numbers.

  2. Switching off resets everything. updateDisplayDimming now clears position, left, top, transition, the marker and the body overflow-x clamp at once when pixel shift is false. The test no longer clears the offset itself before the off case, so it now covers the reset instead of masking it.

  3. No horizontal scrollbar. Body overflow-x is hidden while the shift is on and cleared when it goes off.

  4. Screenshot out of the repo. Removed from the branch; attached to this comment instead.

Small: the test saves and restores the document/window globals it stubs in afterEach.

Tests: client 791/791, build clean.

Generated with Muse AI 🤖
pr337-pixel-shift-setting

adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 25, 2026
Port of the jherforth#337 review fix: a transform on #root would make it the containing block for its position:fixed descendants and push the dock off screen, while relative left/top offsets leave every fixed layer viewport-anchored. Positive-x steps hide horizontal overflow on <body> while the shift runs.
@jherforth

Copy link
Copy Markdown
Owner

Let's pick up the "Display dimming" section as a whole and move it to Admin Panel > Displays > This Display. Then break out the sections: Touch, expand widgets, dimming

@jherforth
jherforth self-requested a review October 26, 2026 13:27
@jherforth jherforth added the enhancement New feature or request label Oct 26, 2026
@jherforth jherforth added this to the 1.11 milestone Oct 26, 2026
Review feedback (jherforth, jherforth#337): pick up the Display dimming
section as a whole and move it to Admin Panel > Displays > This
Display, then break out the sections Touch, expand widgets,
dimming. The dimming section (DisplayDimmingSettings, including
the pixel-shift toggle) moves unchanged — it saves only its own
field through its own save button — from the Look tab to the end
of This Display, after that page's own save button. Touch,
expanding widgets, and dimming are now separated by dividers
instead of flowing as one list. Client 791/791, build clean.

Generated with Muse AI 🤖
@adamecker

Copy link
Copy Markdown
Contributor Author

Done in 7cc47a8 (pushed to this branch):

  • The Display dimming section moved as a whole from the Look tab to Admin Panel > Displays > This Display, after that page's own save button. It saves only its own field through its own save button, so it works unchanged in the new spot — and the pixel-shift toggle moves along with it, since it lives inside that section.
  • This Display's sections are now broken out with dividers: Touch, expanding widgets, then dimming (dock idle hide stays first, as-is).

Client 791/791, build clean.

Generated with Muse AI 🤖

jherforth and others added 2 commits October 26, 2026 11:07
… card

- The dock is position: fixed, so #root's relative offset doesn't move
  it, yet it's the most static thing on a wall display. The shift now
  also sets --pixel-shift-x/-y on <html>, which the dock adds to its own
  transform, and turning the shift off removes them.
- Pixel shift moves to the end of the dimming card. It sat between "Dim
  the display when idle" and "Idle seconds before dimming", splitting
  two settings that belong together, and it works without dimming.
- Its help text says so: "Works whether or not dimming is on."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jherforth

Copy link
Copy Markdown
Owner

Thanks. This addressed mrramam's points, and the move to This Display reads well. In Edge, with the clock jumped past 4 minutes, #root shifted (4, 3), the dock stayed on screen, there was no horizontal scrollbar, and a sideways scroll moved nothing. Turning it off put everything back exactly. I added two commits before merging:

  • e7f4295: merged main, cleanly.
  • 1f28db7:
    • The dock moves too. It's position: fixed, so #root's offset didn't reach it, yet it's the most static thing on a wall display. The shift now also sets --pixel-shift-x/-y on <html>, the dock adds them to its transform, and turning it off removes them. In Edge it moves (4, 3) with the page.
    • Pixel shift sits last in the dimming card. It was between "Dim the display when idle" and "Idle seconds before dimming", and it works without dimming. The help text now says so.

Client tests and CI pass.

@jherforth
jherforth merged commit 9d4732c into jherforth:main Oct 26, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Oct 26, 2026
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 26, 2026
# Conflicts:
#	client/src/utils/displayDim.js
#	client/src/utils/displayDim.pixelShift.test.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants