Skip to content

Fix profile button covered by native title bar buttons on Windows#2569

Open
imnasnainaec wants to merge 8 commits into
mainfrom
fix/title-bar-profile-button-overlap
Open

Fix profile button covered by native title bar buttons on Windows#2569
imnasnainaec wants to merge 8 commits into
mainfrom
fix/title-bar-profile-button-overlap

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

On Windows, the toolbar's UserProfilePopover trigger can render underneath the OS's native minimize/maximize/close buttons. The toolbar reserves space for those buttons (drawn by Chromium as a true overlay via Electron's titleBarOverlay, see main.ts) using a hardcoded guess of 138px + 1rem. That guess only holds at 100% DPI with the standard three-button cluster — under display scaling or OS text-size settings the real reserved width can be larger, so the profile button ends up painted over by the native close button.

Before:
Screenshot 2026-07-16 113829

Replaces the fixed guess with a live measurement from the standard Window Controls Overlay API (navigator.windowControlsOverlay.getTitlebarAreaRect()), which Chromium keeps accurate across DPI/theme changes and updates via a geometrychange event. macOS's fixed-width traffic-lights padding is untouched.

After:
Screenshot 2026-07-17 123959

Changes

  • src/renderer/window-controls-overlay.d.ts (new) — ambient types for the Window Controls Overlay API (not yet in TS's DOM lib).
  • src/renderer/hooks/use-window-controls-overlay.hook.ts (new) — reads the live titlebar rect and updates on geometrychange.
  • src/renderer/components/platform-bible-toolbar.tsx — wraps <Toolbar> in a local <div> carrying the live-measured paddingInlineEnd plus a small tuned breathing-room margin (RESERVED_SPACE_BREATHING_ROOM_PX = 4). Suppresses Toolbar's own end-side border/padding (tw:border-e-0 tw:pe-0) when the wrapper is active, so reserved space isn't applied twice. lib/platform-bible-react is untouched.
  • Tests: new hook unit tests, extended platform-bible-toolbar.test.tsx coverage, and a new Windows-gated e2e spec (e2e-tests/tests/isolated/title-bar/) asserting the profile button's bounding box stays inside the live overlay rect and that clicking it actually opens the popover.

AI Involvement

Diagnosed and implemented with Claude Code (Sonnet 5): traced the bug to the hardcoded 138px reservation, implemented the navigator.windowControlsOverlay-based fix, and wrote the accompanying unit/e2e tests.

Testing

  • tsc clean on the main app and platform-bible-react
  • Unit tests pass (new hook tests, new/updated toolbar tests)
  • ESLint clean on all touched/new files
  • New e2e spec (title-bar-reserved-space.spec.ts) run against a live Windows instance — needs CDP connection to a running app
  • Manual visual verification on Windows at non-100% DPI scaling
  • Manual visual verification on macOS/Linux that nothing has changed

Risk Level

Low — isolated to toolbar layout on Windows; falls back to the previous static class whenever the new API is unavailable or not yet resolved, so behavior on macOS/Linux and non-Electron test environments is unchanged.

Co-authored-by: Claude Sonnet 5 noreply@anthropic.com

Devin review: https://app.devin.ai/review/paranext/paranext-core/pull/2569


This change is Reviewable

imnasnainaec and others added 2 commits July 16, 2026 15:35
The toolbar reserved a fixed 138px for Windows' caption-button cluster, but
that guess doesn't hold under DPI scaling or OS text-size settings, so the
profile button could render underneath the native (OS-drawn) close button.
Reads the real reserved width live via the Window Controls Overlay API
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the missing navigation-history/, scroll-groups/, title-bar/, and
verse-navigation/ entries to the isolated E2E README's subdirectory list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Session-URL: <session URL>
@imnasnainaec
imnasnainaec force-pushed the fix/title-bar-profile-button-overlap branch from 50b4f4e to b5dcca9 Compare July 16, 2026 19:44
@imnasnainaec

This comment was marked as outdated.

@imnasnainaec

This comment was marked as outdated.

imnasnainaec and others added 2 commits July 17, 2026 12:49
…style prop

Wrap the toolbar in a local div carrying the live-measured reserved-space
padding instead of threading it through platform-bible-react's Toolbar
component, avoiding an unnecessary shared-library API change. Suppresses
Toolbar's own end-side border and padding when the wrapper takes over so
the reservation isn't applied twice, and tunes a small breathing-room
margin so content doesn't sit pixel-flush against the native buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@imnasnainaec

Copy link
Copy Markdown
Contributor Author

Condensed summary of /review-paratext with Claude Sonnet 5 as of fde6e80

Review summary

Fixes the profile button rendering under Windows' native title-bar buttons at non-100% DPI scaling, via a live measurement from the Window Controls Overlay API. Purpose confirmed with the author; scope stayed tight.

Findings: no critical issues. One important finding (stale generated-doc drift) is now moot — see below. One minor doc gap was fixed (missing e2e-test README entries). Three Devin AI comments were incorporated: two verified as real but non-currently-exploitable pre-existing limitations (RTL padding assumption, Windows-fullscreen static fallback), left as-is; the third (loss of breathing-room spacing) turned out to predict, in the abstract, a bug the author later found live.

Considered and altered: the original implementation added an optional style prop to the shared platform-bible-react Toolbar component. Mid-review, the author reconsidered this as disproportionate for a single call site's fix (it required a full dist/ rebuild and added permanent shared-lib API surface) and reverted it in favor of a fix local to platform-bible-toolbar.tsx alone — lib/platform-bible-react is now unchanged from main.

Reverted/fixed along the way: that rework surfaced three visual bugs found by the author through live testing, not static analysis — a stray border artifact, an over-large reserved gap, and (once those were fixed) a too-small one. All three are fixed and covered by tests.

Design understanding: the author initially said they were "still reviewing it myself" when asked to explain the design, but went on to personally drive the architecture decision and correctly diagnose all three live bugs before I confirmed the causes — worth a brief check-in at the review meeting rather than reading either signal alone.

Quality checks: typecheck, lint, and the full relevant test suite (30/30) are clean, including after the follow-up fixes and a merge from main.

imnasnainaec and others added 2 commits July 17, 2026 16:26
The wrapper div introduced for the live-measured reserved space only
suppressed Toolbar's own end-side border, leaving the top/bottom border
stopping short at Toolbar's narrower edge instead of continuing across
the reserved strip. The wrapper now takes over Toolbar's border on all
four sides (and Toolbar drops its own entirely) whenever it's carrying
the live measurement, so the outline encloses the whole toolbar-plus-
reserved-space region again, matching how it looked before the padding
moved outside Toolbar's own box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@katherinejensen00 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review — PR #2569 (Live WCO Title-Bar Measurement)

18 findings across correctness, testing, and code quality. Key items (see inline comments for blocking/warning details):

Blocking

  1. test.skip() inside beforeAll() is a no-op — tests run on all platforms
  2. paddingInlineEnd reserves space on the wrong physical side in RTL locales
  3. Reserved-space strip has no WebkitAppRegion — dead zone for window dragging

Warnings

  1. WorkspaceUpdatingOverlay hardcodes top: 48, but wrapper tw:border adds 2 px
  2. Redundant re-render on mount: useState init + effect both allocate fresh DOMRect
  3. Hook stuck forever if windowControlsOverlay is undefined at first render

Suggestions (summary — no inline comments)

12. E2E test in new subdirectorye2e-tests/CLAUDE.md says prefer a flat spec file directly in tests/isolated/ over a new folder. tests/isolated/title-bar-reserved-space.spec.ts would run without config changes; the current tests/isolated/title-bar/ subdirectory requires a named project registration in playwright.config.ts to be discovered.

13. Extract triple condition to named booleanosPlatformToReserveSpaceFor !== undefined && osPlatformToReserveSpaceFor !== 'darwin' && windowControlsOverlayRect encodes 'Windows with a live overlay' but forces readers to parse three clauses. const isWindowsWithLiveOverlay = ... one line above makes the ternary self-documenting.

14. toolbarReservedSpaceStyle allocates a new object every render — when the live-measurement path is active, every re-render of PlatformBibleToolbar produces a fresh { paddingInlineEnd: N }. React re-renders the full Toolbar subtree even when N is unchanged. useMemo(() => ..., [windowControlsOverlayRect, osPlatformToReserveSpaceFor]) would prevent that.

15. windowControlsOverlay in the useEffect dep array is misleadingnavigator.windowControlsOverlay is a stable platform property whose identity never changes. Listing it implies the effect might re-run on change; [] with an inline read would be more honest.

16. Fix at the wrong architectural depth — the PR overrides Toolbar's private classes (tw:border, tw:px-4) from the caller. If those defaults change in platform-bible-react, the suppressions silently break. Consider a reservedInlineEnd?: number prop on Toolbar or an optional pixel arg on getToolbarOSReservedSpaceClassName so the measurement lives inside one abstraction.

17. event.titlebarAreaRect declared but never consumedWindowControlsOverlayGeometryChangeEvent.titlebarAreaRect is declared in the .d.ts, but updateRect calls getTitlebarAreaRect() again. Reading event.titlebarAreaRect avoids the extra method call and aligns with the declared type.

18. addEventListener missing third parameter — the real EventTarget.addEventListener accepts options?: boolean | AddEventListenerOptions. The narrow declaration prevents { once: true } and similar patterns. Same applies to removeEventListener.

Comment thread e2e-tests/tests/isolated/title-bar/title-bar-reserved-space.spec.ts Outdated
Comment thread src/renderer/components/platform-bible-toolbar.tsx Outdated
Comment thread src/renderer/components/platform-bible-toolbar.tsx Outdated
const { windowControlsOverlay } = navigator;

const [titlebarAreaRect, setTitlebarAreaRect] = useState<DOMRect | undefined>(() =>
windowControlsOverlay?.visible ? windowControlsOverlay.getTitlebarAreaRect() : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — redundant re-render guaranteed on every mount when WCO is visible.

The useState lazy initializer calls getTitlebarAreaRect() at mount → DOMRect A. Then useEffect fires and calls updateRect()getTitlebarAreaRect() again → DOMRect B (a new object — browsers always construct a fresh DOMRect). React's Object.is comparison sees two different references and schedules an extra re-render of PlatformBibleToolbar even though the geometry is identical. Every cold start triggers one extra toolbar paint.

Fix: either remove the lazy initializer (accept one render with undefined before the effect fires) or skip the updateRect() call in the effect when the state was already set by the initializer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Fair catch — one extra render on cold start. Not worth the added branching to avoid it, leaving as-is.

* full screen).
*/
export function useWindowControlsOverlay(): DOMRect | undefined {
const { windowControlsOverlay } = navigator;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — hook is structurally stuck if navigator.windowControlsOverlay is undefined at first render.

windowControlsOverlay is captured by destructuring at render time and placed in the dep array. If it is undefined at first render (Storybook, Vitest/jsdom, non-Electron host), the dep array locks in [undefined] and the effect never re-runs. There is no mechanism to watch navigator for property additions, so the hook permanently returns undefined in those environments.

In production Electron with titleBarOverlay: true the API is synchronously available, so this is not a live-app risk — but the hook silently fails in every non-Electron test/preview host, and unit tests must mock the API before render to exercise the live path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Real, but only affects non-Electron test/preview hosts — in production the API is synchronously available at first render. Leaving as-is.

Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
}

const updateRect = () =>
setTitlebarAreaRect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — consider reading event.titlebarAreaRect instead of calling getTitlebarAreaRect() again.

WindowControlsOverlayGeometryChangeEvent (declared in window-controls-overlay.d.ts) already carries titlebarAreaRect: DOMRect at fire time. Reading event.titlebarAreaRect in the handler avoids the extra method call and uses the snapshot that triggered the event, which is more semantically aligned with the declared type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Reasonable, but re-invoking it is cheap and keeps the update path uniform with the initial read. Leaving as-is.

Comment thread src/renderer/window-controls-overlay.d.ts
@merchako

Copy link
Copy Markdown
Contributor

Tested on macOS.

  • back = you branch;
  • front = evergreen P10
CleanShot 2026-07-22 at 20 01 04

They're the same.
lgtm.

@irahopkinson

Copy link
Copy Markdown
Contributor

Tested in WSL Ubuntu 22.04:
image

imnasnainaec and others added 2 commits July 23, 2026 11:56
- Reserve space with physical paddingRight instead of the logical
  paddingInlineEnd, which would reserve the wrong side in RTL locales
- Make the reserved-space wrapper a drag region again (WebkitAppRegion)
  so the window can still be dragged from that strip
- Fix the e2e test.skip() no-op inside beforeAll by moving it to
  describe scope, so it actually skips on non-Windows
- Drop the dead default export, a no-op setState, and return undefined
  in useWindowControlsOverlay; add the missing options param to the
  WindowControlsOverlay addEventListener/removeEventListener types
- Fix a test comment that named the wrong quantity

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…WorkspaceUpdatingOverlay

Swap the wrapper's tw:border for an equivalent inset box-shadow. The wrapper has no
explicit height, so a real border added to its layout height whenever the live
measurement was active, silently invalidating WorkspaceUpdatingOverlay's hardcoded
top offset (a separate, unrelated component). A box-shadow paints the same outline
without occupying layout space, so that offset stays correct in every case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@merchako @irahopkinson Thanks for the non-Windows tests and screenshots!

@katherinejensen00 Thanks for the review!
All blocking items fixed. One warning fixed and the other two responded to. Most suggestions enacted.

@imnasnainaec reviewed all commit messages and made 13 comments.
Reviewable status: 0 of 7 files reviewed, 12 unresolved discussions (waiting on katherinejensen00).

Comment thread e2e-tests/tests/isolated/title-bar/title-bar-reserved-space.spec.ts Outdated
const triggerBox = await trigger.boundingBox();
expect(triggerBox).not.toBeNull();
if (!triggerBox || overlayRight === undefined) return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for type-satisfaction.

Comment thread src/renderer/window-controls-overlay.d.ts
Comment thread src/renderer/components/platform-bible-toolbar.tsx Outdated
Comment thread src/renderer/components/platform-bible-toolbar.tsx Outdated
const { windowControlsOverlay } = navigator;

const [titlebarAreaRect, setTitlebarAreaRect] = useState<DOMRect | undefined>(() =>
windowControlsOverlay?.visible ? windowControlsOverlay.getTitlebarAreaRect() : undefined,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Fair catch — one extra render on cold start. Not worth the added branching to avoid it, leaving as-is.

Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
}

const updateRect = () =>
setTitlebarAreaRect(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude:
Reasonable, but re-invoking it is cheap and keeps the update path uniform with the initial read. Leaving as-is.

Comment thread src/renderer/hooks/use-window-controls-overlay.hook.ts Outdated
@imnasnainaec
imnasnainaec marked this pull request as ready for review July 23, 2026 20:33
@imnasnainaec

Copy link
Copy Markdown
Contributor Author

The new e2e test was written analogously to the existing isolated tests. It still requires human execution/verification as part of review, since the isolated e2e files don't pass for me locally.

// OS controls area width 150px + 4px breathing room (RESERVED_SPACE_BREATHING_ROOM_PX)
expect(screen.getByTestId('toolbar-reserved-space-wrapper')).toHaveStyle({
paddingInlineEnd: '154px',
paddingRight: '154px',

@merchako merchako Jul 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an AI may have hallucinated the need to switch from logical directions to physical directions.

Issue: This change assumes the window controls (minimize/maximize/close on Windows, the traffic lights on macOS) always sit on a fixed physical edge, and that the previous logical directionality was wrong. That breaks in RTL locales (Arabic, Hebrew), where the OS mirrors the window chrome:

  • Windows places the caption buttons on the left in RTL. The Windows TitleBar docs state, "The layout is reversed when the FlowDirection is RightToLeft." You can see this in the Paratext 9 Arabic tutorial videos:
Image
  • macOS moves the traffic lights from the top-left to the top-right in RTL:
Image

Because Platform.Bible renders its own title bar in the web layer (not native chrome), the correct tool here is CSS logical direction — dir plus inline-start/inline-end — which flips automatically with the document's text direction. Hard-coding physical left/right defeats that and produces a mirrored, non-native layout in RTL.

Why it matters:

  • Chrome position is driven by text direction, not a fixed edge, on every OS we target.
  • Applying a physical-direction constant uniformly ignores that each OS mirrors the title bar in RTL.
  • The result is misaligned controls and a non-native feel in RTL locales.

Recommended fix:

  • Revert to logical directions.
  • Drive title-bar layout from the document/window text direction rather than a physical constant.

This applies to all logical→physical regressions in this PR.

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.

4 participants