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:
1 parent
c31eed5a3a
commit
c7cff441a2
4 files changed
+48
-80
No files matched your search
+33
-20
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user