diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 26c651cd0d..df0420d325 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -21,6 +21,19 @@ import type { Page } from '@playwright/test'; * motion resolves to — where the highlight lands, how wide each bar computes — * and cannot assert that anything animated on the way there. The tick entrance * has no end state to check and therefore no coverage here at all. + * + * Platform note: the pointer-reachability assertions in this file + * (`unreachableTicks`, `activeTickVisibility`) are load-bearing on macOS and + * nowhere else. macOS renders the chat scroller's vertical scrollbar as an + * overlay: it takes no layout space, so a rail parked on the scroller's right + * edge draws on top of it, but the scrollbar's hit region still intercepts + * the pointer — every tick under it renders yet never receives a click or + * hover, and the rail reads as gone. Linux's in-flow scrollbar moves the + * content column left instead, so the regression itself goes green in CI. + * What CI *does* catch is the platform-neutral geometry the fix rests on: + * the `stays inside the scrollport` test asserts the rail keeps clear + * of the measured dead band. Run this spec on a macOS machine before merging + * anything that touches the rail's edge or scrollbar. */ const RAIL_PROBE = `(() => { @@ -167,6 +180,18 @@ test('the prompt rail stays inside the scrollport at every scroll position', asy expect(probe!.insetTop, where).toBeGreaterThanOrEqual(0); expect(probe!.insetBottom, where).toBeGreaterThanOrEqual(0); expect(probe!.insetRight, where).toBeGreaterThanOrEqual(0); + // macOS's overlay scrollbar hit band starts ~14px in from the scroller's + // right edge; the tick bar must stay clear of it. Platform-neutral: a + // Linux in-flow scrollbar passes this trivially, but it is the geometry + // the macOS click-through fix rests on — pre-fix this read ~5px. + const barInset = await page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement; + const bar = document.querySelector('.maka-prompt-rail-tick-bar') as HTMLElement; + const s = scroller.getBoundingClientRect(); + const b = bar.getBoundingClientRect(); + return Math.round(s.right - b.right); + }); + expect(barInset, where).toBeGreaterThanOrEqual(14); } }); diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 0c6991371d..1be7a0e0db 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -27,8 +27,9 @@ engage while any transcript is on screen, and the rail does its own centring from the measured band below. - `--maka-prompt-rail-scrollport` / `--maka-prompt-rail-dock` are measured in - prompt-anchor-rail.tsx; see the rail's own rule for what they buy. */ + `--maka-prompt-rail-scrollport` / `--maka-prompt-rail-dock` / + `--maka-prompt-rail-height` are measured in prompt-anchor-rail.tsx; see the + rail's own rule for what they buy. */ .maka-prompt-rail-anchor { position: sticky; top: 0; @@ -41,26 +42,32 @@ .maka-prompt-rail { position: absolute; - right: var(--space-1); - /* The scrollport's lower band belongs to the sticky composer dock, so the - rail centres on — and is capped to — what is left above it. Centring on - the bare scrollport ran the lower ticks under the dock (122px of overlap - at 1240x617), over the frosted blur and the composer card. - - Measured from the anchor's top edge, which is the scrollport's, so this is - an absolute position rather than an offset from a centreline that sticky - clamping can move. The fallbacks are the pre-measurement first paint: no - dock inset, and the window standing in for the scrollport. */ + /* On macOS the chat scroller's vertical scrollbar is overlay: it takes no + layout space, but its hit region still intercepts the pointer, so a rail + parked under it renders yet reads as dead. Linux's in-flow scrollbar + moves the content column left instead, which is why the regression + sailed through CI green. Measured on macOS the dead band starts 14px in + from the scroller's right edge, so the rail rests at + `right: space-1 + space-2` (12px), clear of it, and hovering settles it + 3px further inward — the old motion's translateX(3px) did the opposite + and pushed the ticks into the band. `translateY(-50%)` stays: it only + centres vertically and has no part in the hit-region problem. */ + right: calc(var(--space-1) + var(--space-2)); top: calc((var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px)) / 2); - /* The -50% is the centring; the X offset is motion. The rail rests - tucked a few px toward the window edge and settles inward when the - pointer arrives, so reaching for it is met halfway. */ - transform: translateY(-50%) translateX(3px); + transform: translateY(-50%); max-height: calc( var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px) - (2 * var(--space-2)) ); overflow-y: auto; overscroll-behavior: contain; + /* The rail is 22px wide: a visible scrollbar crowds the ticks, and on macOS + the overlay scrollbar's hit region swallows the right half of every tick + for a beat after the rail scrolls itself. Wheel/trackpad scrolling still + works with the bar hidden. */ + scrollbar-width: none; + ::-webkit-scrollbar { + width: 0; + } display: flex; flex-direction: column; align-items: flex-end; @@ -73,13 +80,14 @@ opacity: var(--opacity-muted); transition: opacity var(--duration-base) var(--ease-out-strong), - transform var(--duration-emphasized) var(--ease-out-strong); + right var(--duration-emphasized) var(--ease-out-strong); -webkit-app-region: no-drag; } .maka-prompt-rail:hover { opacity: 1; - transform: translateY(-50%) translateX(0); + right: calc(var(--space-1) + var(--space-2) + 3px); + transform: translateY(-50%); } /* Ticks arrive rather than appear: a new prompt's tick slides in from the edge,