fix(frontend): draw the tab strip's scroll bar instead of the native one (#10547)

* fix(frontend): draw the tab strip's scroll bar instead of the native one

The strip sizes its scroll row to the tabs, but a native horizontal
scrollbar claims layout height on top of that: Firefox spends 11px on
`scrollbar-width: thin` — `--wm-scrollbar-size` is WebKit-only, so the
4px it asks for is ignored there — which clipped the tabs at the top of
the 32px sessions strip and left a wide gutter under them.

Hide the native bar and draw a 4px thumb from `scrollLeft`/`scrollWidth`
instead: it costs no layout height, is the same size in every engine, and
sits on the strip's bottom edge, flush under the tabs. Tabs drop to `h-6`
so they clear it, and the strip's default height matches the sessions
caller's `h-8` so every strip has the same geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): clamp the tab strip thumb at both ends of its track

WebKit's elastic overscroll drives `scrollLeft` negative, which slid the
thumb out of the track's left edge and into the strip's padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-05 20:17:05 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 154f8f461e
commit 2c189fea14
4 changed files with 153 additions and 42 deletions
+3 -2
View File
@@ -280,8 +280,9 @@
}
/* Subtle scrollbar: a thin, rounded thumb that only appears on hover, on both
axes. Shared by ScrollableX (tab strips, code blocks) and the AI chat. Size
via the `--wm-scrollbar-size` var (default 6px). Higher specificity than the
axes. Shared by ScrollableX (code blocks, tab headers) and the AI chat. Size
via the `--wm-scrollbar-size` var (default 6px) — WebKit only; Firefox sizes
`thin` itself and spends ~11px of the box on it. Higher specificity than the
app-wide `*::-webkit-scrollbar`, so it overrides it. */
.scrollbar-subtle {
scrollbar-width: thin;
@@ -4,7 +4,9 @@
// Subtle horizontal scroll: native overflow (so wheel/trackpad/drag always
// work) with the shared `.scrollbar-subtle` thumb (thin, hover-revealed). Bar
// thickness is tunable via the `--wm-scrollbar-size` CSS var (pass through
// `style`), so denser callers (e.g. the tab strip) can shrink it.
// `style`) — WebKit only, so Firefox spends its own ~11px of the box on the
// bar whatever this says. Not for a height-constrained row: draw the thumb
// yourself there, the way the tab strip does.
let {
class: c = '',
style = '',
@@ -29,7 +29,6 @@
import { X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
import ScrollableX from '../ScrollableX.svelte'
interface Props {
tabs: TabItem[]
@@ -41,7 +40,9 @@
* activated via Enter/Space — lets the active tab host a secondary affordance
* (e.g. toggling the breadcrumb picker rendered in `tabAccessory`). */
onActiveClick?: (id: string) => void
/** Extra classes for the outer tab strip. */
/** Extra classes for the outer tab strip. It is 32px tall unless `trailing`
* content is taller; a fixed height here overrides that, and going taller
* pulls the tabs away from the scroll bar, which stays on the bottom edge. */
class?: string
/** Render inside the scroll row, right after the last tab (e.g. a "+" new-tab
* button) — scrolls with the tabs, unlike `trailing`. */
@@ -88,6 +89,85 @@
if (!isDragging) dndMiddle = next
})
// Scroll bar. The native one is hidden (`no-scrollbar`) and redrawn here: its
// height is a WebKit-only setting, so Firefox spends 11px of the strip on a
// bar there is no room for and clips the tabs. Ours is 4px in every engine and
// costs no layout height at all.
const MIN_THUMB = 24
let scrollEl = $state<HTMLElement | undefined>(undefined)
let scrollLeft = $state(0)
let viewport = $state(0)
let content = $state(0)
const scrollable = $derived(Math.max(0, content - viewport))
const overflowing = $derived(scrollable > 1)
// Clamped to the viewport: a pane dragged shut leaves a few pixels of strip,
// and an unclamped minimum-width thumb would hang out of it.
const thumbWidth = $derived(
overflowing ? Math.min(viewport, Math.max(MIN_THUMB, (viewport / content) * viewport)) : 0
)
// Clamped at both ends: `scrollLeft` is fractional on HiDPI while the widths
// are rounded, so the ratio can tip past 1, and WebKit's elastic overscroll
// drives it negative — either way the thumb would leave the track.
const thumbLeft = $derived(
scrollable > 0
? Math.max(
0,
Math.min(viewport - thumbWidth, (scrollLeft / scrollable) * (viewport - thumbWidth))
)
: 0
)
function measure() {
const el = scrollEl
if (!el) return
scrollLeft = el.scrollLeft
viewport = el.clientWidth
content = el.scrollWidth
}
// Both ends move independently: the viewport on a pane resize, the content as
// tabs open, close and get renamed.
$effect(() => {
const el = scrollEl
if (!el) return
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
if (el.firstElementChild) ro.observe(el.firstElementChild)
return () => ro.disconnect()
})
// Drag the thumb: pointer capture keeps the gesture alive past the strip's
// edges, and the ratio maps thumb travel back onto scroll travel. Recomputing
// from the anchor each move (rather than accumulating) means clamping at
// either end doesn't drift, and reading the travel live keeps a tab opening
// mid-drag from scaling every later move against a stale track.
function handleThumbPointerDown(e: PointerEvent) {
const el = scrollEl
// Primary button only: a right-click would open the context menu without
// delivering the pointerup that ends the drag.
if (!el || e.button !== 0) return
e.preventDefault()
const target = e.currentTarget as HTMLElement
const startX = e.clientX
const startScroll = el.scrollLeft
target.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
const travel = viewport - thumbWidth
if (travel <= 0) return
el.scrollLeft = startScroll + ((ev.clientX - startX) / travel) * scrollable
}
const onUp = (ev: PointerEvent) => {
target.releasePointerCapture(ev.pointerId)
target.removeEventListener('pointermove', onMove)
target.removeEventListener('pointerup', onUp)
target.removeEventListener('pointercancel', onUp)
}
target.addEventListener('pointermove', onMove)
target.addEventListener('pointerup', onUp)
target.addEventListener('pointercancel', onUp)
}
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
isDragging = true
dndMiddle = e.detail.items
@@ -100,7 +180,7 @@
function tabClasses(isActive: boolean) {
return twMerge(
'group relative inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
isActive
? 'bg-surface-tertiary text-emphasis'
: 'bg-transparent text-hint hover:text-secondary'
@@ -189,41 +269,69 @@
</div>
{/snippet}
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface', c)}>
<!-- 4px bar to match the strip's `pb-1` reserve. -->
<ScrollableX class="flex-1 min-w-0 pt-1 pl-1 pb-1" style="--wm-scrollbar-size: 4px;">
<div class="flex items-center" role="tablist">
{#each pinnedLeft as tab (tab.id)}
{@render tabButton(tab)}
{/each}
<div
class="flex items-center"
use:dndzone={{
items: dndMiddle,
flipDurationMs: 150,
type: dndType,
dropTargetStyle: {}
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each dndMiddle as tab (tab.id)}
<div>
{@render tabButton(tab)}
</div>
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface min-h-8', c)}>
<!-- The tabs centre in the full strip and the bar overlays the air under them,
flush with the strip's bottom edge — it takes no height of its own, so the
strip never resizes and the tabs sit at the same place whether or not they
overflow. -->
<div class="group/scroll relative flex-1 min-w-0 self-stretch">
<div
bind:this={scrollEl}
onscroll={() => scrollEl && (scrollLeft = scrollEl.scrollLeft)}
class="h-full overflow-x-auto overflow-y-hidden no-scrollbar pl-1"
>
<!-- `w-max`: without it the row is pinned to the viewport width and the tabs
overflow *out* of it, so the ResizeObserver below never sees a tab open
or a label change and the scroll bar goes stale. -->
<div class="flex items-center h-full w-max" role="tablist">
{#each pinnedLeft as tab (tab.id)}
{@render tabButton(tab)}
{/each}
<div
class="flex items-center"
use:dndzone={{
items: dndMiddle,
flipDurationMs: 150,
type: dndType,
dropTargetStyle: {}
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each dndMiddle as tab (tab.id)}
<!-- `flex`, not the default block: an inline-flex tab in a block wrapper
sits on a text baseline and rides ~1.5px off the row's centre. -->
<div class="flex">
{@render tabButton(tab)}
</div>
{/each}
</div>
{#each pinnedRight as tab (tab.id)}
{@render tabButton(tab)}
{/each}
{#if afterTabs}
{@render afterTabs()}
{/if}
</div>
{#each pinnedRight as tab (tab.id)}
{@render tabButton(tab)}
{/each}
{#if afterTabs}
{@render afterTabs()}
{/if}
</div>
</ScrollableX>
{#if overflowing}
<!-- Decorative: it mirrors the scroll position and can be dragged, but the
strip is scrollable without it (wheel, trackpad, and the arrow keys that
scroll the focused tab into view), so it stays out of the a11y tree
rather than posing as a control at a 4px hit target. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
aria-hidden="true"
class="absolute bottom-0 left-0 h-1 rounded-full touch-none bg-hint/0 group-hover/scroll:bg-hint/40 hover:!bg-secondary/60 transition-colors"
style="width: {thumbWidth}px; transform: translateX({thumbLeft}px);"
onpointerdown={handleThumbPointerDown}
></div>
{/if}
</div>
{#if trailing}
<div class="ml-1 pr-1 flex items-center shrink-0">
@@ -12,8 +12,8 @@
let tab = $state('button')
// Enough tabs to overflow a narrow strip so the shared ScrollableX hover
// scrollbar is exercised: drag to reorder, hover to reveal the 4px thumb.
// Enough tabs to overflow a narrow strip so the strip's own hover scrollbar is
// exercised: drag to reorder, hover to reveal the 4px thumb.
let draggableTabs = $state<TabItem[]>(
Array.from({ length: 14 }, (_, i) => ({ id: `t${i}`, label: `Preview tab ${i + 1}` }))
)
@@ -200,8 +200,8 @@ That's the full round-trip.`
</TabContent>
<TabContent value="scrollbar" class="p-4">
<div class="text-xs text-tertiary mb-3">
DraggableTabs (uses the shared <code>ScrollableX</code>, 4px bar): hover to reveal the
thumb, drag to reorder.
DraggableTabs (draws its own 4px bar on the strip's bottom edge): hover to reveal the thumb,
drag to reorder.
</div>
<div class="border border-border-light rounded-md" style="max-width: 420px;">
<DraggableTabs