diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx
index c8a73eb..9c6a08c 100644
--- a/src/components/PageHeader.tsx
+++ b/src/components/PageHeader.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
+import { StickyRow } from './StickyRow';
export interface PageHeaderProps {
title: string;
@@ -39,9 +40,19 @@ export interface PageHeaderProps {
*
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
+ *
+ * ── Held under the nav bar ──────────────────────────────────────────────────
+ *
+ * This row does not scroll away. It used to: reaching the bottom of a long list
+ * put the tab you were in, the search and every action button off the top of
+ * the window, so the only way back to the controls over what you were reading
+ * was to scroll back through all of it. The page still scrolls normally —
+ * nothing here gets a scrollbar of its own — the row simply stays. The
+ * behaviour and the canvas it paints live in `StickyRow` and `.page-sticky`.
*/
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
const hasRow = Boolean(actions || (isTabsInline && tabs));
+ const hasTabsRow = Boolean(tabs && !isTabsInline);
return (
<>
@@ -64,29 +75,33 @@ export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderPro
below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches.
*/}
- {hasRow ? (
-
- {/* Left of the row when inline, so the tabs start at the page's left
- edge and the actions stay on the right. */}
- {isTabsInline && tabs ?
{tabs}
: }
+ {hasRow || hasTabsRow ? (
+
+ {hasRow ? (
+
+ {/* Left of the row when inline, so the tabs start at the page's
+ left edge and the actions stay on the right. */}
+ {isTabsInline && tabs ?
: null}
+
+ ) : null}
>
);
}
diff --git a/src/components/StickyRow.tsx b/src/components/StickyRow.tsx
new file mode 100644
index 0000000..392b723
--- /dev/null
+++ b/src/components/StickyRow.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+
+/**
+ * A row of page controls held under the nav bar while the page scrolls.
+ *
+ * Whatever sits above a page — its view tabs, its search, its action buttons —
+ * used to leave with the scroll. Reaching the bottom of a long list put every
+ * control over that list off the top of the window, and the only way back to
+ * them was to scroll back through everything just read. The further in somebody
+ * was, the further from the controls.
+ *
+ * The page still scrolls normally. Nothing here gets a scrollbar of its own —
+ * the row simply stays where it is. Positioning and the canvas it paints live
+ * in `.page-sticky`; this component exists for the one part CSS cannot answer,
+ * which is whether the row is currently pinned.
+ *
+ * `PageHeader` wraps itself in this, so every page built from it gets the
+ * behaviour without asking. Dispatch builds its own switcher rather than going
+ * through `PageHeader`, so it wraps itself — which is the reason this is a
+ * component and not four more lines inside the header.
+ */
+export function StickyRow({ children }: { children: ReactNode }) {
+ /**
+ * Pinned or not, so the hairline can be drawn only when it is.
+ *
+ * At rest the nav bar's own hairline sits just above this one, and two
+ * parallel lines across the page with nothing between them read as a mistake.
+ * The line is what separates the row from content passing UNDER it, so it
+ * belongs to the pinned state rather than to the row.
+ *
+ * Watched with an observer rather than a scroll handler: a scroll listener
+ * runs on every frame of every scroll to answer a question that changes
+ * twice. The negative top margin puts the observer's boundary a pixel above
+ * where the row comes to rest, so "no longer fully inside that box" means
+ * exactly "stuck".
+ */
+ const rowRef = useRef(null);
+ const [isStuck, setIsStuck] = useState(false);
+
+ useEffect(() => {
+ const element = rowRef.current;
+ /* Guarded for any renderer without it — the row still sticks, it just
+ never grows its line, which is a missing hairline rather than a crash. */
+ if (!element || typeof IntersectionObserver === 'undefined') return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
+ { rootMargin: '-57px 0px 0px 0px', threshold: 1 },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/trailMap.css b/src/components/trailMap.css
index 314b498..06cb857 100644
--- a/src/components/trailMap.css
+++ b/src/components/trailMap.css
@@ -8,6 +8,7 @@
.trail-map {
position: relative;
+ z-index: 0;
width: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx
index 20595a9..af2ec5f 100644
--- a/src/features/catalogue/CatalogueBrowser.tsx
+++ b/src/features/catalogue/CatalogueBrowser.tsx
@@ -377,27 +377,43 @@ export function CatalogueBrowser({
It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */}
-
+ {/* Pinned to the right-hand corner, and stated inline so it cannot be
+ undone by a stylesheet.
+
+ `justify="end"` was already here and already resolves to
+ `justify-content: flex-end` — HStack maps `justify` to the main axis,
+ and the rule is in the bundle — yet the row was reported rendering at
+ the top LEFT of the platform catalogue. A utility class landing after
+ the component's own is the only thing that explains that, and an
+ inline declaration is the one thing such a class cannot outrank.
+ `width` is explicit for the same reason rather than relying on the
+ flex default. */}
+
+
diff --git a/src/features/catalogue/CatalogueSidebar.tsx b/src/features/catalogue/CatalogueSidebar.tsx
index 1e85ce7..9bde4d0 100644
--- a/src/features/catalogue/CatalogueSidebar.tsx
+++ b/src/features/catalogue/CatalogueSidebar.tsx
@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ChevronDown } from 'lucide-react';
import type { CatalogueBrand } from '@/api/types';
-const COLLAPSED_COUNT = 8;
+const COLLAPSED_COUNT = 25;
/**
* The brand rail.
diff --git a/src/features/store-admin/pages/DispatchPage.tsx b/src/features/store-admin/pages/DispatchPage.tsx
index a85b475..f48e63f 100644
--- a/src/features/store-admin/pages/DispatchPage.tsx
+++ b/src/features/store-admin/pages/DispatchPage.tsx
@@ -22,6 +22,7 @@ import {
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar';
+import { StickyRow } from '@/components/StickyRow';
import { useSelection, type Selection } from '@/components/useSelection';
import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager';
@@ -244,57 +245,62 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
operations cluster beside it was removed earlier — and a card holding
nothing but a tab track is a box drawn round a box. Every other page
stands its switcher directly on the page. */}
-
- }
- count={totals.riders || undefined}
- isActive={mode === 'riders'}
- onClick={() => changeMode('riders')}
- />
- }
- count={branches.length || undefined}
- isActive={mode === 'stores'}
- onClick={() => changeMode('stores')}
- />
- {/* A shop's customer book is its own. Grouping a partner's rounds by
- customer would mean reading every merchant's book to put a name to
- a phone number, so the tab is not offered on a partner board. */}
- {scope.kind === 'tenant' ? (
+ {/* Held under the nav bar while the board scrolls. Dispatch builds its
+ own switcher instead of going through `PageHeader`, so it asks for
+ the behaviour directly — same component, same row, same canvas. */}
+
+ }
- isActive={mode === 'customers'}
- onClick={() => changeMode('customers')}
+ label="By rider"
+ icon={}
+ count={totals.riders || undefined}
+ isActive={mode === 'riders'}
+ onClick={() => changeMode('riders')}
/>
- ) : null}
+ }
+ count={branches.length || undefined}
+ isActive={mode === 'stores'}
+ onClick={() => changeMode('stores')}
+ />
+ {/* A shop's customer book is its own. Grouping a partner's rounds by
+ customer would mean reading every merchant's book to put a name to
+ a phone number, so the tab is not offered on a partner board. */}
+ {scope.kind === 'tenant' ? (
+ }
+ isActive={mode === 'customers'}
+ onClick={() => changeMode('customers')}
+ />
+ ) : null}
- {/* The three below group the day differently from the three above:
- those slice the same stops by whose they are, these report on how
- the day went. The rule in the track says so. */}
- }
- isActive={mode === 'active'}
- onClick={() => changeMode('active')}
- isGroupStart
- isLive
- />
- }
- isActive={mode === 'timing'}
- onClick={() => changeMode('timing')}
- />
- }
- isActive={mode === 'profit'}
- onClick={() => changeMode('profit')}
- />
-
+ {/* The three below group the day differently from the three above:
+ those slice the same stops by whose they are, these report on how
+ the day went. The rule in the track says so. */}
+ }
+ isActive={mode === 'active'}
+ onClick={() => changeMode('active')}
+ isGroupStart
+ isLive
+ />
+ }
+ isActive={mode === 'timing'}
+ onClick={() => changeMode('timing')}
+ />
+ }
+ isActive={mode === 'profit'}
+ onClick={() => changeMode('profit')}
+ />
+
+
{/* The same four-card row every other page uses — same `kpi-grid`, same
diff --git a/src/index.css b/src/index.css
index b714327..0326dd3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -115,6 +115,20 @@
--color-canvas-mid: #f8f8f8;
--color-canvas-warm: #f8f6f1;
+ /* The canvas itself, named once. `body::before` paints it across the
+ viewport; anything that has to sit on top of it and vanish paints the
+ same gradient with `background-attachment: fixed` -- see `.page-sticky`.
+ Two copies of these stops would drift the first time one is tuned. */
+ --canvas-gradient: linear-gradient(
+ 90deg,
+ var(--color-canvas-violet) 0%,
+ #f5f2f7 28%,
+ #f7f6f7 45%,
+ var(--color-canvas-mid) 65%,
+ #f8f7f4 86%,
+ var(--color-canvas-warm) 100%
+ );
+
--container-page: 80rem;
--container-wide: 108rem;
--container-admin: 102rem;
@@ -137,15 +151,7 @@ body::before {
inset: 0;
z-index: -1;
pointer-events: none;
- background: linear-gradient(
- 90deg,
- var(--color-canvas-violet) 0%,
- #f5f2f7 28%,
- #f7f6f7 45%,
- var(--color-canvas-mid) 65%,
- #f8f7f4 86%,
- var(--color-canvas-warm) 100%
- );
+ background: var(--canvas-gradient);
}
/* Every application shell is transparent so the canvas reads through. */
@@ -843,11 +849,14 @@ main {
top: 72px;
/* The clip lives here, on the element that owns the radius. */
overflow: hidden;
+ height: calc(100dvh - 98px);
+ display: flex;
+ flex-direction: column;
}
.rail-scroll {
padding: 6px;
- max-height: calc(100dvh - 98px);
+ flex: 1;
overflow-y: auto;
/* The thin, quiet scrollbar this rail used to declare for itself is now the
whole console's — both scrollbar properties are inherited, so the pair on
@@ -2312,3 +2321,62 @@ main {
.fleet-actions > *:first-child button {
justify-content: center;
}
+
+/* ── The page's own header row, held under the nav bar ──────────────────────
+
+ The tabs and the buttons above a page used to leave with the scroll. Getting
+ to the bottom of a long list meant the controls over that list -- the tab you
+ were in, the search, the action buttons -- were off the top of the window,
+ and the only way back to them was to scroll back through everything you had
+ just read. The further in you were, the further from every control.
+
+ So the row stays. The page scrolls underneath it exactly as before; nothing
+ is given its own scrollbar.
+
+ ── Why the background is a gradient and not a colour ──
+
+ The canvas is a FIXED, viewport-wide gradient painted on `body::before`, so
+ there is no single colour this row could be filled with -- a flat fill would
+ read as a band sitting on the page, lighter or darker than the canvas
+ depending which third of the window it is in.
+
+ `background-attachment: fixed` makes the positioning area the viewport rather
+ than this element, which is the same frame `body::before` uses. The row then
+ paints the exact slice of canvas it is covering, and matches at any width and
+ any scroll position. The gradient is a variable so the two cannot drift.
+
+ The offset is the nav bar's own 56px, and the z-index sits between the page's
+ content (3 at most) and that bar (40), so it covers rows on the way past and
+ is itself covered by the bar above and any drawer. */
+.page-sticky {
+ position: sticky;
+ top: 56px;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ /* What the two rows had as siblings of the page column. */
+ gap: 24px;
+ background: var(--canvas-gradient) fixed;
+
+ /* A band of canvas above and below, so a row does not touch the controls as
+ it passes under them. The negative margins hand the same amount back to the
+ column, leaving the resting layout exactly as it was -- this costs no
+ vertical space until it is actually stuck. */
+ padding-block: 12px;
+ margin-block: -12px;
+}
+
+/* The nav bar carries a hairline; a second one at rest would be two lines 24px
+ apart. It appears only once something has scrolled beneath. */
+.page-sticky::after {
+ content: '';
+ position: absolute;
+ inset: auto 0 0 0;
+ height: 1px;
+ background: var(--color-line);
+ opacity: 0;
+ transition: opacity 140ms ease;
+}
+.page-sticky[data-stuck='true']::after {
+ opacity: 1;
+}