feat(ui): desktop scrolls inside, under a fixed header

On the desktop the window no longer scrolls: the area between header
and footer does, with room for its scrollbar kept on both sides so the
content stays centred and nothing shifts between pages. This replaces
the scrollbar-gutter tricks (header colour in the gutter, Chrome's
always-on scrollbar, which also let the sticky header scroll away in
Chrome). Triage fills the height: card and buttons stay, the queue takes
the rest and scrolls inside. The Kanban height fit and the Quick Dump
button use the scroll area. Phones keep scrolling the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 19:15:43 +01:00
1 parent c31eed5a3a
commit c7cff441a2
4 files changed
+48 -80

No files matched your search

+33 -20
View File
@@ -206,21 +206,12 @@ export const App: React.FC = () => {
const [showProfileMenu, setShowProfileMenu] = useState(false);
const connection = useConnectionState();
// Height of the app header as --app-header-h: the page background paints the header colour up to there,
// so the scrollbar gutter on the right continues the header instead of showing the page colour
const [headerEl, setHeaderEl] = useState<HTMLElement | null>(null);
// Desktop: the window does not scroll; this area under the header does (phones keep the window scroll)
const scrollAreaRef = useRef<HTMLDivElement>(null);
// A new page starts at its top (the desktop scroll area would otherwise keep the old page's position)
useEffect(() => {
if (!headerEl || typeof ResizeObserver === 'undefined') return;
const root = document.documentElement;
const update = () => root.style.setProperty('--app-header-h', `${headerEl.offsetHeight}px`);
update();
const observer = new ResizeObserver(update);
observer.observe(headerEl);
return () => {
observer.disconnect();
root.style.removeProperty('--app-header-h');
};
}, [headerEl]);
scrollAreaRef.current?.scrollTo?.({ top: 0 });
}, [activeTab]);
const [showAbout, setShowAbout] = useState(false);
const [showTriageTrash, setShowTriageTrash] = useState(false);
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
@@ -848,7 +839,7 @@ export const App: React.FC = () => {
if (isPhoneWidth()) {
openMobileTriageCapture();
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
scrollAreaRef.current?.scrollTo?.({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
}
};
@@ -920,13 +911,12 @@ export const App: React.FC = () => {
return (
<div
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 sm:h-[100dvh] sm:overflow-hidden ${
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-[100dvh]'
}`}
>
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header
ref={setHeaderEl}
className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm"
>
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
@@ -1306,14 +1296,36 @@ export const App: React.FC = () => {
)}
{/* Main Container */}
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
{/* Desktop: the window never scrolls, only this area under the header (room for the scrollbar kept on
both sides, so the content stays centred under the header and nothing shifts between pages). The
workspace fills it exactly and scrolls only inside the note. Phones scroll the window as before. */}
<div
ref={scrollAreaRef}
data-app-scroll
className={`flex-1 flex flex-col min-h-0 sm:[scrollbar-gutter:stable_both-edges] ${
activeTab === 'workspace' ? 'sm:overflow-hidden' : 'sm:overflow-y-auto'
}`}
>
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
activeTab === 'workspace'
? 'min-h-0 flex flex-col sm:pb-0'
: activeTab === 'triage'
? 'sm:min-h-0 sm:flex sm:flex-col sm:pb-6'
: 'sm:pb-6'
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
<section
key={dataEpoch}
className={
activeTab === 'workspace'
? 'flex-1 min-h-0 flex flex-col'
: activeTab === 'triage'
? 'sm:flex-1 sm:min-h-0 sm:flex sm:flex-col' // the queue fills the rest of the height (TriageDeck)
: undefined
}
>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
@@ -1393,6 +1405,7 @@ export const App: React.FC = () => {
</section>
</ViewErrorBoundary>
</main>
</div>
<AppFooter
legal={authStatus.legal}
+8 -4
View File
@@ -554,11 +554,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
setKanbanColHeight(null);
return;
}
// From the board's top to the window's bottom; then take back what still overflows (footer, paddings)
const top = el.getBoundingClientRect().top + window.scrollY;
setKanbanColHeight(Math.max(320, window.innerHeight - top - 16));
// From the board's top to the bottom of the app's scroll area (the desktop window itself does not
// scroll); then take back what still overflows (paddings)
const area = el.closest<HTMLElement>('[data-app-scroll]');
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
const areaHeight = area ? area.clientHeight : window.innerHeight;
const top = el.getBoundingClientRect().top - areaTop;
setKanbanColHeight(Math.max(320, areaHeight - top - 16));
requestAnimationFrame(() => {
const over = document.documentElement.scrollHeight - window.innerHeight;
const over = area ? area.scrollHeight - area.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
});
};
+7 -5
View File
@@ -488,7 +488,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
}
return (
<div className="max-w-2xl mx-auto select-none space-y-4">
// Desktop: a column filling the height under the header; card and buttons keep their size, the queue
// takes the rest and scrolls inside (the page only scrolls when the window is too low even for that)
<div className="w-full max-w-2xl mx-auto select-none space-y-4 sm:flex-1 sm:min-h-0 sm:flex sm:flex-col">
{/* Top Deck Status & Undo Bar */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
@@ -931,7 +933,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
{cards.length > 1 && (
<div className="pt-2" ref={queueRef}>
<div className="pt-2 sm:flex-1 sm:min-h-[9rem] sm:flex sm:flex-col" ref={queueRef}>
{/* Phones: the queue stays folded so the card and its buttons fit on the screen */}
<button
type="button"
@@ -955,11 +957,11 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button>
{/* Folds open/closed on phones; always open from sm upwards */}
<div
className={`grid transition-[grid-template-rows,opacity,visibility] duration-300 ease-out sm:grid-rows-[1fr] sm:opacity-100 sm:visible ${
className={`grid transition-[grid-template-rows,opacity,visibility] duration-300 ease-out sm:flex-1 sm:min-h-0 sm:grid-rows-[1fr] sm:opacity-100 sm:visible ${
showQueueMobile ? 'grid-rows-[1fr] opacity-100 visible' : 'grid-rows-[0fr] opacity-0 invisible'
}`}
>
<div className="overflow-hidden min-h-0">
<div className="overflow-hidden min-h-0 sm:flex sm:flex-col">
<div className="flex items-center justify-between mb-2 px-1 pt-2 sm:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-accent" />
@@ -970,7 +972,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
</div>
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
<div className="panel-surface divide-y divide-border max-h-80 sm:max-h-none sm:flex-1 sm:min-h-0 overflow-y-auto overflow-x-hidden">
{cards.slice(1).map((item) => (
<SwipeableQueueItem
key={item.id}
-51
View File
@@ -29,45 +29,6 @@
-webkit-tap-highlight-color: transparent;
}
/* Room for the scrollbar is always kept: pages that scroll (e.g. a wide Kanban board) and pages that do
not keep the same width, so nothing shifts when switching tabs. Overlay scrollbars (phones) are not
affected. */
html {
scrollbar-gutter: stable;
}
/* Chrome paints an empty gutter (page without scrollbar) plain white, ignoring the page background. There
the scrollbar is always shown instead: without anything to scroll it is an empty track, coloured like
the header and page by body::-webkit-scrollbar below. Firefox keeps the gutter, which it paints with the
html background. */
@supports selector(::-webkit-scrollbar) {
html {
overflow-y: scroll;
}
}
/* Header colour down to the header's height (--app-header-h, set by App), then the page colour. The header's
bottom line is its own layer on top, since the theme gives it as a translucent colour. Defined on body:
Chrome resolves variables for the page scrollbar from there (html sees it through inheritance too). */
:root,
body {
--header-gutter-bg: linear-gradient(
to bottom,
transparent calc(var(--app-header-h, 0px) - 1px),
var(--border-color) calc(var(--app-header-h, 0px) - 1px),
var(--border-color) var(--app-header-h, 0px),
transparent var(--app-header-h, 0px)
),
linear-gradient(to bottom, var(--bg-panel) var(--app-header-h, 0px), var(--bg-app) var(--app-header-h, 0px));
}
/* The gutter shows the page background; on the desktop it carries the header colour (and its bottom
line) down to the header's height (--app-header-h, set by App), fixed like the sticky header, so the
header seems to run to the window edge */
@media (min-width: 768px) {
html {
background: var(--header-gutter-bg);
background-attachment: fixed;
}
}
body {
background-color: var(--bg-app);
color: var(--text-main);
@@ -86,18 +47,6 @@ body {
background: transparent;
}
/* Chrome takes the page scrollbar's style from body (html:: does not apply) and paints white behind a
transparent track, where Firefox shows the html background: the page scrollbar gets the same
background as html above, on the desktop with the header colour on top */
body::-webkit-scrollbar {
background: var(--bg-app);
}
@media (min-width: 768px) {
body::-webkit-scrollbar {
background: var(--header-gutter-bg);
}
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--radius-btn);