diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 796c4a1..a47fdb7 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -75,7 +75,7 @@ export class ErrorBoundary extends Component { margin: '48px auto', maxWidth: 620, padding: '28px 32px', - borderRadius: 16, + borderRadius: 'var(--card-radius)', border: '1px solid #F1D3D3', background: '#FFFBFB', fontFamily: 'inherit', diff --git a/src/components/KpiCard.tsx b/src/components/KpiCard.tsx index afa8e9b..a783b97 100644 --- a/src/components/KpiCard.tsx +++ b/src/components/KpiCard.tsx @@ -1,99 +1,89 @@ import type { ReactNode } from 'react'; -import { Card } from '@astryxdesign/core/Card'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; +import './kpiCard.css'; + +/** + * A KPI tile — the console's headline figure. + * + * ── There were two of these, and neither worked ───────────────────────────── + * + * This component put an icon inline with the label and CENTRED the value + * underneath it, which is why a row of tiles never lined up: a centred number + * sits in a different place in every tile depending on how long it is. + * `console.css` had a second, unrelated implementation — `.kpi` — with a + * brand-tinted icon tile on the left and the text stacked beside it. The two + * appeared on the same screen. The second was the better design and this is + * now that design, for both. + * + * ── `note` is rendered again, and that is the real change ─────────────────── + * + * The line under the figure — "142 app · 38 counter", "12% of app orders" — + * was removed from the tile at some point but never removed from the CALLERS: + * 86 of them still compute and pass it, and it was being thrown away. That is + * the half of a KPI that carries the meaning. A bare `0` under "Unsynced + * bills" reads as "no data" when it means everything is in the books, and `42` + * under "Total orders" says nothing about which channel they came through. + * + * ── `tone` does something now ─────────────────────────────────────────────── + * + * All five tones used to map to `var(--color-brand)`, so the prop was + * decorative — every tile was purple whether it reported takings or a failure. + * The tone now colours the icon tile only: the figure itself stays in ink, + * because a number that changes colour with its own value is hard to compare + * against the tile beside it. Brand remains the default, so a tile that is + * merely reporting is not shouting. + */ export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral'; -const TONE_COLOR: Record = { - accent: 'var(--color-brand)', - success: 'var(--color-brand)', - warning: 'var(--color-brand)', - error: 'var(--color-brand)', - neutral: 'var(--color-brand)', -}; - export interface KpiCardProps { /** Small-caps label. Say what it is, not what it means. */ label: string; value: string; /** - * Accepted and ignored, like `fill` below. - * - * This was the sub-pill — where the number got its meaning, since "1 of 84 - * orders" says something "1.2%" does not. The pill was removed from the tile - * deliberately; 87 call sites across 21 files still pass the text, so the - * prop stays declared to keep them compiling and is not read. - * - * That means every one of those 87 strings is now written and never shown. - * Either drop them from the callers or bring the pill back — right now it is - * neither, and this comment is here so that is visible rather than a puzzle - * for whoever next wonders where "84 orders" went. + * The line under the figure — what the number is made of, or what it is a + * share of. This is where a KPI gets its meaning; see the note above. */ note?: string; + /** + * Colours the icon tile. `neutral` and `accent` are both the brand — a tile + * reports by default and only says more when something is actually wrong. + */ tone?: KpiTone; icon?: ReactNode; /** * Accepted and ignored. * - * The underline bar this used to drive was removed from the tile. Six call - * sites still pass it, so the prop stays declared to keep them compiling — - * it is not read. Either drop it from the callers or restore the bar; right - * now it is neither, and this comment is here so that is visible. + * The 2px underline bar this drove was removed from the tile deliberately — + * a proportion bar under five tiles turned the strip into a chart. Six call + * sites still pass it, so the prop stays declared to keep them compiling. + * Drop it from those callers and this can go. */ fill?: number; } -/** - * A KPI tile. - * - * Label and icon, then a large tabular value. The sub-pill and the 2px tone - * underline were both removed from the tile; `note` and `fill` still arrive - * from the call sites and are ignored — see the props above. Sized to sit 4–6 - * across rather than 4 — a console is glanced at all day, not read once. - */ -export function KpiCard({ label, value, tone = 'neutral', icon }: KpiCardProps) { - const color = TONE_COLOR[tone]; - +export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) { return ( - - - {/* Icon first, then the label; the pill sits opposite it. Both are - fixed to this row so tiles line up whether or not they carry one. */} - {/* A fixed height, so a label that wraps to two lines (a long note - squeezes it) does not push that tile's value below its neighbours'. */} - - - {icon ? ( - - {icon} - - ) : null} - - {label} - - +
+ {/* Label and icon share the top row; the figure gets the full width of + the card underneath them. - {/* Note pill removed as requested */} - + The icon used to sit in a column of its own to the LEFT, and at the + width these tiles actually get — five across a 1,224px column, less + 340px whenever Nearle Buddy is open — a 40px tile plus its gutter + left about 134px for the number. "₹1,24,500" does not fit in 134px at + 24px bold, so it wrapped, and it wrapped mid-figure: the first tile + on the Console read "₹1,24,50" on one line and "0" on the next. */} +
+ {label} + {icon ? ( + + {icon} + + ) : null} +
- - - {value} - - - - - + {value} + {note ? {note} : null} +
); } diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx new file mode 100644 index 0000000..c18e2b7 --- /dev/null +++ b/src/components/Panel.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from 'react'; +import './panel.css'; + +/** + * The console's card. Every surface that holds a table, a list or a form is + * one of these. + * + * ── Why it has three zones and the old card had one ───────────────────────── + * + * The card this replaces was a bare rectangle: `` with a + * table inside it and nothing else. The title, the row count, the search box + * and the tabs all lived ABOVE it, in the page header, so the box itself said + * nothing — it drew a line around a table and stopped. No edge treatment fixes + * that. A border, a shadow, a gradient: a rectangle with no anatomy reads as + * unfinished whichever way you paint its outline, which is exactly what it + * looked like. + * + * So the card frames its content now: + * + * ┌───────────────────────────────────────┐ + * │ PRODUCTS 1,248 [Filter] [+ Add] │ header — what this is, + * ├───────────────────────────────────────┤ how much of it, + * │ …the table… │ what you can do + * ├───────────────────────────────────────┤ + * │ 1–25 of 1,248 ‹ 1 2 3 › │ footer — where you are in it + * └───────────────────────────────────────┘ + * + * The header is not decoration. It is the three facts a person needs before + * reading a table, in the place the table is, rather than floating above it + * where they belong to the page instead of to the data. + * + * ── The edge ──────────────────────────────────────────────────────────────── + * + * A hairline plus a 1px micro-lift — `0 1px 2px` at 4%. Not a blur: a wide + * soft shadow makes a panel float, and a page of five floating panels is a + * tray of slabs. This is just enough to separate the panel from the canvas + * behind it and to make the hairline read as an edge rather than as a drawn + * line. + */ + +export interface PanelProps { + /** + * The eyebrow. Set in caps at 11px, because it labels the panel rather than + * titling the page — a 20px heading inside a card competes with the page's + * own, and the two stacked is what made the old layout repeat itself. + */ + title?: string; + /** + * The row count, beside the title in its own chip. + * + * Passed as a number and formatted here, so every panel in the console counts + * the same way. It is the first thing anybody checks against a filter. + */ + count?: number | undefined; + /** A word for what is counted — "products", "orders". Default: nothing. */ + countLabel?: string; + /** Search, filters, a primary action. Right-aligned in the header band. */ + actions?: ReactNode; + /** + * The footer band — in practice always a ``. + * + * Absent when the pager has nothing to say: `TablePager` returns null on a + * single page, and a footer band rendered around null is a 44px strip of + * tinted chrome under every short table. Pass it through `footer` and this + * decides; do not put a pager in `children`. + */ + footer?: ReactNode; + /** + * Padding for the body. `none` for a table, which brings its own; `md` for + * prose, a form or an empty state. + */ + padding?: 'none' | 'md'; + /** Stretches the panel to fill a grid row, for a row of unequal panels. */ + isFilled?: boolean; + children: ReactNode; +} + +export function Panel({ + title, + count, + countLabel, + actions, + footer, + padding = 'none', + isFilled, + children, +}: PanelProps) { + const hasHead = Boolean(title || actions || count !== undefined); + + return ( +
+ {hasHead ? ( +
+
+ {title ?

{title}

: null} + {/* Rendered at zero too. "0 products" is a fact — the filter + matched nothing — and hiding it leaves an empty table with no + explanation of whether it is empty or still loading. */} + {count === undefined ? null : ( + + {count.toLocaleString('en-IN')} + {countLabel ? {countLabel} : null} + + )} +
+ {actions ?
{actions}
: null} +
+ ) : null} + +
+ {children} +
+ + {/* `footer` is rendered only when it is truthy, and `TablePager` returns + null rather than false — so a single-page table passes an element that + renders nothing, and the band would still draw. The check is on the + pager's own emptiness instead: see `hasFooter` below. */} + {hasFooter(footer) ?
{footer}
: null} +
+ ); +} + +/** + * Whether the footer has anything in it. + * + * `` returns `null` from its own render when there is one page, but + * the ELEMENT is still truthy here — React has not called it yet. So a plain + * `footer ? … : null` drew an empty tinted band under every table short enough + * not to need a pager, which is the one place this design could have looked + * worse than the bare box it replaces. + * + * The band is therefore driven by CSS instead: `:empty` cannot see through the + * component boundary, so the footer element is always rendered when a footer is + * passed, and `.panel-card-foot:not(:has(*))` collapses it to nothing when the + * pager rendered no DOM. `:has` is supported everywhere this console runs. + */ +function hasFooter(footer: ReactNode): boolean { + return footer !== null && footer !== undefined && footer !== false; +} diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx new file mode 100644 index 0000000..2e24d4b --- /dev/null +++ b/src/components/SearchInput.tsx @@ -0,0 +1,70 @@ +import { Search, X } from 'lucide-react'; +import './searchInput.css'; + +/** + * The console's search box, shaped to sit beside a `TabBar`. + * + * Same 32px height, same corner, same hairline and card surface as the track + * the view tabs sit in — so a row reading + * + * [ Orders 85 | Deliveries 1 | Counter sales 312 ] [ 🔍 Search… ] + * + * reads as one row of controls with a gap in it, rather than as a tab strip + * and an unrelated form field that happen to share a line. + * + * ── Why it is not Astryx's `TextInput` ────────────────────────────────────── + * + * Every page that has a search wraps `TextInput size="sm"` in a `div` with a + * hand-picked width — 230px on Users, 240px on Sales, 260px on Stores — and + * `size="sm"` is 32px tall but takes the theme's `--radius-element`, which is + * 12px. Against an 8px track that is visibly a different family of corner. + * This is the same control at the same height with the track's own radius. + */ +export interface SearchInputProps { + value: string; + onChange: (next: string) => void; + placeholder?: string; + /** The accessible name. Say what is being searched — "Search orders". */ + label: string; + /** + * Width in px, or `'full'` to fill the container. + * + * The default suits a page header sitting opposite a tab strip. `'full'` is + * for a box that sits directly above the list it filters — a drawer's + * district picker, say — where a fixed width over a full-width list reads as + * two unrelated things. + */ + width?: number | 'full'; +} + +export function SearchInput({ value, onChange, placeholder, label, width = 260 }: SearchInputProps) { + return ( +
+ + onChange(event.target.value)} + /> + {/* Only when there is something to clear. A permanently visible X on an + empty field is a control that does nothing, and it costs the + placeholder ten characters of room. */} + {value ? ( + + ) : null} +
+ ); +} diff --git a/src/components/SheetDropzone.tsx b/src/components/SheetDropzone.tsx index 54315b8..0e69d19 100644 --- a/src/components/SheetDropzone.tsx +++ b/src/components/SheetDropzone.tsx @@ -66,8 +66,8 @@ export function SheetDropzone({ alignItems: 'center', gap: 14, padding: '14px 16px', - borderRadius: 14, - border: '1px solid var(--color-line)', + borderRadius: 'var(--card-radius)', + border: 'var(--card-border)', background: 'var(--color-surface-subtle)', }} > @@ -153,7 +153,7 @@ export function SheetDropzone({ alignItems: 'center', gap: 8, padding: '16px 20px', - borderRadius: 13, + borderRadius: 'var(--card-radius)', border: `1.5px dashed ${ isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))' }`, diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx new file mode 100644 index 0000000..bd31800 --- /dev/null +++ b/src/components/TabBar.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from 'react'; +import './tabBar.css'; + +/** + * The console's view switcher — Orders / Deliveries / Counter sales, Catalogue + * / My stock / My requests, Store users / Terminal accounts / Riders. + * + * ── There were six of these ───────────────────────────────────────────────── + * + * `TabButton` was defined privately in `StoresPage`, `InventoryPage`, + * `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies + * of the same button, each styled inline, and they had already drifted: three + * of them filled the count badge with `--color-warning`, so "85" under Orders + * was drawn in the colour this console reserves for something being wrong. A + * count is not an alarm. Sales had the sensible version. Nobody could see the + * difference because no two of them are ever on screen together. + * + * ── Why it is a track and not loose pills ─────────────────────────────────── + * + * The old buttons were transparent until selected, so an unselected set read as + * four pieces of text floating above the page with no indication they were one + * control or that picking one deselects the others. They sit in a recessed + * track now, with the selected tab raised out of it on the card surface — the + * standard segmented control, and the one shape that says "these are the views, + * you are in this one" without needing a label to say so. + */ + +export interface TabBarProps { + children: ReactNode; + /** `sm` for a secondary strip — a filter under a primary switcher. */ + size?: 'md' | 'sm'; + 'aria-label'?: string; +} + +export function TabBar({ children, size = 'md', ...rest }: TabBarProps) { + return ( +
+ {children} +
+ ); +} + +export interface TabProps { + label: string; + icon?: ReactNode; + isActive: boolean; + onClick: () => void; + /** + * The count beside the label. Omit it and no count is drawn. + * + * Whether ZERO is worth drawing depends on what the tab is, so the caller + * decides rather than this component: + * + * - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders + * 85" reads as a broken tab; "Deliveries" alone says nothing and lets the + * empty state explain. + * - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer + * to a question the reader is asking — nothing was cancelled — and a rung + * that loses its number when it empties makes the ladder jump about as + * the day goes on. + */ + count?: number | undefined; + /** + * Draws a hairline before this tab, dividing the track into groups. + * + * Sales needs it: Orders and Deliveries are two halves of the app's own + * trade, and Counter sales is a different channel entirely. The rule says so + * without spending a tab on a heading. + */ + isGroupStart?: boolean; +} + +export function Tab({ label, icon, isActive, onClick, count, isGroupStart }: TabProps) { + return ( + + ); +} diff --git a/src/components/kpiCard.css b/src/components/kpiCard.css new file mode 100644 index 0000000..9cd2a0b --- /dev/null +++ b/src/components/kpiCard.css @@ -0,0 +1,140 @@ +/* ══ The KPI tile ══════════════════════════════════════════════════════════ + The console's headline figure, and the card design the rest of the site + follows: the same surface tokens, the same icon tile, the same + label / value / note hierarchy. + + Layout is a stack: a top row carrying the label and the icon tile, then the + figure at full card width, then the note. + + The figure is LEFT-aligned and owns the whole width, and both halves of that + matter. The old tile centred it, so the number sat in a different place in + every card depending on how many digits it had and a row of tiles never + lined up. The tile before this one put the icon in a column to the left, + which left about 134px for the figure at the width these actually get — and + "₹1,24,500" wrapped mid-number. */ + +.kpi-card { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + /* A floor, not a height. Labels wrap to two lines at narrow widths — with + Nearle Buddy open these are about 195px wide, and "Cancelled orders" + ellipsised to "Cance…" is a label that has stopped working — so the tile + grows and the row stays level. */ + min-height: 104px; + padding: 16px; + + /* The console's one card surface. Same tokens as `Panel` and every other + card in the app; see the note in `index.css`. */ + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); + box-shadow: var(--card-shadow); +} + +/* ── The icon tile ────────────────────────────────────────────────────────── + A rounded square in the tone's tint, one step tighter than the card that + holds it — a control radius inside a card radius, which is what keeps it + from reading as a small card of its own. */ +.kpi-card-icon { + width: 40px; + height: 40px; + flex: none; + display: grid; + place-items: center; + border-radius: var(--card-radius-sm); + background: var(--kpi-tint); + color: var(--kpi-ink); +} + +/* Brand by default — a tile that is merely reporting should not shout. The + other three are for tiles whose figure IS a state. */ +.kpi-card { + --kpi-ink: var(--color-brand); + --kpi-tint: var(--color-brand-tint); +} +.kpi-card[data-tone='success'] { + --kpi-ink: var(--color-success, #1f9d55); + --kpi-tint: color-mix(in oklab, var(--color-success, #1f9d55) 12%, transparent); +} +.kpi-card[data-tone='warning'] { + --kpi-ink: var(--color-warning, #b7860b); + --kpi-tint: color-mix(in oklab, var(--color-warning, #b7860b) 14%, transparent); +} +.kpi-card[data-tone='error'] { + --kpi-ink: var(--color-error, #d64545); + --kpi-tint: color-mix(in oklab, var(--color-error, #d64545) 12%, transparent); +} + +/* ── The top row ────────────────────────────────────────────────────────── */ +/* Label left, icon right. The label takes the space the icon does not, and the + figure below gets the whole card. */ +.kpi-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; + margin-bottom: 8px; +} + +/* The label names the figure and stays quiet doing it. Not caps: at 11px in + caps with letter-spacing, "Cancelled orders" runs wider than the tile and a + two-line eyebrow reads as a heading that broke. Sentence case, ink-3. */ +.kpi-card-label { + font: 500 12.5px/1.3 var(--font-sans); + color: var(--color-ink-3); + min-width: 0; + /* Two lines, then ellipsis — never a third. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* The figure. Tabular so a column of tiles lines up on the digits, and pulled + in very slightly — at 25px the default tracking makes a rupee figure look + spaced out rather than set. + + IT NEVER WRAPS. `overflow-wrap: anywhere` was on this rule and it broke + "₹1,24,500" across two lines between the 0 and the 0 — a figure split + mid-digit is not a smaller figure, it is a wrong one. A number too wide for + its tile ellipsises instead, which is visibly truncated rather than quietly + misread, and the `title` is not needed because the same figure is on the + page it links to. */ +.kpi-card-value { + font: 700 25px/1.2 var(--font-sans); + letter-spacing: -0.02em; + color: var(--color-ink-1); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* What the figure is made of. This is the line that makes the number mean + something — see the note in `KpiCard.tsx` for why it had to come back. */ +.kpi-card-note { + margin-top: 3px; + font: 400 11.5px/1.4 var(--font-sans); + color: var(--color-ink-4); + /* Two lines at most. "142 app · 312 counter" wraps at tile width and that is + fine; a third line would push the tile past its neighbours. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* `.kpi-grid` is NOT defined here. It already exists in `index.css`, built on + container queries rather than viewport ones — which is what lets a tile row + reflow when Nearle Buddy opens beside it and takes 340px out of the column, + something a viewport breakpoint cannot see. Defining a second one here would + have won on import order and silently thrown that away. */ + +@media (max-width: 600px) { + .kpi-card { min-height: 96px; padding: 14px; } + .kpi-card-icon { width: 32px; height: 32px; } + .kpi-card-value { font-size: 22px; } +} diff --git a/src/components/panel.css b/src/components/panel.css new file mode 100644 index 0000000..fffba0f --- /dev/null +++ b/src/components/panel.css @@ -0,0 +1,141 @@ +/* ══ The panel — the console's one card ════════════════════════════════════ + Three zones: a header band that says what this is and how much of it there + is, a body, and a footer band that says where you are in it. See the note at + the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer + outline. + + Every size here comes from the card tokens in `index.css`. */ + +.panel-card { + display: flex; + flex-direction: column; + min-width: 0; + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); + box-shadow: var(--card-shadow); + /* The clip belongs on the element that owns the radius, or the header band's + tint squares off the top two corners. */ + overflow: hidden; +} + +/* For a grid row of panels that should finish level with each other. */ +.panel-card[data-filled='true'] { height: 100%; } + +/* ── Header band ────────────────────────────────────────────────────────── */ +/* Tinted rather than white, so the band reads as chrome and the rows below it + read as data. A white header separated only by a rule is the same surface + twice, which is how a heading ends up looking like a first table row. */ +.panel-card-head { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + min-height: 48px; + padding: 8px 16px; + background: var(--color-surface-subtle); + border-bottom: var(--card-border); +} + +.panel-card-title-group { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px + title inside a card competes with the page's own title above it — the two + stacked is what made every screen say its subject twice. */ +.panel-card-title { + margin: 0; + font: 600 11px/1.2 var(--font-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The count, in its own chip. Tabular, because it changes as a filter narrows + and a figure that shifts sideways while you read it is hard to trust. */ +.panel-card-count { + display: inline-flex; + align-items: baseline; + gap: 4px; + flex: none; + padding: 2px 8px; + border-radius: 999px; + background: var(--color-surface-sunken); + font: 600 11.5px/1.45 var(--font-sans); + font-variant-numeric: tabular-nums; + color: var(--color-ink-2); +} +.panel-card-count-label { + font-weight: 400; + font-size: 11px; + color: var(--color-ink-4); +} + +.panel-card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-left: auto; +} + +/* ── Body ───────────────────────────────────────────────────────────────── */ + +.panel-card-body { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} +.panel-card-body[data-pad='md'] { padding: 16px; } + +/* ── Footer band ────────────────────────────────────────────────────────── */ + +.panel-card-foot { + flex: none; + padding: 8px 16px; + background: var(--color-surface-subtle); + border-top: var(--card-border); +} + +/* The pager hides itself on a single page — it returns null — but the band + around it is rendered by the parent, which cannot see that. Without this a + short table carried a 44px strip of tinted chrome saying nothing. + `:has` collapses the band when its child rendered no DOM at all. */ +.panel-card-foot:not(:has(*)) { + display: none; +} + +/* The pager's own wrapper adds padding meant for sitting loose under a table. + Inside the band the band supplies it. */ +.panel-card-foot .table-pager { + padding: 0; + border-top: 0; +} + +/* ── A table sitting flush in the body ────────────────────────────────────── + The table brings its own horizontal scroll and its own header row. What it + must not bring is a rounded corner or an outer border — the panel owns both, + and a table with its own edge inside a panel is the double line this design + is meant to remove. */ +.panel-card-body > .table-scroll { + border-radius: 0; + border: 0; +} + +/* ── Narrow ─────────────────────────────────────────────────────────────── */ +@media (max-width: 600px) { + .panel-card-head { padding: 8px 12px; } + .panel-card-foot { padding: 8px 12px; } + .panel-card-body[data-pad='md'] { padding: 12px; } + /* Actions drop to their own line rather than squeezing the title out. */ + .panel-card-actions { margin-left: 0; width: 100%; } +} diff --git a/src/components/searchInput.css b/src/components/searchInput.css new file mode 100644 index 0000000..14bd460 --- /dev/null +++ b/src/components/searchInput.css @@ -0,0 +1,82 @@ +/* ══ Search box ════════════════════════════════════════════════════════════ + Shaped to match the view-tab track it sits opposite — see the note in + `SearchInput.tsx`. Every number here is the same number `tabBar.css` uses. */ + +.searchbox { + display: inline-flex; + align-items: center; + gap: 7px; + /* The track's height and the track's corner. This is the whole point of the + component: a search that is visibly the same object as the buttons at the + other end of the row. */ + height: 38px; + padding: 0 10px; + border-radius: var(--card-radius-sm); + border: var(--card-border); + background: var(--card-bg); + box-shadow: var(--card-shadow); + max-width: 100%; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +/* Fills its container, for a box sitting directly above the list it filters. */ +.searchbox[data-full='true'] { display: flex; width: 100%; } + +.searchbox:hover { border-color: var(--color-ink-4); } + +/* Focus lands on the BOX, not on the bare input inside it — the input has no + border of its own, so the default ring would draw inside the box and read as + a second, smaller field. */ +.searchbox:focus-within { + border-color: var(--color-brand); + box-shadow: 0 0 0 3px var(--color-brand-tint); +} + +.searchbox-icon { flex: none; color: var(--color-ink-4); } +.searchbox:focus-within .searchbox-icon { color: var(--color-brand); } + +.searchbox-input { + flex: 1 1 auto; + min-width: 0; + border: 0; + padding: 0; + background: transparent; + color: var(--color-ink-1); + font: 400 13px/1 var(--font-sans); + outline: none; + /* Safari draws its own rounded field and inner shadow on a text input, and + `type="search"` adds a magnifier and a cancel button on top of ours. */ + appearance: none; + -webkit-appearance: none; +} +.searchbox-input::placeholder { color: var(--color-ink-4); } +.searchbox-input::-webkit-search-decoration, +.searchbox-input::-webkit-search-cancel-button, +.searchbox-input::-webkit-search-results-button { + appearance: none; + -webkit-appearance: none; + display: none; +} + +.searchbox-clear { + flex: none; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 999px; + background: var(--color-surface-sunken); + color: var(--color-ink-3); + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} +.searchbox-clear:hover { background: var(--color-line); color: var(--color-ink-1); } +.searchbox-clear:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; } + +/* On a phone the header row wraps, and a 260px box beside a scrolling tab + track leaves neither enough room. It takes the full line instead. */ +@media (max-width: 600px) { + .searchbox { width: 100% !important; } +} diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index e7d20e9..fd153b0 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -105,6 +105,29 @@ export function AppShell({ const isMobile = useIsMobile(); const menuRef = useRef(null); + /* + A new page starts at the top. + + A single-page app does not reload, so the document keeps whatever scroll + offset the last page was left at: read Sales down to row 40, click Inventory, + and Inventory opens 2,000px down — usually past everything it has, so it + looks empty. Nothing in the app was resetting this. + + INSTANT, deliberately, and it is the one place in the console that opts out + of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would + animate those 2,000px on every navigation, which means a second of the new + page flying past before it settles — the page arriving late, rather than the + page arriving. Smooth is for a move you asked for within a page; a route + change is not one. + + Keyed on `pathname` alone, not on the whole location: the branch scope and + the date range travel in the query string, and re-scoping a table you are + halfway down should leave you where you were. + */ + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + }, [pathname]); + // Escape closes the account menu. A menu that can only be dismissed by // finding the trigger again is a trap for anyone on a keyboard, and this one // sits over the page rather than beside it. diff --git a/src/components/tabBar.css b/src/components/tabBar.css new file mode 100644 index 0000000..a11d9f0 --- /dev/null +++ b/src/components/tabBar.css @@ -0,0 +1,143 @@ +/* ══ The view switcher ═════════════════════════════════════════════════════ + A segmented control: a recessed track, and the selected view raised out of + it onto the card surface. See the note in `TabBar.tsx` for why this replaced + six private copies of a transparent pill. */ + +.tabbar { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: var(--card-radius-sm); + background: var(--color-surface-sunken); + /* The track is a control, so it takes the control radius rather than the + card one, and it never stretches: a segmented control that fills the page + width puts its two tabs at either end of the screen. */ + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; +} +.tabbar::-webkit-scrollbar { display: none; } + +.tabbar-tab { + display: inline-flex; + align-items: center; + gap: 7px; + flex: none; + height: 32px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: calc(var(--card-radius-sm) - 3px); + background: transparent; + color: var(--color-ink-3); + font: 500 13px/1 var(--font-sans); + white-space: nowrap; + cursor: pointer; + transition: background 160ms ease, color 160ms ease, border-color 160ms ease; +} + +/* Hover is a hint of the track's own surface, not the selected state — an + unselected tab that looks selected on hover is the oldest bug in this + control. */ +.tabbar-tab:hover:not([aria-selected='true']) { + background: color-mix(in oklab, var(--color-surface) 60%, transparent); + color: var(--color-ink-1); +} + +/* Raised out of the track: the card surface, a hairline and the same 1px + micro-lift every card in the console carries. That is what makes it read as + sitting ON the track rather than tinted inside it. */ +.tabbar-tab[aria-selected='true'] { + background: var(--card-bg); + border-color: var(--color-line); + box-shadow: var(--card-shadow); + color: var(--color-ink-1); + font-weight: 600; +} + +.tabbar-tab:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 1px; +} + +/* The icon carries the brand on the selected tab and nothing on the others, so + colour marks the selection once rather than three times. */ +.tabbar-icon { + display: inline-flex; + flex: none; + color: var(--color-ink-4); +} +.tabbar-tab[aria-selected='true'] .tabbar-icon { color: var(--color-brand); } + +.tabbar-label { min-width: 0; } + +/* ── The count ────────────────────────────────────────────────────────────── + A figure, not a badge. + + Three of the six copies filled this with `--color-warning` — the amber this + console uses for something needing attention — so a tab reading "Orders 85" + announced eighty-five problems. It is set as a quiet tabular number that + takes the brand only on the selected tab, which is the one whose count the + reader is actually acting on. */ +.tabbar-count { + flex: none; + font: 600 11.5px/1 var(--font-sans); + font-variant-numeric: tabular-nums; + color: var(--color-ink-4); + padding: 3px 6px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-ink-4) 10%, transparent); +} +.tabbar-tab[aria-selected='true'] .tabbar-count { + color: var(--color-brand); + background: var(--color-brand-tint); +} + +/* ── Group divider ────────────────────────────────────────────────────────── + A hairline inside the track, for a tab that belongs to a different family + from the one before it — Counter sales beside the app's own Orders and + Deliveries. Drawn as a margin plus a border so it sits in the gap rather + than on either tab. */ +.tabbar-tab[data-group-start='true'] { + margin-left: 9px; + position: relative; +} +.tabbar-tab[data-group-start='true']::before { + content: ''; + position: absolute; + left: -6px; + top: 6px; + bottom: 6px; + width: 1px; + background: var(--color-line); +} + +/* ── Secondary strip ──────────────────────────────────────────────────────── + Under a primary switcher — a status filter, say. Smaller, and with no track + of its own, so two rows of tabs do not read as two controls of equal rank. */ +.tabbar[data-size='sm'] { + background: transparent; + padding: 0; + gap: 4px; +} +.tabbar[data-size='sm'] .tabbar-tab { + height: 28px; + padding: 0 10px; + font-size: 12.5px; + border-radius: var(--card-radius-sm); +} +.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] { + background: var(--color-brand-tint); + border-color: transparent; + box-shadow: none; + color: var(--color-brand); +} +.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] .tabbar-count { + background: color-mix(in oklab, var(--color-brand) 16%, transparent); +} +.tabbar[data-size='sm'] .tabbar-icon { display: none; } + +@media (max-width: 600px) { + .tabbar-tab { padding: 0 10px; gap: 6px; } + .tabbar-icon { display: none; } +} diff --git a/src/components/trailMap.css b/src/components/trailMap.css index 821abfd..314b498 100644 --- a/src/components/trailMap.css +++ b/src/components/trailMap.css @@ -11,7 +11,7 @@ width: 100%; overflow: hidden; border: 1px solid var(--color-border); - border-radius: 10px; + border-radius: var(--card-radius); background: var(--color-surface-sunken); } @@ -84,9 +84,11 @@ .trail-map .leaflet-popup-content-wrapper { padding: 0 !important; - border-radius: 14px !important; - background: #ffffff !important; - border: 1px solid rgba(226, 232, 240, 0.95) !important; + border-radius: var(--card-radius) !important; + background: var(--card-bg) !important; + /* The console's hairline, not a near-miss of it. The shadow STAYS: this one + genuinely floats, over a map, which is what a shadow is for. */ + border: var(--card-border) !important; box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important; overflow: hidden !important; } diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index 4caa63d..259b457 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { AlertCircle, @@ -69,6 +69,40 @@ export function LoginPage() { const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + /* + Each step brings its own panel into view. + + The three steps are one screen, and on a phone the brand panel sits ABOVE the + form — so the card is taller than the viewport and answering the email step + replaces the panel below the fold. Without this the page does not move: you + press Continue, something changes off screen, and the password field you are + meant to type into is never shown to you. + + Smooth rather than a jump, because unlike a route change this is a move + WITHIN a screen the reader is already looking at, and seeing the page travel + is what connects the button they pressed to the field that arrived. It is + skipped on the first render — arriving at a login already scrolled to the + form would hide the brand and the "which account is this" context above it — + and a reader who has asked for reduced motion gets the instant jump, via the + global rule in `index.css`. + + DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic: + hooks must run in the same order on every render, and this component returns + early the moment a session exists. Put below that line, these two refs and + the effect would simply stop being called on the render that signs somebody + in — which is React's "rendered fewer hooks than expected" crash, on the + happy path. + */ + const panelRef = useRef(null); + const isFirstStep = useRef(true); + useEffect(() => { + if (isFirstStep.current) { + isFirstStep.current = false; + return; + } + panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [step]); + if (user) return ; /** Step one: which door does this email need? */ @@ -168,17 +202,31 @@ export function LoginPage() { >
+ {/* The ref lives on a wrapper rather than inside the two panels, so + neither has to know it is being scrolled to. `display: grid` keeps + it out of the way: as a grid item the wrapper stretches to the row, + and the panel inside stretches with it, which is what centres the + form vertically beside the brand. A plain block here would collapse + to its content's height and un-centre it. */} +
{step !== 'setup' ? ( )} +
); diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index cf427cb..474bcf3 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -7,10 +7,10 @@ import { IconButton } from '@astryxdesign/core/IconButton'; import { Pagination } from '@astryxdesign/core/Pagination'; import { Skeleton } from '@astryxdesign/core/Skeleton'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { Token } from '@astryxdesign/core/Token'; import { VStack } from '@astryxdesign/core/VStack'; -import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react'; +import { Funnel, PackageSearch, SearchX } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { catalogueKey, catalogueKeysOf } from '@/api/catalogue'; import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; import { APP_BROWSE_CATEGORY } from './tenantCategories'; @@ -391,16 +391,12 @@ export function CatalogueBrowser({ size="sm" onClick={() => setIsFiltersOpen((open) => !open)} /> - } - hasClear + width={220} />
diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index 3f79ccd..59d214b 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -9,6 +9,9 @@ import { Bullets, DrawerButton, DrawerCard, + Metric, + Metrics, + Mono, Row, Section, } from '@/features/store-admin/drawerKit'; @@ -102,7 +105,7 @@ export function CatalogueDetailDrawer({ subtitle={`${brand} · catalogue id ${product.id}`} width={540} onClose={onClose} - {...(isImported ? { meta: } : {})} + {...(isImported ? { meta: } : {})} {...(!isImported && !blockedReason && onImport ? { isFooterFilled: true, @@ -162,7 +165,7 @@ export function CatalogueDetailDrawer({ ))} - + {images.length} photos — only the first is imported @@ -171,31 +174,13 @@ export function CatalogueDetailDrawer({ {/* A RANGE, not a price. What the shop charges is set after the import, and conflating the two is how a catalogue figure ends up on a shelf. */} - -
- - Market price range - - {product.price_range ?? '—'} - - - {product.size ? : null} -
-
+ {/* The kit's own metric, not a 22px figure typed here. It was the largest + type in any drawer in the console and it sat on a catalogue REFERENCE + — louder than the selling price in the product drawer next to it. */} + + + + {product.description ? (
@@ -243,7 +228,7 @@ export function CatalogueDetailDrawer({ label={fact.label} value={ fact.isMono ? ( - {fact.value} + {fact.value} ) : ( fact.value ) @@ -282,21 +267,21 @@ export function CatalogueDetailDrawer({ label="Shown in the app under" value={ aisle ? ( - + ) : ( Uncategorized ) } />
- + {suggested.rule === 'catalogue' ? `The catalogue's own category for this product.` : product.category ? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.` : `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`} - + {aisle ? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.' : 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'} diff --git a/src/features/console/console.css b/src/features/console/console.css index 41583e5..eb9b5e5 100644 --- a/src/features/console/console.css +++ b/src/features/console/console.css @@ -63,40 +63,84 @@ .kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; } } -/* Icon beside the figure, nothing beneath it. The card used to carry a trend - row and a sparkline; with those gone it is a label and a number, and the - layout should not still be shaped around what it no longer holds. */ -.kpi { - border: 1px solid var(--color-line); border-radius: 16px; - background: var(--color-surface); padding: 16px; - display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; -} -.kpi-icon { - width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; - background: var(--color-brand-tint); color: var(--color-brand); -} -.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); } -/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about - 195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that - has stopped doing its job. The min-height keeps all five value rows level - whether the label took one line or two. */ -.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); } -.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; } +/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`, + `.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the + same object that `components/KpiCard.tsx` renders, with a different icon + radius, a different value size and a different gap. Both appeared on the + same screen. The component won, this strip now renders it, and the classes + are gone rather than left as a near-copy for someone to edit by mistake. */ -/* ── Panels ─────────────────────────────────────────────────────────────── */ +/* ── Panels ───────────────────────────────────────────────────────────────── + + The Console's panels are the SAME object as `components/Panel.tsx` — a + framed card with a header band, a body and a footer band. They stay as CSS + classes rather than becoming that component because these six sections each + lay their own body out (a chart, a donut, a map, a list of alerts) and have + no table or pager to hand it; the component's job is the table case. + + What matters is that they are the same DESIGN. They were not: this file had + a white header with a 15.5px title and a sub-line, the component has a + tinted band with an 11px eyebrow, and the two sat on the same screen. The + band and the type below are now the component's, restated here. + + The panel no longer pads itself — the bands and the body pad separately, or + a header band cannot reach the panel's edges. */ .panel { - border: 1px solid var(--color-line); border-radius: 16px; - background: var(--color-surface); padding: 18px; - display: flex; flex-direction: column; gap: 14px; min-width: 0; + border: var(--card-border); border-radius: var(--card-radius); + background: var(--card-bg); box-shadow: var(--card-shadow); + display: flex; flex-direction: column; min-width: 0; + /* The clip belongs on the element that owns the radius, or the header + band's tint squares off the top two corners. */ + overflow: hidden; +} + +/* The body. Every direct child of `.panel` that is not a band is content, so + the padding and the stack live on a wrapper the sections already render. */ +.panel > :not(.panel-head):not(.panel-foot) { + padding: 16px 18px; +} +/* Consecutive content blocks should not double their gutter. */ +.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) { + padding-top: 0; +} + +.panel-head { + display: flex; justify-content: space-between; align-items: center; + gap: 14px; flex-wrap: wrap; + min-height: 48px; padding: 8px 18px; + background: var(--color-surface-subtle); + border-bottom: var(--card-border); +} +/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a + card competes with the page's own title above it. */ +.panel-title { + margin: 0; + font: 600 11px/1.2 var(--font-sans); + letter-spacing: 0.08em; text-transform: uppercase; + color: var(--color-ink-3); +} +/* Title and sub-line sit on ONE row, the way the table panel's title and count + do. Every section wraps the pair in a plain `div`, which stacks them by + default — and a stacked pair is the two-line header this band replaced. */ +.panel-head > div:first-child { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} +.panel-sub { + margin: 0; + font: 400 12px/1.4 var(--font-sans); + color: var(--color-ink-4); } -.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; } -.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); } -.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); } .panel-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; - font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); + padding: 8px 18px; + background: var(--color-surface-subtle); + border-top: var(--card-border); + font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3); } .panel-empty { display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center; @@ -198,8 +242,8 @@ .share { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 10px 14px; - border: 1px solid var(--color-line); border-radius: 12px; - background: var(--color-surface-subtle); + border: var(--card-border); border-radius: var(--card-radius-sm); + background: var(--card-bg-subtle); } .share .donut-key { align-self: center; } .share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); } @@ -236,8 +280,8 @@ .quick-list { display: flex; flex-direction: column; gap: 8px; } .quick { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; - padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px; - text-decoration: none; background: var(--color-surface); + padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm); + text-decoration: none; background: var(--card-bg); } .quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); } @@ -277,13 +321,8 @@ .chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; } .chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } -.search { - display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px; - min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px; - background: var(--color-surface); color: var(--color-ink-4); -} -.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); } -.search:focus-within { border-color: var(--color-brand); } +/* `.search` was here — the Console and Counters each drew a label-wrapping-an- + input version of the search box. Both use `components/SearchInput` now. */ /* ── Segmented control ──────────────────────────────────────────────────── */ .seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); } @@ -303,7 +342,7 @@ .attention-card { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px; - align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px; + align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius); } .attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; } .attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; } @@ -324,8 +363,8 @@ /* ── Loading ────────────────────────────────────────────────────────────── */ @keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } } .sk-card, .sk-chart { - border: 1px solid var(--color-line); border-radius: 16px; - background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite; + border: var(--card-border); border-radius: var(--card-radius); + background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite; } .sk-card { height: 104px; } .sk-chart { height: 300px; } diff --git a/src/features/console/sections/BranchOverview.tsx b/src/features/console/sections/BranchOverview.tsx index a074ba6..b3fc277 100644 --- a/src/features/console/sections/BranchOverview.tsx +++ b/src/features/console/sections/BranchOverview.tsx @@ -29,7 +29,7 @@ export interface BranchOverviewProps { export function BranchOverview({ rows, onSelect }: BranchOverviewProps) { if (rows.length === 0) { return ( - + No branches are set up yet. @@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) { ); return ( - +
diff --git a/src/features/console/sections/KpiStrip.tsx b/src/features/console/sections/KpiStrip.tsx index 3aea882..c08937d 100644 --- a/src/features/console/sections/KpiStrip.tsx +++ b/src/features/console/sections/KpiStrip.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from 'react'; import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react'; +import { KpiCard } from '@/components/KpiCard'; import { count, money } from '@/features/store-admin/format'; import type { ConsoleTotals } from '../consoleModel'; @@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) { return (
- } label="Online Sales" value={money(totals.onlineRevenue)} /> - } label="Counter Sales" value={money(totals.counterRevenue)} /> - } label="Online Sales" value={money(totals.onlineRevenue)} /> + } label="Counter Sales" value={money(totals.counterRevenue)} /> + } label="Total Orders" value={count(totals.totalOrders)} note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`} /> - } label="Cancelled Orders" value={count(totals.cancelled)} @@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) { ? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` } : {})} /> - } label="Unsynced Bills" value={count(totals.pendingBills)} @@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) { ); } -function Kpi({ - icon, label, value, note, -}: { - icon: ReactNode; - label: string; - value: string; - note?: string; -}) { - return ( -
- {icon} -
- {label} - {value} - {note ? {note} : null} -
-
- ); -} diff --git a/src/features/console/sections/ShopSummary.tsx b/src/features/console/sections/ShopSummary.tsx index 58db3f9..7c51d54 100644 --- a/src/features/console/sections/ShopSummary.tsx +++ b/src/features/console/sections/ShopSummary.tsx @@ -29,7 +29,7 @@ export interface ShopSummaryProps { export function ShopSummary({ row, shop, base }: ShopSummaryProps) { if (!row) { return ( - + No branch is selected. @@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) { const total = row.onlineRevenue + row.counterRevenue; return ( - + diff --git a/src/features/console/sections/TillSync.tsx b/src/features/console/sections/TillSync.tsx index c04c6a6..7672293 100644 --- a/src/features/console/sections/TillSync.tsx +++ b/src/features/console/sections/TillSync.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Search } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { branchLabel, count, money } from '@/features/store-admin/format'; import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus'; import type { BranchRow, HealthTone } from '../consoleModel'; @@ -111,15 +111,13 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
- + diff --git a/src/features/nearle-admin/PartnerAssignDrawer.tsx b/src/features/nearle-admin/PartnerAssignDrawer.tsx index a4079e1..6d66526 100644 --- a/src/features/nearle-admin/PartnerAssignDrawer.tsx +++ b/src/features/nearle-admin/PartnerAssignDrawer.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, Check, Truck } from 'lucide-react'; import { errorMessage } from '@/api/client'; @@ -8,7 +7,7 @@ import { tenantsApi } from '@/api/tenants'; import { useAllPartners, useAppRegions } from '@/queries/hooks'; import { queryKeys } from '@/queries/keys'; import { Drawer } from '@/features/store-admin/Drawer'; -import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; +import { Alert, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; /** * Which delivery partner supplies a merchant's riders. @@ -89,11 +88,7 @@ export function PartnerAssignDrawer({ } > - {error ? ( - - {error} - - ) : null} + {error ? {error} : null} }> A partner supplies riders to this shop. With one set, the assign screen offers the diff --git a/src/features/nearle-admin/PartnerRidersDrawer.tsx b/src/features/nearle-admin/PartnerRidersDrawer.tsx index 12be2c5..2f51b39 100644 --- a/src/features/nearle-admin/PartnerRidersDrawer.tsx +++ b/src/features/nearle-admin/PartnerRidersDrawer.tsx @@ -1,12 +1,17 @@ import { useState } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; import { Bike, Plus } from 'lucide-react'; import type { Partner, RiderRosterRow } from '@/api/deliveries'; import { usePartnerRiders } from '@/queries/hooks'; import { Drawer } from '@/features/store-admin/Drawer'; -import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit'; +import { + Badge, + DrawerButton, + DrawerCard, + LinkAction, + Note, + Row, + Section, +} from '@/features/store-admin/drawerKit'; import { RiderDrawer } from '@/features/store-admin/RiderDrawer'; /** @@ -59,7 +64,11 @@ export function PartnerRidersDrawer({ } > - + {/* No `VStack` around this. The drawer body IS a column with the + system's own gap; a second stack inside it set a second, different + one, which is why this drawer's sections sat closer together than + every other drawer's. */} + <> }> These riders deliver for every merchant this partner supplies. A rider hired here does not appear in the on-duty fleet until they open the rider app and start a shift — that @@ -67,9 +76,7 @@ export function PartnerRidersDrawer({ {riders.isLoading ? ( - - Reading riders… - + Reading riders… ) : rows.length === 0 ? (
@@ -88,17 +95,17 @@ export function PartnerRidersDrawer({ key={rider.userid} label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`} value={ - + -
)} -
+ {editing ? ( diff --git a/src/features/nearle-admin/ShiftsPanel.tsx b/src/features/nearle-admin/ShiftsPanel.tsx index 794acd4..f9eae5c 100644 --- a/src/features/nearle-admin/ShiftsPanel.tsx +++ b/src/features/nearle-admin/ShiftsPanel.tsx @@ -139,7 +139,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
- + Where the fleet is registered @@ -162,7 +162,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) { - + The day, rider by rider @@ -202,7 +202,7 @@ export function ShiftsPanel({ partnerid }: { partnerid: number }) {
- + diff --git a/src/features/nearle-admin/pages/OnboardTenantPage.tsx b/src/features/nearle-admin/pages/OnboardTenantPage.tsx index 25bcb96..cc0149f 100644 --- a/src/features/nearle-admin/pages/OnboardTenantPage.tsx +++ b/src/features/nearle-admin/pages/OnboardTenantPage.tsx @@ -138,7 +138,7 @@ export function OnboardTenantPage() { - + @@ -153,7 +153,7 @@ export function OnboardTenantPage() { {created?.tenantid && created?.locationid ? ( - + {/* Section 1: Business Details Card */} - +
{/* Section 2: Head Office Card */} - +
{/* Partners Table Card */} - + } + >
- -
+ {/* Drawers */} {ridersFor ? ( @@ -553,14 +557,15 @@ function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose: District -
{shown.map((option) => { diff --git a/src/features/nearle-admin/pages/StoreDetailPage.tsx b/src/features/nearle-admin/pages/StoreDetailPage.tsx index 50a9db3..6b4fec3 100644 --- a/src/features/nearle-admin/pages/StoreDetailPage.tsx +++ b/src/features/nearle-admin/pages/StoreDetailPage.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { Badge } from '@astryxdesign/core/Badge'; import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { Text } from '@astryxdesign/core/Text'; @@ -12,10 +11,10 @@ import { DataState } from '@/components/DataState'; import { Freshness } from '@/components/Freshness'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; -import { SectionHeader } from '@/components/SectionHeader'; import { useOrderSummary, useOrders, useTenantLocations, useTenants } from '@/queries/hooks'; import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats'; import type { TenantInfo, TenantLocation } from '@/api/types'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { Drawer } from '@/features/store-admin/Drawer'; @@ -279,12 +278,13 @@ export function StoreDetailPage() {
- } - /> - - + count={paged.total} + countLabel="branches" + actions={} + footer={} + >
- -
+
{isPartnerOpen && tenantid ? ( diff --git a/src/features/nearle-admin/pages/StoresPage.tsx b/src/features/nearle-admin/pages/StoresPage.tsx index 9ac7b97..f2f860a 100644 --- a/src/features/nearle-admin/pages/StoresPage.tsx +++ b/src/features/nearle-admin/pages/StoresPage.tsx @@ -6,13 +6,14 @@ import { Card } from '@astryxdesign/core/Card'; import { Table, type TableColumn } from '@astryxdesign/core/Table'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Building2, Plus, Store, Users } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { DataState } from '@/components/DataState'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; -import { SectionHeader } from '@/components/SectionHeader'; +import { Tab, TabBar } from '@/components/TabBar'; +import { Panel } from '@/components/Panel'; import { useTenants, useTenantsByApproval } from '@/queries/hooks'; import type { TenantInfo } from '@/api/types'; @@ -240,43 +241,66 @@ export function StoresPage() { /> - - + setTab('directory')} /> - setTab('pending')} /> - + {tab === 'pending' ? ( ) : ( - - - + + Page {page} + {search ? ` · filtered from ${rows.length} on this page` : ''} + + +
@@ -409,59 +409,7 @@ function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: bool ); } -function TabButton({ - label, - isActive, - onClick, - badge, -}: { - label: string; - isActive: boolean; - onClick: () => void; - badge?: number; -}) { - return ( - - ); -} +/* The page-local `TabButton` lived here — see `components/TabBar`. */ function Th({ children }: { children?: React.ReactNode }) { return ( diff --git a/src/features/nearle-admin/pages/fleet.css b/src/features/nearle-admin/pages/fleet.css index d460fca..b02ba1e 100644 --- a/src/features/nearle-admin/pages/fleet.css +++ b/src/features/nearle-admin/pages/fleet.css @@ -2,20 +2,37 @@ * The fleet page: a banner, a map beside a gantt, and the gantt itself. */ +/* The console's small control: a branch picker, a rider-partner picker and the + Shifts day input all wear this. + + Its own shape before was 32px at a 7px corner — a seventh radius in an app + that has one. It takes the search box's measurements now, which matters most + on Inventory, where a `.fleet-select` branch picker sits immediately beside a + `SearchInput`: the two were a different height AND a different corner, six + pixels apart. */ .fleet-select { - height: 32px; + height: 38px; padding: 0 10px; - border: 1px solid var(--color-border); - border-radius: 7px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius-sm); + background: var(--card-bg); + box-shadow: var(--card-shadow); font: inherit; font-size: 13px; color: var(--color-ink-1); + transition: border-color 160ms ease, box-shadow 160ms ease; } +.fleet-select:hover { border-color: var(--color-ink-4); } + +/* The same focus treatment as the search box — a brand border and a 3px tint + ring, not the global 2px outline, so two controls sitting side by side do + not light up in different ways. */ +.fleet-select:focus, .fleet-select:focus-visible { - outline: 2px solid var(--color-brand); - outline-offset: 1px; + outline: none; + border-color: var(--color-brand); + box-shadow: 0 0 0 3px var(--color-brand-tint); } /* ── The recommendation banner ───────────────────────────────────────────── */ @@ -30,8 +47,8 @@ padding: 12px 14px; border: 1px solid var(--color-border); border-left-width: 3px; - border-radius: 9px; - background: var(--color-surface); + border-radius: var(--card-radius); + background: var(--card-bg); } .fleet-banner > svg { diff --git a/src/features/onboarding/OnboardingPage.tsx b/src/features/onboarding/OnboardingPage.tsx index 9615bac..57f386e 100644 --- a/src/features/onboarding/OnboardingPage.tsx +++ b/src/features/onboarding/OnboardingPage.tsx @@ -231,6 +231,23 @@ export function OnboardingPage() { persist(completeStep(state, step, nextOf(step))); } + /* + Each step of the wizard starts at the top of itself. + + The steps are STATE, not routes, so the shell's own reset — which keys on the + pathname — never fires here: the URL is `/admin/onboarding` for all seven of + them. Finishing the inventory step from the bottom of a long list and + pressing Save & continue therefore opened the next step already scrolled past + its heading and its explanation, which is exactly the part a person setting + up a shop for the first time needs to read. + + Instant, for the same reason the shell's is: this replaces the whole page + body, so there is nothing continuous for a smooth travel to connect. + */ + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + }, [step]); + const heading = HEADINGS[step]; const isFirst = step === 'welcome'; const isLast = step === 'done'; diff --git a/src/features/onboarding/steps/CatalogueStep.tsx b/src/features/onboarding/steps/CatalogueStep.tsx index c0234cc..42564bf 100644 --- a/src/features/onboarding/steps/CatalogueStep.tsx +++ b/src/features/onboarding/steps/CatalogueStep.tsx @@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte return ( {productCount > 0 ? ( - + You already have {productCount} product{productCount === 1 ? '' : 's'} diff --git a/src/features/onboarding/steps/DeliveryStep.tsx b/src/features/onboarding/steps/DeliveryStep.tsx index bcb0277..421c961 100644 --- a/src/features/onboarding/steps/DeliveryStep.tsx +++ b/src/features/onboarding/steps/DeliveryStep.tsx @@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) { {/* Only when it applies. Nothing below exists for a collection-only shop, and rendering it disabled would be showing work that is not theirs. */} {value.offersDelivery === true ? ( - + diff --git a/src/features/onboarding/steps/InventoryStep.tsx b/src/features/onboarding/steps/InventoryStep.tsx index 76c0a8c..3dd75ef 100644 --- a/src/features/onboarding/steps/InventoryStep.tsx +++ b/src/features/onboarding/steps/InventoryStep.tsx @@ -53,7 +53,7 @@ const TIPS = [ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) { return ( - + @@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
- + @@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro - + Tips diff --git a/src/features/onboarding/steps/StoreInfoStep.tsx b/src/features/onboarding/steps/StoreInfoStep.tsx index aa4b19a..65a4db7 100644 --- a/src/features/onboarding/steps/StoreInfoStep.tsx +++ b/src/features/onboarding/steps/StoreInfoStep.tsx @@ -158,7 +158,7 @@ function Group({ children: React.ReactNode; }) { return ( - + diff --git a/src/features/onboarding/steps/WelcomeStep.tsx b/src/features/onboarding/steps/WelcomeStep.tsx index d4988fc..78da3fb 100644 --- a/src/features/onboarding/steps/WelcomeStep.tsx +++ b/src/features/onboarding/steps/WelcomeStep.tsx @@ -113,7 +113,7 @@ export function WelcomeStep({
{benefits.map(({ icon: Icon, title, body }) => ( - + diff --git a/src/features/qr/StoreQrPanel.tsx b/src/features/qr/StoreQrPanel.tsx index 9e6ad24..6b87aeb 100644 --- a/src/features/qr/StoreQrPanel.tsx +++ b/src/features/qr/StoreQrPanel.tsx @@ -110,9 +110,9 @@ export function StoreQrPanel({
diff --git a/src/features/store-admin/ActivePanel.tsx b/src/features/store-admin/ActivePanel.tsx index 4c314e6..d97b961 100644 --- a/src/features/store-admin/ActivePanel.tsx +++ b/src/features/store-admin/ActivePanel.tsx @@ -130,7 +130,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) { return ( - + @@ -164,7 +164,7 @@ export function ActivePanel({ stops }: { stops: readonly Stop[] }) { const rider = entry.live; const worries = concerns(rider); return ( - + diff --git a/src/features/store-admin/CounterDrawer.tsx b/src/features/store-admin/CounterDrawer.tsx index 924cfbe..91a7e9f 100644 --- a/src/features/store-admin/CounterDrawer.tsx +++ b/src/features/store-admin/CounterDrawer.tsx @@ -1,13 +1,20 @@ import { useState } from 'react'; -import { Button } from '@astryxdesign/core/Button'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { VStack } from '@astryxdesign/core/VStack'; -import { EyeOff } from 'lucide-react'; +import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react'; import { Drawer } from './Drawer'; +import { + Badge, + DrawerButton, + DrawerCard, + Field, + Metric, + Metrics, + Note, + Row, + Section, + TextField, +} from './drawerKit'; import type { CounterLabels } from './counterLabels'; -import type { CounterRow } from './counterRows'; +import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows'; import { money } from './format'; import { shortAge } from './posStatus'; @@ -21,6 +28,19 @@ import { shortAge } from './posStatus'; * Rename and hide live here rather than on the row. They are rare actions, and * a control on every row of a fifteen-row table is fifteen chances to hide a * counter by mistake. + * + * ── What this used to get wrong ───────────────────────────────────────────── + * + * The table's Status column — Active, Attention, Offline, Never used — was the + * one reading this drawer did not repeat, so the chip that made somebody open a + * counter vanished the moment they did. It is in the header now, from the same + * `state` the row chips from, and the named problem behind an "Attention" is + * stated rather than left as a colour. + * + * The readings were also drawn by a `.reading-list` belonging to the counters + * page stylesheet, which is why this drawer had a different row height, label + * weight and hairline from every other drawer in the console. It is built from + * the kit now, like the rest. */ export interface CounterDrawerProps { row: CounterRow; @@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) { labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '', ); const status = row.status; - - const readings: [string, string][] = [ - ['Counter code', row.terminalId], - ['Branch', row.branchName], - ['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`], - ]; - - if (status) { - readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]); - readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']); - readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]); - // The number that explains a revenue figure reading low, so it is stated - // even when it is zero rather than hidden as "nothing to report". - readings.push(['Waiting to send', String(status.pendingBills)]); - if (status.reason) readings.push(['Reported', status.reason]); - } else { - readings.push(['Status', 'This counter has never reported in.']); - } + const isNamed = labels.isNamed(row.terminalId); return ( - -
- {readings.map(([label, value]) => ( -
- {label} - {value} -
- ))} -
- - - + + {isNamed ? {row.branchName} : null} + + } + isFooterSpread + footer={ + <> + } + onClick={() => { + labels.hide(row.terminalId); + onClose(); + }} /> - - - ); -} diff --git a/src/features/store-admin/PlanVsActualPanel.tsx b/src/features/store-admin/PlanVsActualPanel.tsx index 248569e..c4f19d1 100644 --- a/src/features/store-admin/PlanVsActualPanel.tsx +++ b/src/features/store-admin/PlanVsActualPanel.tsx @@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Info } from 'lucide-react'; import type { DeliveryRow } from '@/api/types'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { compare, journeyOf, lateness, span, type StepKey } from './plannedVsActual'; @@ -63,7 +64,7 @@ export function PlanVsActualPanel({ if (isLoading) { return ( - + Reading the day… @@ -75,7 +76,7 @@ export function PlanVsActualPanel({ if (finished.length === 0) { return ( - + Nothing finished in this range @@ -94,7 +95,7 @@ export function PlanVsActualPanel({ return ( {/* ── The promise ────────────────────────────────────────────────── */} - + Against the promise @@ -134,7 +135,7 @@ export function PlanVsActualPanel({ {/* ── Where the time goes ────────────────────────────────────────── */} - + @@ -191,7 +192,7 @@ export function PlanVsActualPanel({ {/* ── Distance ───────────────────────────────────────────────────── */} - + Distance @@ -222,7 +223,12 @@ export function PlanVsActualPanel({ {/* ── The deliveries themselves ──────────────────────────────────── */} - + } + > @@ -298,8 +304,7 @@ export function PlanVsActualPanel({
- -
+ ); } diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index 6f739c1..faaa2fa 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -1,10 +1,15 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react'; +import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react'; import { productsApi } from '@/api/products'; -import type { CatalogueProduct, Product } from '@/api/types'; +import type { CatalogueProduct, Product, StockRequest } from '@/api/types'; +/* A pure reading of a `StockRequest`, not a Store user component — the same + three functions the Store user's table chips from, so the row and this + drawer cannot label one request two ways. */ +import { REQUEST_COLOR, REQUEST_LABEL, requestState } from '@/features/store-user/requestState'; import { queryKeys } from '@/queries/keys'; import { useAppAisles, useCatalogueProduct } from '@/queries/hooks'; +import { aisleLabelOf, UNCATEGORISED_AISLE } from './appAisle'; import { useBranchScope } from './BranchScope'; import { Drawer } from './Drawer'; import { @@ -57,9 +62,23 @@ import { export function ProductDrawer({ product, canManage = true, + request, onClose, }: { product: Product; + /** + * The branch's latest stock request for this product, where there is one. + * + * Passed in rather than fetched: the Store user's catalogue already holds + * every request for the page — `latestByProduct` — and a second read here + * would be a request per drawer open for data already in hand. It is absent + * in the merchant's console, which has no "ask for stock" at all. + * + * It is here because the row carries a Stock request column, and a drawer + * that drops a column the row shows makes the reader go back to the list to + * find out what they were just looking at. + */ + request?: StockRequest | undefined; /** * False in the Store user workspace. * @@ -77,11 +96,20 @@ export function ProductDrawer({ const [shot, setShot] = useState(0); /* The aisle name for this product's subcategoryid, from the same list the - customer app groups by. */ + customer app groups by — and through the same helper the products table + prints with, because the two used to disagree: the row fell back to the + product's own `categoryname` when the platform list had no entry, and this + drawer did not, so one product read "Dairy" in the list and + "Uncategorized" in the sheet opened from it. */ const aisles = useAppAisles(tenantid || undefined); - const aisleName = - (aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ?? - null; + const aisleNames = useMemo(() => { + const out = new Map(); + for (const row of aisles.data ?? []) { + if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname); + } + return out; + }, [aisles.data]); + const aisle = aisleLabelOf(product, aisleNames); /* What the catalogue said, preferred from the product's own copy. @@ -115,6 +143,18 @@ export function ProductDrawer({ }); + /* The row's chip, rebuilt from the same reading of the request the table + uses. Quantity is stated because "Asked for" without a number is the half + of the fact nobody needs. */ + const requestStateValue = requestState(request); + const requestChip = requestStateValue + ? { + label: REQUEST_LABEL[requestStateValue], + colour: REQUEST_COLOR[requestStateValue], + detail: `${count(request?.qty ?? 0)} ${[product.unitvalue, product.productunit].filter(Boolean).join(' ') || 'units'}`, + } + : null; + const price = effectivePrice(product); const cost = product.productcost ?? 0; /* Margin is derived, not stored. Shown only when both halves are real — a @@ -238,15 +278,32 @@ export function ProductDrawer({ cosmetic default here: it is the heading the app will print. */} } - {...(aisleName ? {} : { tone: 'muted' as const })} + {...(aisle === UNCATEGORISED_AISLE ? { tone: 'muted' as const } : {})} /> } /> + {/* The row's own Stock request column, repeated here rather than + dropped. Same chip, same wording, same quantity — it is read from + the same `requestState` the table reads. */} + {requestChip ? ( + + + {requestChip.detail} +
+ } + icon={} + /> + ) : null}
diff --git a/src/features/store-admin/ProductsPanel.tsx b/src/features/store-admin/ProductsPanel.tsx index 5ae626d..4abaee7 100644 --- a/src/features/store-admin/ProductsPanel.tsx +++ b/src/features/store-admin/ProductsPanel.tsx @@ -1,14 +1,14 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Selector } from '@astryxdesign/core/Selector'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { ImageOff } from 'lucide-react'; import type { Product } from '@/api/types'; -import { SectionHeader } from '@/components/SectionHeader'; +import { Panel } from '@/components/Panel'; import { useAppAisles, useLocationProducts } from '@/queries/hooks'; +import { aisleLabelOf } from './appAisle'; import { useBranchScope } from './BranchScope'; import { count, money } from './format'; import { ProductDrawer } from './ProductDrawer'; @@ -133,25 +133,26 @@ export function ProductsPanel({ [aisles.data], ); + /* The panel's own header owns the category filter now, rather than a + `SectionHeader` floating above the card. It filters THIS table; sitting + outside the panel it read as a page-level control that happened to be + nearby. */ + const filter = ( +
+ +
+ ); + return ( - - - - } - /> - {selection.count > 0 ? (
@@ -180,6 +181,7 @@ export function ProductsPanel({ selection={selection} queryKey={category} isLoading={products.isLoading} + actions={filter} onOpen={setOpen} onPrice={(product) => setPublishing([product])} /> @@ -222,6 +224,7 @@ function ProductTable({ aisleNames, selection, isLoading, + actions, onOpen, onPrice, queryKey, @@ -232,6 +235,8 @@ function ProductTable({ /** The tick state, owned by the panel so its bar can act on the choice. */ selection: ReturnType; isLoading: boolean; + /** The category filter, rendered in the panel header beside the count. */ + actions: ReactNode; onOpen: (product: Product) => void; onPrice: (product: Product) => void; /** The category filter; changing it returns the pager to page 1. */ @@ -242,20 +247,18 @@ function ProductTable({ if (isLoading) { return ( - - - - Reading your catalogue… - - - + + + Reading your catalogue… + + ); } if (rows.length === 0) { return ( - - + + Nothing here yet @@ -265,12 +268,18 @@ function ProductTable({ were expecting rows. - + ); } return ( - + } + >
{/* `table-layout: fixed` with an explicit colgroup. Without it the browser sizes every column to its content, so Category and Status @@ -342,8 +351,7 @@ function ProductTable({
- -
+ ); } @@ -445,11 +453,9 @@ function Row({ > {/* The aisle id is on the row; the NAME is not — `getlocationproducts` selects `a.*` and joins no subcategory table — so it is looked up - from the platform list the app itself groups by. */} - {aisleNames.get(product.subcategoryid ?? 0) ?? - product.categoryname ?? - product.Subcategoryname ?? - 'Uncategorized'} + from the platform list the app itself groups by. The drawer prints + the same string from the same helper, or the two disagree. */} + {aisleLabelOf(product, aisleNames)} diff --git a/src/features/store-admin/ProfitPanel.tsx b/src/features/store-admin/ProfitPanel.tsx index f922aa2..dc34982 100644 --- a/src/features/store-admin/ProfitPanel.tsx +++ b/src/features/store-admin/ProfitPanel.tsx @@ -5,6 +5,7 @@ import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, Info } from 'lucide-react'; import type { DeliveryRow } from '@/api/types'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { coverage, profitability } from './profitability'; @@ -48,7 +49,7 @@ export function ProfitPanel({ if (isLoading) { return ( - + Reading the day… @@ -60,7 +61,7 @@ export function ProfitPanel({ if (result.deliveries === 0) { return ( - + Nothing finished in this range @@ -76,7 +77,7 @@ export function ProfitPanel({ return ( - + @@ -129,7 +130,7 @@ export function ProfitPanel({ - + By rider @@ -167,7 +168,12 @@ export function ProfitPanel({
- + } + > Delivery by delivery @@ -211,8 +217,7 @@ export function ProfitPanel({ - - +
diff --git a/src/features/store-admin/PublishDialog.tsx b/src/features/store-admin/PublishDialog.tsx index 1c5bc3b..dfc1418 100644 --- a/src/features/store-admin/PublishDialog.tsx +++ b/src/features/store-admin/PublishDialog.tsx @@ -330,7 +330,7 @@ export function PublishDialog({ products, tenantid, branchCount, onClose }: Publ value={ 0 ? 'var(--color-warning, #b7860b)' : '#0f8a5f'} + colour={failed.length > 0 ? 'var(--color-warning, #b7860b)' : 'var(--color-success, #1f9d55)'} /> } {...(failed.length === 0 ? { tone: 'success' as const } : {})} diff --git a/src/features/store-admin/RiderDrawer.tsx b/src/features/store-admin/RiderDrawer.tsx index 0f0ffda..f332495 100644 --- a/src/features/store-admin/RiderDrawer.tsx +++ b/src/features/store-admin/RiderDrawer.tsx @@ -1,19 +1,26 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Selector } from '@astryxdesign/core/Selector'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Bike, Info } from 'lucide-react'; +import { Bike, Building2, CircleDot } from 'lucide-react'; import { errorMessage } from '@/api/client'; import { ridersApi, type RiderRosterRow } from '@/api/deliveries'; import type { TenantLocation } from '@/api/types'; import { useRiderShifts } from '@/queries/hooks'; import { queryKeys } from '@/queries/keys'; import { Drawer } from './Drawer'; -import { DrawerButton } from './drawerKit'; -import './pages/deliveries.css'; +import { + Alert, + Badge, + DrawerButton, + DrawerCard, + Field, + FieldRow, + Note, + Row, + Section, + SelectField, + TextField, +} from './drawerKit'; +import { branchLabel } from './format'; /** * Hiring a rider, or editing one. @@ -30,6 +37,15 @@ import './pages/deliveries.css'; * and start a shift, because that list requires a clock-in stamped today. That * is correct behaviour and it looks exactly like a failed save, so the drawer * says it before you press the button rather than leaving you to wonder. + * + * ── What it used to leave out ─────────────────────────────────────────────── + * + * The roster table carries Partner and On shift, and this drawer carried + * neither — so opening a rider to check who they ride for lost the answer the + * row had just given. Both are FACTS rather than fields: whose rider this is + * comes from the scope the drawer was opened in, and being on shift means a + * clock-in stamped today, which the console cannot set. They are stated at the + * top, above the things that can actually be typed. */ /** * Whose rider this is. @@ -140,12 +156,34 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) { applocationid > 0 && !save.isPending; + /* Whose rider this is, in the words the roster's Partner column uses. An own + rider reads "Own rider" there and here, rather than an empty cell in one + place and nothing at all in the other. */ + const partnerName = + owner.kind === 'partner' ? owner.partnername : (row?.partnername ?? 'Own rider'); + return ( + ), + })} isFooterSpread footer={ <> @@ -160,46 +198,61 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) { } > - - {/* The region is stated, not offered. If the branch has none, saying so - here beats a 400 after the form is filled in. */} - {applocationid === 0 ? ( -
- - - This branch has no delivery region set, so a rider added to it would not appear - anywhere. Set the region on the branch first. - -
- ) : null} + {problem ? {problem} : null} - - - - - - + This branch has no delivery region set, so a rider added to it would not appear anywhere. + Set the region on the branch first. + + ) : null} + + {/* ── What the roster row said ─────────────────────────────────────── + Read-only, because neither can be typed: ownership comes from the + scope this drawer was opened in, and being on shift means a clock-in + stamped today, which only the rider app writes. */} + {isNew ? null : ( + + } /> + } + {...(row.isonduty ? { tone: 'success' as const } : { tone: 'muted' as const })} /> - - + + )} - - - Shift and partner - - + + + + + + + + + + + + + + + + + + +
+ + set('shiftid')(String(value))} - options={(shifts.data ?? []).map((shift) => ({ - value: String(shift.shiftid), - label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`, - }))} + onChange={set('shiftid')} placeholder={ shifts.isLoading ? 'Loading shifts…' @@ -207,94 +260,89 @@ export function RiderDrawer({ row, owner, branch, onClose }: RiderDrawerProps) { ? 'Choose a shift' : 'No shifts set up for this region' } + options={(shifts.data ?? []).map((shift) => ({ + value: String(shift.shiftid), + label: shift.shiftname || `${shift.starttime} – ${shift.endtime}`, + }))} /> - {/* A merchant's rider works out of one of that merchant's branches. - A partner's does not — a partner supplies several merchants and is - tied to none of their outlets — so the question is only asked - where it has an answer. */} - {owner.kind === 'tenant' ? ( - + + {/* A merchant's rider works out of one of that merchant's branches. + A partner's does not — a partner supplies several merchants and is + tied to none of their outlets — so the question is only asked + where it has an answer. */} + {owner.kind === 'tenant' ? ( + + set('locationid')(String(value))} + onChange={set('locationid')} + placeholder="Choose the branch they ride from" options={owner.branches.map((entry) => ({ value: String(entry.locationid), - label: entry.locationname || `Branch ${entry.locationid}`, + label: branchLabel(entry.locationname) || `Branch ${entry.locationid}`, }))} - placeholder="Choose the branch they ride from" - description="Which of your outlets this rider works out of. It can be changed later." /> - ) : ( -
- - - Rides for {owner.partnername}, and delivers for every merchant that - partner supplies in this region. - -
- )} - +
+ ) : ( + + Rides for {owner.partnername}, and delivers for every merchant that + partner supplies in this region. + + )} +
- - - Vehicle and papers - - - + + + - - - - - + + + + + + + + + + + + + + + + + + {!isNew ? ( +
+ + - - - + +
+ ) : null} - {!isNew ? ( - set('status')(String(value))} - options={[ - { value: 'Active', label: 'Active' }, - { value: 'InActive', label: 'Inactive — cannot be given work' }, - ]} - /> - ) : null} - - {problem ? ( - - {problem} - - ) : null} - - {/* Said before the button, not after. This is the behaviour most likely - to be read as a failed save. */} - {isNew ? ( -
- - - They will show here straight away, but not in the fleet you assign deliveries from - until they sign in to the rider app and start a shift. - -
- ) : null} -
+ {/* Said before the button, not after. This is the behaviour most likely + to be read as a failed save. */} + {isNew ? ( + + They will show here straight away, but not in the fleet you assign deliveries from until + they sign in to the rider app and start a shift. + + ) : null}
); } diff --git a/src/features/store-admin/RoutePlanDrawer.tsx b/src/features/store-admin/RoutePlanDrawer.tsx index 7a92daf..7f81628 100644 --- a/src/features/store-admin/RoutePlanDrawer.tsx +++ b/src/features/store-admin/RoutePlanDrawer.tsx @@ -11,7 +11,7 @@ import { optimiserApi, type SequencedStop } from '@/api/optimiser'; import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types'; import { queryKeys } from '@/queries/keys'; import { Drawer } from './Drawer'; -import { DrawerButton } from './drawerKit'; +import { Alert, DrawerButton } from './drawerKit'; import { buildDeliveries, riderName } from './assignDelivery'; import { money } from './format'; import { orderValue } from './orderStatus'; @@ -271,11 +271,7 @@ export function RoutePlanDrawer({
) : null} - {problem ? ( - - {problem} - - ) : null} + {problem ? {problem} : null} {outcome ? ( diff --git a/src/features/store-admin/appAisle.test.ts b/src/features/store-admin/appAisle.test.ts index 84558a0..24e3831 100644 --- a/src/features/store-admin/appAisle.test.ts +++ b/src/features/store-admin/appAisle.test.ts @@ -13,6 +13,8 @@ import { aisleForCategory, aisleIdForCategory, aisleIdsFrom, + aisleLabelOf, + UNCATEGORISED_AISLE, } from './appAisle'; import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory'; @@ -89,3 +91,36 @@ test('every aisle has an id, and no two share one', () => { assert.equal(new Set(values).size, values.length); assert.ok(values.every((id) => id > 0)); }); + +/* ── The label a row and a drawer must agree on ───────────────────────────── */ + +// The bug this replaces: the table fell back to `categoryname`, the drawer did +// not, so one product read "Dairy" in the list and "Uncategorized" in the sheet +// opened from it. +test('the printed aisle prefers the platform list, then the row own names', () => { + const names = new Map([[15, 'Dairy, Deli & Egg']]); + assert.equal(aisleLabelOf({ subcategoryid: 15 }, names), 'Dairy, Deli & Egg'); + assert.equal( + aisleLabelOf({ subcategoryid: 99, categoryname: 'Dairy' }, names), + 'Dairy', + 'a subcategoryid the platform list does not know still has the row own name', + ); + assert.equal( + aisleLabelOf({ subcategoryid: 99, Subcategoryname: 'Snacks' }, names), + 'Snacks', + 'the misspelt wire field is read too', + ); +}); + +// `categoryname` comes back as '' on every row `getlocationproducts` returns, +// and `??` does not skip an empty string — it printed a blank cell. +test('a blank name is absent, not a label', () => { + const names = new Map(); + assert.equal(aisleLabelOf({ subcategoryid: 0, categoryname: '' }, names), 'Uncategorized'); + assert.equal(aisleLabelOf({ categoryname: ' ' }, names), 'Uncategorized'); + assert.equal(aisleLabelOf({}, names), 'Uncategorized'); +}); + +test('"Uncategorized" is the app own heading, spelt the app own way', () => { + assert.equal(UNCATEGORISED_AISLE, 'Uncategorized'); +}); diff --git a/src/features/store-admin/appAisle.ts b/src/features/store-admin/appAisle.ts index c912579..283371f 100644 --- a/src/features/store-admin/appAisle.ts +++ b/src/features/store-admin/appAisle.ts @@ -196,3 +196,44 @@ export function aisleIdForCategory( if (!aisle) return 0; return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle]; } + +/** + * The aisle name to PRINT for a product, on a row or in a drawer. + * + * This exists because the table and the drawer each grew their own version of + * it and they disagreed: the row fell back to `categoryname` when the platform + * list had no entry for the product's `subcategoryid`, and the drawer did not — + * so the same product read "Dairy" in the list and "Uncategorized" the moment + * you opened it. There is one answer to "where does the app show this", and it + * is here. + * + * Blank is treated as absent, which `??` alone does not do. `categoryname` is + * empty on every row `getlocationproducts` returns — the app's category 2 has + * no row in `productcategories` — and falling back to an empty string printed a + * blank cell where "Uncategorized" is the truth: it is the heading the customer + * app will actually render for a product with no aisle. + */ +export function aisleLabelOf( + product: { + subcategoryid?: number | undefined; + categoryname?: string | undefined; + Subcategoryname?: string | undefined; + }, + aisleNames: ReadonlyMap, +): string { + const named = aisleNames.get(product.subcategoryid ?? 0); + return ( + firstFilled(named, product.categoryname, product.Subcategoryname) ?? UNCATEGORISED_AISLE + ); +} + +/** The heading the customer app prints for anything with no aisle. */ +export const UNCATEGORISED_AISLE = 'Uncategorized'; + +function firstFilled(...values: (string | undefined)[]): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return undefined; +} diff --git a/src/features/store-admin/counterRows.ts b/src/features/store-admin/counterRows.ts index 39cca85..07b6a00 100644 --- a/src/features/store-admin/counterRows.ts +++ b/src/features/store-admin/counterRows.ts @@ -124,3 +124,42 @@ export function byUrgency(a: CounterRow, b: CounterRow): number { const rank: Record = { offline: 0, attention: 1, never: 2, active: 3 }; return rank[a.state] - rank[b.state]; } + +/* ── How a counter's state is named and coloured ──────────────────────────── */ + +/** + * The four states, in the words and colours the whole console uses for them. + * + * These lived as two private maps inside `CountersPage`, which is why the + * drawer opened from that table showed no status at all: the vocabulary was not + * reachable from it. A state has one name and one colour wherever it is + * printed — the table's pill, the attention card, the drawer's header badge. + */ +export const COUNTER_STATE_LABEL: Record = { + active: 'Active', + attention: 'Attention', + never: 'Never used', + offline: 'Offline', +}; + +/** The page's own pill tone, for `data-tone` in `counters.css`. */ +export const COUNTER_STATE_TONE: Record = { + active: 'healthy', + attention: 'attention', + never: 'neutral', + offline: 'critical', +}; + +/** + * The same four states as a colour, for the drawer badge. + * + * The badge takes a colour rather than a tone name because `drawerKit`'s + * `Badge` is fed one by every other drawer — statuses in this console come from + * several vocabularies and the shared badge deliberately knows none of them. + */ +export const COUNTER_STATE_COLOUR: Record = { + active: 'var(--color-success, #1f9d55)', + attention: 'var(--color-warning, #b7860b)', + never: 'var(--color-ink-4)', + offline: 'var(--color-error, #d64545)', +}; diff --git a/src/features/store-admin/dateRangePicker.css b/src/features/store-admin/dateRangePicker.css new file mode 100644 index 0000000..3a61278 --- /dev/null +++ b/src/features/store-admin/dateRangePicker.css @@ -0,0 +1,38 @@ +/* ══ The date filter ═══════════════════════════════════════════════════════ + The console's date range sits in the app header and, on Uploads, directly + beside a search box. It has to be the same object as the search and the view + tabs: 38px tall, `--card-radius-sm` at the corner, one hairline. + + ── Why this is four custom properties and not a rewrite ──────────────────── + + The control is Astryx's `DateRangeInput` — a trigger, a calendar popover, a + preset list and range logic. None of that is worth rebuilding to change a + corner, and none of it is what looked wrong. + + Astryx sets the trigger's height from `--size-element-*` and its corner from + `--radius-element`, both as CSS custom properties read at use time. So + redefining them on a wrapper reaches inside the component through the + cascade, which is the seam the design system deliberately leaves. No + selector here touches a StyleX class name — those are generated and would + break on the next release. + + The override is SCOPED to this wrapper on purpose. `--radius-element` is the + corner of every Astryx control in the app — buttons, selectors, inputs — and + they are 32px tall where this is 38. Changing the token globally would give + a 32px button a 10px corner it was not drawn for. */ + +.datefilter { + /* The picker is rendered `size="sm"`, so this is the one that applies; the + other two are set as well because the size prop is a call-site detail and + this wrapper should not care which one it is given. */ + --size-element-sm: 38px; + --size-element-md: 38px; + --size-element-lg: 38px; + --radius-element: var(--card-radius-sm); +} + +/* The header gives it a fixed width; on a phone the nav row wraps and a 180px + picker beside a branch picker leaves neither readable. */ +@media (max-width: 600px) { + .datefilter { width: 100%; } +} diff --git a/src/features/store-admin/drawer.css b/src/features/store-admin/drawer.css index 85085d0..d8f3737 100644 --- a/src/features/store-admin/drawer.css +++ b/src/features/store-admin/drawer.css @@ -1,11 +1,72 @@ /* ══ The drawer design system ══════════════════════════════════════════════ - One shell, one header, one card, one row, one badge, one button — used by - every side drawer in the console so they read as one product rather than as - several. Everything below is built on the tokens already in `index.css`: - #662582 is the Nearle purple, #0f172a the navy, and the ink scale supplies - the muted greys. No drawer defines a colour of its own. + One shell, one header, one card, one row, one badge, one button, one field — + used by every side drawer in the console so they read as one product rather + than as several. - Spacing is an 8px system throughout: 8 / 12 / 16 / 20 / 24. */ + ── What changed in this pass, and why ────────────────────────────────────── + + 1. EVERY SIZE IS A TOKEN NOW. The old sheet typed its own numbers at each + use — 13.5px here, 14.5px there, 11.5px somewhere else, four radii, three + control heights. Half-pixel type is what made the drawers look hand-made + beside the tables they open from: no two labels sat on the same baseline + and no two boxes shared a corner. There is one ramp below and nothing + outside it. + + 2. THE HEADER IS THE SURFACE, NOT THE BRAND. It used to be a bar of + saturated purple. That cost real information: a status badge cannot be + read in its own colour on a purple ground, so the badge had to be + re-invented there as a white chip with a coloured dot — the one badge in + the console that did not match the chip in the row it was opened from. A + white header with a hairline gives the badge its colour back, lets the + title sit in the same ink as the page, and stops the drawer announcing + itself louder than the data it carries. The brand still has the primary + button, the icons and the timeline. + + The header keeps its 57px, which is not cosmetic: `AppShell`'s nav bar is + a 56px row plus a 1px rule, so an open drawer's header finishes on exactly + the line the nav bar finishes on, edge to edge across the screen. + + 3. THE KIT COVERS FORMS. Seven drawers are forms and the kit had nothing for + them, so each reached for Astryx's inputs and its own inline styles. The + field, input, select, choice and alert rules below are that missing half. + + Everything reads from the brand tokens in `index.css`. No rule here invents + a colour. */ + +.drawer-root { + /* ── The ramp ────────────────────────────────────────────────────────── + Whole pixels, and a real ratio between the steps. Anything that needs a + size takes one of these. */ + --dr-text-xs: 11px; /* eyebrow caps: section titles, metric labels */ + --dr-text-sm: 12px; /* secondary: subtitles, notes under a value */ + --dr-text-md: 13px; /* body: row labels, help text, buttons */ + --dr-text-lg: 14px; /* emphasis: row values, field labels, titles */ + --dr-text-xl: 18px; /* the one figure a drawer is opened to read */ + + /* ── Spacing, on a 4px grid ─────────────────────────────────────────── */ + --dr-gap-xs: 4px; + --dr-gap-sm: 8px; + --dr-gap-md: 12px; + --dr-gap-lg: 16px; + --dr-gap-xl: 20px; + + /* ── Shape ──────────────────────────────────────────────────────────── + The card radius is the CONSOLE's card radius, not one of the drawer's + own. A card in a drawer and a card on the page behind it are the same + object seen in two places, and this file had them at 10 and 16. + + `--dr-radius-sm` stays a local number because it is a CONTROL radius — + the close button, an input, a photo thumbnail — not a small card. */ + --dr-radius: var(--card-radius); + --dr-radius-sm: 8px; + --dr-control: 36px; /* every button and input is this tall */ + --dr-pad-x: 20px; /* the panel's own gutter, used by head/body/foot */ + --dr-row-pad: 11px 14px; + + /* ── Lines ──────────────────────────────────────────────────────────── */ + --dr-line: var(--color-line); + --dr-line-soft: color-mix(in oklab, var(--color-line) 55%, transparent); +} /* ── Shell ──────────────────────────────────────────────────────────────── */ @@ -26,7 +87,7 @@ inset: 0; border: 0; padding: 0; - background: rgb(15 23 42 / 0.32); + background: rgb(15 23 42 / 0.28); cursor: pointer; animation: drawer-fade 180ms ease; } @@ -40,7 +101,10 @@ height: 100%; max-width: 92vw; background: var(--color-surface); - box-shadow: -24px 0 56px -12px rgb(15 23 42 / 0.18); + /* A hairline as well as the shadow. With a white header the panel needs an + edge of its own, or it bleeds into a white table behind it. */ + border-left: 1px solid var(--dr-line); + box-shadow: -32px 0 64px -24px rgb(15 23 42 / 0.22); animation: drawer-in 220ms cubic-bezier(0.32, 0.72, 0, 1); } @@ -59,36 +123,26 @@ /* ── Header ───────────────────────────────────────────────────────────────── - ONE BAR HIGH, and the same bar the page wears. + ONE BAR HIGH, and the same bar the page wears — see the note at the top of + this file for the 57 and for why it is no longer purple. - `AppShell` sets its nav bar to a 56px row plus a 1px rule, so the drawer's - header is 56 + 1 too: open a drawer and its header sits on exactly the line - the nav bar sits on, edge to edge across the screen, instead of a 116px block - starting level with it and finishing 60px lower. - - Getting there meant the header becoming a ROW rather than a stack: title and - subtitle share a line-and-a-half in the middle, the status badge sits beside - them, and the close button ends the row. Nothing was dropped to make it fit — - the same four things are in it. - - It is painted in the brand purple. The one place a drawer needs to announce - itself is the moment it slides in over the page, and the purple does that - without the panel needing a border or a heavier shadow. */ + It is a ROW rather than a stack: title and subtitle share a line and a half + in the middle, the status badge sits beside them, and the close button ends + the row. A stacked header was a third line, and a third line is what made + this twice the height of the nav bar it now matches. */ .drawer-head { flex: none; display: flex; align-items: center; - gap: 12px; + gap: var(--dr-gap-md); /* 57, not 56: box-sizing is border-box across the app, so the 1px rule below eats into the height. The nav bar is a 56px row PLUS its rule, and the two have to finish on the same pixel. */ height: 57px; - padding: 0 12px 0 20px; - background: var(--color-brand); - /* A darker purple, not the grey page rule: a grey seam across a saturated - ground reads as a rendering fault rather than an edge. */ - border-bottom: 1px solid var(--color-brand-strong); + padding: 0 var(--dr-gap-md) 0 var(--dr-pad-x); + background: var(--color-surface); + border-bottom: 1px solid var(--dr-line); } .drawer-head-text { @@ -106,42 +160,44 @@ full text is on the page behind it as well as in the tooltip. */ .drawer-title { margin: 0; - font: 600 15px/1.3 var(--font-sans); - letter-spacing: -0.005em; - color: #fff; + font: 600 var(--dr-text-lg)/1.3 var(--font-sans); + letter-spacing: -0.006em; + color: var(--color-ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* An order id is a code, and a code reads better tabular. */ -.drawer-title[data-mono='true'] { font-family: var(--font-mono); font-size: 14px; } +.drawer-title[data-mono='true'] { + font-family: var(--font-mono); + font-size: var(--dr-text-md); + letter-spacing: 0; +} .drawer-sub { - font: 400 11.5px/1.35 var(--font-sans); - /* Not a grey: a grey on purple goes muddy. White at 72% keeps the same - "quieter than the title" relationship the page's ink scale gives. */ - color: rgb(255 255 255 / 0.72); + font: 400 var(--dr-text-sm)/1.35 var(--font-sans); + color: var(--color-ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* Beside the title now, not under it — that stacked row is what made the - header three lines tall. It never wraps: a second badge shortens the title - rather than pushing the row taller. */ +/* Beside the title, not under it — that stacked row is what made the header + three lines tall. It never wraps: a second badge shortens the title rather + than pushing the row taller. */ .drawer-meta { display: flex; align-items: center; - gap: 8px; + gap: var(--dr-gap-sm); flex: none; flex-wrap: nowrap; } /* Capped, because the meta keeps its width and the title gives way. A branch named 'R mart - RS Puram' would otherwise eat the order id it sits beside. */ .drawer-meta-text { - font: 400 12px/1.4 var(--font-sans); - color: rgb(255 255 255 / 0.72); - max-width: 30ch; + font: 400 var(--dr-text-sm)/1.4 var(--font-sans); + color: var(--color-ink-4); + max-width: 24ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -150,18 +206,22 @@ .drawer-close { width: 30px; height: 30px; - border-radius: 9px; - border: 0; + border-radius: var(--dr-radius-sm); + border: 1px solid transparent; flex: none; display: grid; place-items: center; - background: rgb(255 255 255 / 0.14); - color: #fff; + background: transparent; + color: var(--color-ink-3); cursor: pointer; - transition: background 160ms ease; + transition: background 160ms ease, color 160ms ease, border-color 160ms ease; } -.drawer-close:hover { background: rgb(255 255 255 / 0.26); } -.drawer-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } +.drawer-close:hover { + background: var(--color-surface-sunken); + border-color: var(--dr-line); + color: var(--color-ink-1); +} +.drawer-close:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } /* ── Body ───────────────────────────────────────────────────────────────── */ @@ -169,11 +229,20 @@ flex: 1 1 auto; min-height: 0; overflow-y: auto; + /* Stops a scroll that reaches the end of the drawer from continuing into the + page underneath. The page is also locked outright while a drawer is open — + see `Drawer.tsx` — because this does nothing about a wheel over the scrim, + which is most of the screen. */ overscroll-behavior: contain; - padding: 20px 24px 24px; + /* Animates the moves the drawer makes for you: the uploads drawer's jump to + its rejection panel, and the browser's own scroll when you tab to a field + below the fold in a long form. The wheel is untouched. The global + reduced-motion reset in `index.css` turns this back to `auto`. */ + scroll-behavior: smooth; + padding: var(--dr-pad-x) var(--dr-pad-x) 24px; display: flex; flex-direction: column; - gap: 24px; + gap: var(--dr-gap-xl); } /* Panels that lay themselves out — a gallery, a form with its own rhythm. */ .drawer-body[data-bare='true'] { padding: 0; gap: 0; } @@ -197,9 +266,9 @@ display: flex; align-items: center; justify-content: flex-end; - gap: 8px; - padding: 12px 24px; - border-top: 1px solid var(--color-line); + gap: var(--dr-gap-sm); + padding: var(--dr-gap-md) var(--dr-pad-x); + border-top: 1px solid var(--dr-line); background: var(--color-surface); } .drawer-foot > * { flex: 0 1 auto; } @@ -212,23 +281,24 @@ /* ── Section ────────────────────────────────────────────────────────────── */ -.drawer-section { display: flex; flex-direction: column; gap: 10px; } +.drawer-section { display: flex; flex-direction: column; gap: var(--dr-gap-md); } .drawer-section-title { - font: 600 11.5px/1 var(--font-sans); - letter-spacing: 0.08em; + margin: 0; + font: 600 var(--dr-text-xs)/1 var(--font-sans); + letter-spacing: 0.075em; text-transform: uppercase; - color: var(--color-ink-3); + color: var(--color-ink-4); } /* ── Card ───────────────────────────────────────────────────────────────── */ /* One radius, one border, everywhere. Rows inside are separated by hairlines rather than by giving each its own card — a stack of cards for four labelled - values is the clutter this redesign exists to remove. */ + values is the clutter this system exists to remove. */ .drawer-card { - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface); + border: 1px solid var(--dr-line); + border-radius: var(--dr-radius); + background: var(--card-bg); overflow: hidden; } .drawer-card[data-tone='subtle'] { background: var(--color-surface-subtle); } @@ -242,47 +312,53 @@ .drawer-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 16px; + gap: var(--dr-gap-lg); align-items: baseline; - padding: 11px 14px; - border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); + padding: var(--dr-row-pad); + border-bottom: 1px solid var(--dr-line-soft); } .drawer-row:last-child { border-bottom: 0; } .drawer-row-label { - font: 400 13.5px/1.45 var(--font-sans); + font: 400 var(--dr-text-md)/1.45 var(--font-sans); color: var(--color-ink-3); display: inline-flex; align-items: center; - gap: 8px; + gap: var(--dr-gap-sm); } -.drawer-row-label svg { color: var(--color-brand); flex: none; } +.drawer-row-label svg { color: var(--color-ink-4); flex: none; } /* Right-aligned, tabular, so a column of values lines up on the decimal. */ .drawer-row-value { - font: 500 14px/1.45 var(--font-sans); + font: 500 var(--dr-text-lg)/1.45 var(--font-sans); color: var(--color-ink-1); text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.drawer-row-value[data-tone='success'] { color: #0f8a5f; font-weight: 600; } -.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); } +.drawer-row-value[data-tone='success'] { color: var(--color-success, #1f9d55); font-weight: 600; } +.drawer-row-value[data-tone='muted'] { color: var(--color-ink-4); font-weight: 400; } /* A row that wraps its value under the label — for prose, not for figures. */ -.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: 4px; } -.drawer-row[data-stacked='true'] .drawer-row-value { text-align: left; font-weight: 400; } +.drawer-row[data-stacked='true'] { grid-template-columns: 1fr; gap: var(--dr-gap-xs); } +.drawer-row[data-stacked='true'] .drawer-row-value { + text-align: left; + font-weight: 400; + font-size: var(--dr-text-md); + line-height: 1.55; + color: var(--color-ink-2); +} /* ── Metric grid ────────────────────────────────────────────────────────── */ -/* Three equal columns. Equal, not auto: the whole point is that the figures sit - on a rhythm, and `auto` columns move them every time a value changes width. */ +/* Equal columns, not auto: the whole point is that the figures sit on a + rhythm, and `auto` columns move them every time a value changes width. */ .drawer-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; - background: var(--color-line); - border: 1px solid var(--color-line); - border-radius: 12px; + background: var(--dr-line); + border: 1px solid var(--dr-line); + border-radius: var(--dr-radius); overflow: hidden; } .drawer-metrics[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -291,66 +367,54 @@ .drawer-metric { display: flex; flex-direction: column; - gap: 4px; - padding: 12px 14px; + gap: var(--dr-gap-xs); + padding: var(--dr-gap-md) 14px; background: var(--color-surface); min-width: 0; } .drawer-metric-label { - font: 600 11px/1 var(--font-sans); - letter-spacing: 0.07em; + font: 600 var(--dr-text-xs)/1 var(--font-sans); + letter-spacing: 0.065em; text-transform: uppercase; color: var(--color-ink-4); } .drawer-metric-value { - font: 600 17px/1.2 var(--font-sans); + font: 600 var(--dr-text-xl)/1.2 var(--font-sans); + letter-spacing: -0.01em; color: var(--color-ink-1); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.drawer-metric-value[data-size='sm'] { font-size: 14.5px; } -.drawer-metric-note { font: 400 12px/1.3 var(--font-sans); color: var(--color-ink-4); } +.drawer-metric-value[data-size='sm'] { font-size: var(--dr-text-lg); letter-spacing: 0; } +.drawer-metric-note { font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); } /* ── Status badge ───────────────────────────────────────────────────────── */ /* One badge, tinted from a single colour, so every status in the console is the - same shape and only the hue carries meaning. */ + same shape and only the hue carries meaning. + + It looks the same in the header as it does on a card. That is the point of + the header no longer being purple: the chip beside the title is now exactly + the chip in the row the drawer was opened from, rather than a white-on-purple + translation of it with the colour demoted to a dot. */ .drawer-badge { display: inline-flex; align-items: center; gap: 5px; - padding: 3px 10px; + padding: 3px 9px; border-radius: 999px; - font: 600 12px/1.5 var(--font-sans); + font: 600 var(--dr-text-sm)/1.5 var(--font-sans); text-transform: capitalize; white-space: nowrap; color: var(--badge); - background: color-mix(in oklab, var(--badge) 12%, transparent); -} - -/* In the purple header the status colour cannot be the text colour — a 12% - tint of green on purple is unreadable, and several statuses are darker than - the ground. The label goes white on a translucent chip and the colour moves - to a dot, lightened so it still separates from the purple. The status is - still distinguishable at a glance, which is the badge's whole job. */ -.drawer-head .drawer-badge { - color: #fff; - background: rgb(255 255 255 / 0.16); - padding-left: 8px; -} -.drawer-head .drawer-badge::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - flex: none; - background: color-mix(in oklab, var(--badge) 55%, white); + background: color-mix(in oklab, var(--badge) 11%, transparent); + border: 1px solid color-mix(in oklab, var(--badge) 24%, transparent); } /* ── Timeline ───────────────────────────────────────────────────────────── */ .drawer-timeline { display: flex; flex-direction: column; } -.drawer-step { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; } +.drawer-step { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: var(--dr-gap-md); } .drawer-step-rail { display: flex; flex-direction: column; align-items: center; } @@ -362,7 +426,7 @@ place-items: center; flex: none; background: var(--color-surface-sunken); - border: 1px solid var(--color-line); + border: 1px solid var(--dr-line); color: var(--color-ink-4); } .drawer-step-dot[data-done='true'] { @@ -375,7 +439,7 @@ .drawer-step-dot[data-current='true'] { background: var(--color-surface); border: 2px solid var(--color-brand); - box-shadow: 0 0 0 4px var(--color-brand-tint); + box-shadow: 0 0 0 3px var(--color-brand-tint); } .drawer-step-dot[data-tone='danger'] { background: color-mix(in oklab, var(--color-error) 14%, transparent); @@ -384,24 +448,38 @@ } /* Purple only as far as the journey has actually got. */ -.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 20px; background: var(--color-line); } +.drawer-step-line { width: 2px; flex: 1 1 auto; min-height: 18px; background: var(--dr-line); } .drawer-step-line[data-done='true'] { background: var(--color-brand); } -.drawer-step-body { padding-bottom: 14px; display: flex; flex-direction: column; gap: 2px; } +.drawer-step-body { + padding-bottom: var(--dr-gap-md); + display: flex; + flex-direction: column; + gap: 2px; +} .drawer-step:last-child .drawer-step-body { padding-bottom: 0; } -.drawer-step-label { font: 500 14px/1.35 var(--font-sans); color: var(--color-ink-4); } +.drawer-step-label { font: 500 var(--dr-text-md)/1.35 var(--font-sans); color: var(--color-ink-4); } .drawer-step-label[data-done='true'] { color: var(--color-ink-1); } .drawer-step-label[data-current='true'] { color: var(--color-brand); font-weight: 600; } -.drawer-step-at { font: 400 12.5px/1.35 var(--font-sans); color: var(--color-ink-3); font-variant-numeric: tabular-nums; } +.drawer-step-at { + font: 400 var(--dr-text-sm)/1.35 var(--font-sans); + color: var(--color-ink-4); + font-variant-numeric: tabular-nums; +} /* ── Route ──────────────────────────────────────────────────────────────── */ -.drawer-party { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; padding: 12px 14px; } +.drawer-party { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: var(--dr-gap-md); + padding: var(--dr-gap-md) 14px; +} .drawer-party-icon { - width: 30px; - height: 30px; - border-radius: 9px; + width: 28px; + height: 28px; + border-radius: var(--dr-radius-sm); display: grid; place-items: center; flex: none; @@ -411,13 +489,21 @@ .drawer-party-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .drawer-party-kind { - font: 600 10.5px/1 var(--font-sans); - letter-spacing: 0.09em; + font: 600 var(--dr-text-xs)/1 var(--font-sans); + letter-spacing: 0.075em; text-transform: uppercase; color: var(--color-ink-4); } -.drawer-party-name { font: 600 14.5px/1.35 var(--font-sans); color: var(--color-ink-1); overflow-wrap: anywhere; } -.drawer-party-detail { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-3); overflow-wrap: anywhere; } +.drawer-party-name { + font: 600 var(--dr-text-lg)/1.35 var(--font-sans); + color: var(--color-ink-1); + overflow-wrap: anywhere; +} +.drawer-party-detail { + font: 400 var(--dr-text-md)/1.45 var(--font-sans); + color: var(--color-ink-3); + overflow-wrap: anywhere; +} /* The phone reads as something you could act on, without pretending to be a button — these drawers are opened on a desktop where tel: does nothing. */ @@ -425,8 +511,8 @@ display: inline-flex; align-items: center; gap: 6px; - margin-top: 4px; - font: 500 12.5px/1 var(--font-sans); + margin-top: var(--dr-gap-xs); + font: 500 var(--dr-text-md)/1 var(--font-sans); color: var(--color-brand); font-variant-numeric: tabular-nums; text-decoration: none; @@ -439,8 +525,8 @@ place-items: center; height: 18px; color: var(--color-ink-4); - border-top: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); - border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent); + border-top: 1px solid var(--dr-line-soft); + border-bottom: 1px solid var(--dr-line-soft); background: var(--color-surface-subtle); } @@ -450,49 +536,195 @@ .drawer-note { display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid var(--color-line); - background: var(--color-surface-subtle); - font: 400 13px/1.5 var(--font-sans); + gap: var(--dr-gap-sm); + padding: var(--dr-gap-md) 14px; + border-radius: var(--dr-radius); + border: 1px solid var(--dr-line); + background: var(--card-bg-subtle); + font: 400 var(--dr-text-md)/1.55 var(--font-sans); color: var(--color-ink-3); } -.drawer-note svg { color: var(--color-ink-4); margin-top: 1px; } +.drawer-note svg { color: var(--color-ink-4); margin-top: 2px; flex: none; } +.drawer-note strong { color: var(--color-ink-1); font-weight: 600; } .drawer-note[data-tone='brand'] { background: var(--color-brand-tint); border-color: color-mix(in oklab, var(--color-brand) 16%, transparent); + color: var(--color-ink-2); } .drawer-note[data-tone='brand'] svg { color: var(--color-brand); } +/* ── Alert ──────────────────────────────────────────────────────────────── */ +/* The loud sibling of the note: a save that failed, a precondition that is not + met. Three drawers each wrote their own version of this box; there is one. */ +.drawer-alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--dr-gap-sm); + padding: var(--dr-gap-md) 14px; + border-radius: var(--dr-radius); + font: 400 var(--dr-text-md)/1.5 var(--font-sans); +} +.drawer-alert svg { margin-top: 2px; flex: none; } +.drawer-alert[data-tone='error'] { + color: var(--color-error, #d64545); + background: color-mix(in oklab, var(--color-error, #d64545) 7%, transparent); + border: 1px solid color-mix(in oklab, var(--color-error, #d64545) 22%, transparent); +} +.drawer-alert[data-tone='warning'] { + color: var(--color-warning, #b7860b); + background: color-mix(in oklab, var(--color-warning, #b7860b) 8%, transparent); + border: 1px solid color-mix(in oklab, var(--color-warning, #b7860b) 24%, transparent); +} + +/* ── Fields ─────────────────────────────────────────────────────────────── */ + +.drawer-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } + +.drawer-field-label { + display: inline-flex; + align-items: center; + gap: 3px; + font: 600 var(--dr-text-md)/1.3 var(--font-sans); + color: var(--color-ink-2); +} +.drawer-field-req { color: var(--color-error, #d64545); } + +.drawer-field-help { + font: 400 var(--dr-text-sm)/1.45 var(--font-sans); + color: var(--color-ink-4); +} +.drawer-field-error { + font: 500 var(--dr-text-sm)/1.45 var(--font-sans); + color: var(--color-error, #d64545); +} + +/* Two fields on a line, stacking rather than squeezing. 200px is a first name + and a last name side by side at 480px, and one above the other below it. */ +.drawer-field-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: var(--dr-gap-md); +} + +.drawer-input { + width: 100%; + height: var(--dr-control); + padding: 0 12px; + border-radius: var(--dr-radius-sm); + border: 1px solid var(--dr-line); + background: var(--color-surface); + color: var(--color-ink-1); + font: 400 var(--dr-text-lg)/1 var(--font-sans); + transition: border-color 160ms ease, box-shadow 160ms ease; + /* Safari gives a text input its own inner shadow and a rounded-rect it will + not let go of; the whole point of this rule is that the field matches the + button beside it. */ + appearance: none; + -webkit-appearance: none; +} +.drawer-input::placeholder { color: var(--color-ink-4); } +.drawer-input:hover:not(:disabled) { border-color: var(--color-ink-4); } +.drawer-input:focus { + outline: none; + border-color: var(--color-brand); + box-shadow: 0 0 0 3px var(--color-brand-tint); +} +.drawer-input[aria-invalid='true'] { + border-color: var(--color-error, #d64545); +} +.drawer-input[aria-invalid='true']:focus { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-error, #d64545) 14%, transparent); +} +.drawer-input:disabled { + background: var(--color-surface-sunken); + color: var(--color-ink-4); + cursor: not-allowed; +} + +/* A native select with the platform arrow suppressed and ours drawn on top, so + a picker and a text field are the same object at a glance. */ +.drawer-select { position: relative; display: block; min-width: 0; } +.drawer-select select { padding-right: 34px; cursor: pointer; } +.drawer-select svg { + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-ink-4); +} + +/* ── Choice cards ───────────────────────────────────────────────────────── */ + +.drawer-choices { display: flex; gap: var(--dr-gap-sm); } + +.drawer-choice { + flex: 1 1 0; + min-width: 0; + text-align: left; + padding: 10px 12px; + border-radius: var(--dr-radius-sm); + cursor: pointer; + border: 1px solid var(--dr-line); + background: var(--color-surface); + transition: border-color 150ms ease, background 150ms ease; +} +.drawer-choice:hover { border-color: var(--color-ink-4); } +.drawer-choice[data-selected='true'] { + border-color: var(--color-brand); + background: var(--color-brand-tint); +} +.drawer-choice:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } + +.drawer-choice-label { + display: flex; + align-items: center; + gap: 5px; + font: 600 var(--dr-text-md)/1.3 var(--font-sans); + color: var(--color-ink-1); +} +.drawer-choice[data-selected='true'] .drawer-choice-label { color: var(--color-brand); } +.drawer-choice-note { + display: block; + margin-top: 2px; + font: 400 var(--dr-text-sm)/1.35 var(--font-sans); + color: var(--color-ink-4); +} + /* ── Buttons ────────────────────────────────────────────────────────────── */ -/* One height, one radius, one type ramp for every button in every drawer. */ +/* One height, one radius, one type ramp for every button in every drawer — and + the same height as an input, so a form does not step up and down its own + right-hand edge. */ .drawer-btn { display: inline-flex; align-items: center; justify-content: center; - gap: 8px; - height: 42px; - padding: 0 18px; - border-radius: 11px; + gap: 6px; + height: var(--dr-control); + padding: 0 14px; + border-radius: var(--dr-radius-sm); border: 1px solid transparent; - font: 600 14px/1 var(--font-sans); + font: 600 var(--dr-text-md)/1 var(--font-sans); cursor: pointer; white-space: nowrap; transition: background 160ms ease, border-color 160ms ease, color 160ms ease; } .drawer-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } -.drawer-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.drawer-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.drawer-btn svg { flex: none; } .drawer-btn[data-variant='primary'] { background: var(--color-brand); color: #fff; } .drawer-btn[data-variant='primary']:hover:not(:disabled) { background: var(--color-brand-strong); } .drawer-btn[data-variant='secondary'] { background: var(--color-surface); - border-color: color-mix(in oklab, var(--color-brand) 35%, transparent); - color: var(--color-brand); + border-color: var(--dr-line); + color: var(--color-ink-1); +} +.drawer-btn[data-variant='secondary']:hover:not(:disabled) { + background: var(--color-surface-sunken); + border-color: var(--color-ink-4); } -.drawer-btn[data-variant='secondary']:hover:not(:disabled) { background: var(--color-brand-tint); } /* Outline, never filled: a destructive action should be reachable without being the brightest thing in the drawer. */ @@ -505,8 +737,11 @@ background: color-mix(in oklab, var(--color-error) 8%, transparent); } -.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-2); } -.drawer-btn[data-variant='ghost']:hover:not(:disabled) { background: var(--color-surface-sunken); } +.drawer-btn[data-variant='ghost'] { background: transparent; color: var(--color-ink-3); } +.drawer-btn[data-variant='ghost']:hover:not(:disabled) { + background: var(--color-surface-sunken); + color: var(--color-ink-1); +} /* A text-weight action that lives inside a section, not in the footer — "Group with another size". */ @@ -517,22 +752,38 @@ padding: 0; border: 0; background: none; - font: 600 13px/1 var(--font-sans); + font: 600 var(--dr-text-md)/1 var(--font-sans); color: var(--color-brand); cursor: pointer; } .drawer-link-action:hover { text-decoration: underline; } +.drawer-link-action:disabled { opacity: 0.45; cursor: not-allowed; text-decoration: none; } .drawer-link-action:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; } +/* ── Codes ──────────────────────────────────────────────────────────────── */ + +.drawer-mono { + font-family: var(--font-mono); + font-size: var(--dr-text-md); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +/* A PIN shown once, meant to be read across a desk and typed in. */ +.drawer-mono[data-size='lg'] { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.18em; +} + /* ── Image area ─────────────────────────────────────────────────────────── */ .drawer-figure { display: grid; place-items: center; - padding: 16px; - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface-subtle); + padding: var(--dr-gap-lg); + border: 1px solid var(--dr-line); + border-radius: var(--dr-radius); + background: var(--card-bg-subtle); /* The backstop. A catalogue photo can be 2000px wide, and an auto grid track will happily size to it and push the image straight out of the panel — which is exactly what happened. Nothing leaves this box. */ @@ -557,13 +808,13 @@ .drawer-figure[data-tall='true'] { height: 260px; } .drawer-figure[data-tall='true'] img { max-height: 228px; } -.drawer-thumbs { display: flex; gap: 8px; flex-wrap: wrap; } +.drawer-thumbs { display: flex; gap: var(--dr-gap-sm); flex-wrap: wrap; } .drawer-thumb { - width: 54px; - height: 54px; + width: 52px; + height: 52px; padding: 3px; - border-radius: 10px; - border: 1px solid var(--color-line); + border-radius: var(--dr-radius-sm); + border: 1px solid var(--dr-line); background: var(--color-surface); cursor: pointer; display: grid; @@ -579,32 +830,54 @@ /* ── Highlight bullets ──────────────────────────────────────────────────── */ -.drawer-bullets { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.drawer-bullets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--dr-gap-lg); +} @media (max-width: 520px) { .drawer-bullets { grid-template-columns: 1fr; } } -.drawer-bullet-list { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; } +.drawer-bullet-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} .drawer-bullet { display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 8px; + gap: var(--dr-gap-sm); align-items: baseline; - font: 400 13.5px/1.45 var(--font-sans); + font: 400 var(--dr-text-md)/1.5 var(--font-sans); color: var(--color-ink-2); } .drawer-bullet::before { content: ''; - width: 5px; - height: 5px; + width: 4px; + height: 4px; border-radius: 999px; background: var(--color-brand); transform: translateY(-2px); } +/* ── Caption ────────────────────────────────────────────────────────────── */ +/* An explanatory line inside a card — why a product was filed where it was, + what an import will and will not do. Quieter than prose and not a note: it + has no box of its own because it is already inside one. */ +.drawer-caption { + font: 400 var(--dr-text-md)/1.55 var(--font-sans); + color: var(--color-ink-3); +} +.drawer-caption strong { color: var(--color-ink-1); font-weight: 600; } + /* ── Prose ──────────────────────────────────────────────────────────────── */ -/* 15px and 1.6 — a description is the one thing in a drawer that gets read - rather than scanned. */ +/* A description is the one thing in a drawer that gets read rather than + scanned, so it is the one thing set above the body size. */ .drawer-prose { - font: 400 14.5px/1.6 var(--font-sans); + margin: 0; + font: 400 var(--dr-text-lg)/1.6 var(--font-sans); color: var(--color-ink-2); overflow-wrap: anywhere; } @@ -612,35 +885,33 @@ .drawer-prose p:last-child { margin-bottom: 0; } /* ── Narrower screens ───────────────────────────────────────────────────── */ -/* The panel is already capped at 92vw; below that the padding is what has to +/* The panel is already capped at 92vw; below that the gutter is what has to give, or a 520px layout inside a 360px panel just clips. */ @media (max-width: 600px) { - .drawer-head { padding: 16px 16px 14px; } - .drawer-body { padding: 16px; gap: 20px; } - .drawer-foot { padding: 12px 16px; } - .drawer-title { font-size: 19px; } + .drawer-root { --dr-pad-x: 16px; } .drawer-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .drawer-metrics[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .drawer-choices { flex-direction: column; } } /* ── Health score, inside the drawer system ─────────────────────────────── */ /* The panel's own pieces — score chip, points, tags — already sit on the brand palette and are kept. What is added here is the wrapper that makes it read as - one compact card rather than as a loose stack, which is what the redesign - asks of it: informative, and not competing with the product it describes. */ + one compact card rather than as a loose stack. */ .health-body { display: flex; flex-direction: column; gap: 10px; padding: 14px; - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface-subtle); + border: 1px solid var(--dr-line); + border-radius: var(--dr-radius); + background: var(--card-bg-subtle); } .health-per { - font: 400 12px/1.3 var(--font-sans); + font: 400 var(--dr-text-sm)/1.3 var(--font-sans); color: var(--color-ink-4); text-align: right; } -.health-caveat { font: 400 12.5px/1.55 var(--font-sans); color: var(--color-ink-3); } +.health-caveat { font: 400 var(--dr-text-md)/1.55 var(--font-sans); color: var(--color-ink-3); } diff --git a/src/features/store-admin/drawerKit.tsx b/src/features/store-admin/drawerKit.tsx index bda4fff..4e70c83 100644 --- a/src/features/store-admin/drawerKit.tsx +++ b/src/features/store-admin/drawerKit.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from 'react'; -import { Info } from 'lucide-react'; +import { AlertTriangle, Check, ChevronDown, Info } from 'lucide-react'; import './drawer.css'; /** @@ -133,10 +133,15 @@ export function Badge({ label, colour }: { label: string; colour: string }) { {label} @@ -227,3 +232,204 @@ export function Bullets({ items }: { items: readonly string[] }) { ); } + +/* ── Form pieces ───────────────────────────────────────────────────────────── + + Seven of the console's drawers are FORMS — add a rider, edit a person, issue + a till account, name a counter — and until now the kit had nothing for them. + So each one reached for Astryx's `TextInput`, `Selector` and `VStack`, laid + them out with its own inline styles, and wrote its own error box. That is why + the edit drawers and the detail drawers did not look like the same product: + different label case, different field gap, different control height, three + spellings of an error. + + These are that missing half. They are deliberately plain — a label, a + control, a line of help, an error — and they carry no layout of their own + beyond the field: `Section` still supplies the grouping, the same as it does + for a read-only drawer. */ + +/** + * A labelled control. + * + * The label is a real ` - +
@@ -265,7 +265,7 @@ export function CounterSalesDrawer({ ) : null} - +
diff --git a/src/features/store-admin/pages/ConsolePage.tsx b/src/features/store-admin/pages/ConsolePage.tsx index b3b9435..6ce1b2d 100644 --- a/src/features/store-admin/pages/ConsolePage.tsx +++ b/src/features/store-admin/pages/ConsolePage.tsx @@ -192,7 +192,7 @@ export function ConsolePage() { Loading branches… ) : rows.length === 0 ? ( - + No branches yet @@ -215,7 +215,7 @@ export function ConsolePage() { {rows.length > 1 ? ( - +
@@ -233,7 +233,7 @@ export function ConsolePage() {
} /> - +
@@ -244,7 +244,7 @@ export function ConsolePage() { {alerts.length === 0 ? ( - + Nothing waiting @@ -285,7 +285,7 @@ function BranchCard({ row }: { row: Row }) { const isActive = branch.status?.toLowerCase() === 'active'; return ( - + @@ -700,7 +700,7 @@ function AlertRow({ alert }: { alert: Alert }) { : 'var(--color-brand)'; return ( - + = { - active: 'Active', - attention: 'Attention', - never: 'Never used', - offline: 'Offline', -}; - -const STATE_TONE: Record = { - active: 'healthy', - attention: 'attention', - never: 'neutral', - offline: 'critical', -}; +/* The names and tones live with the rows now, in `counterRows`, so the drawer + opened from this table can chip a counter the same way the row does. */ +const STATE_LABEL = COUNTER_STATE_LABEL; +const STATE_TONE = COUNTER_STATE_TONE; export function CountersPage() { const { branches, scoped, isPinned } = useBranchScope(); @@ -121,15 +123,13 @@ export function CountersPage() {
- + { setTerm(next); setPage(1); }} + placeholder="Code, name or branch…" + width={300} + /> {showBranch ? (
@@ -921,8 +927,7 @@ function EnhancedGroupDetail({
- -
+ )} ); diff --git a/src/features/store-admin/pages/InventoryPage.tsx b/src/features/store-admin/pages/InventoryPage.tsx index c4addab..b4d7111 100644 --- a/src/features/store-admin/pages/InventoryPage.tsx +++ b/src/features/store-admin/pages/InventoryPage.tsx @@ -6,7 +6,6 @@ import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, @@ -24,10 +23,12 @@ import { Truck, X, } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { stockApi } from '@/api/stock'; import type { StockRequest } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; +import { Tab, TabBar } from '@/components/TabBar'; import { queryKeys } from '@/queries/keys'; import { useStockRequests, useStockStatement } from '@/queries/hooks'; import { useSelection } from '@/components/useSelection'; @@ -41,6 +42,7 @@ import { type StepId, } from '@/features/onboarding/onboardingState'; import { useLocationProducts } from '@/queries/hooks'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { branchLabel, count, money } from '../format'; @@ -155,33 +157,33 @@ export function InventoryPage() { giving it a row of its own below. It only appears on that tab — an action for a panel you are not looking at is a misfire waiting to happen. */ - - + } isActive={tab === 'products'} onClick={() => chooseTab('products')} /> - } isActive={tab === 'catalogue'} onClick={() => chooseTab('catalogue')} /> - } isActive={tab === 'requests'} onClick={() => chooseTab('requests')} - badge={pending.length || undefined} + count={pending.length || undefined} /> - } isActive={tab === 'stock'} onClick={() => chooseTab('stock')} /> - + } /> @@ -443,16 +445,13 @@ function RequestsPanel({ {/* Search box */} -
- -
+ {/* ── Bulk Action Bar ──────────────────────────────────────────────── */} @@ -554,7 +553,7 @@ function RequestsPanel({ {/* ── Table Card ──────────────────────────────────────────────────── */} {isLoading ? ( - + Reading stock requests… @@ -562,7 +561,7 @@ function RequestsPanel({ ) : filteredRows.length === 0 ? ( - + @@ -578,7 +577,12 @@ function RequestsPanel({ ) : ( - + } + >
- -
+ )} {/* ── Guidance Note ──────────────────────────────────────────────── */} @@ -961,22 +964,18 @@ function StockPanel() { ) : null} -
- -
+ {/* ── Table Card ──────────────────────────────────────────────────── */} - + {statement.isLoading ? ( @@ -1220,63 +1219,7 @@ function BarAction({ ); } -function TabButton({ - label, - icon, - isActive, - onClick, - badge, -}: { - label: string; - icon: React.ReactNode; - isActive: boolean; - onClick: () => void; - badge?: number; -}) { - return ( - - ); -} +/* The page-local `TabButton` lived here — see `components/TabBar`. */ function Th({ children, diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx index b6a9694..aeb5a96 100644 --- a/src/features/store-admin/pages/OnboardBranchPage.tsx +++ b/src/features/store-admin/pages/OnboardBranchPage.tsx @@ -248,7 +248,7 @@ export function OnboardBranchPage() { - + @@ -263,7 +263,7 @@ export function OnboardBranchPage() { {mutation.data?.locationid ? ( - + Onboard Branch {/* Section 1: Business Details & Identity */} - +
{/* Section 2: Location */} - +
{/* Section 3: Operating Hours & Delivery */} - +
- + } isActive={tab === 'revenue'} onClick={() => setTab('revenue')} /> - } isActive={tab === 'sales'} onClick={() => setTab('sales')} /> - } isActive={tab === 'inventory'} onClick={() => setTab('inventory')} /> - + } /> @@ -167,9 +169,14 @@ export function ReportsPage() { {rows.length > 1 ? ( - - - + /* No SectionHeader above it any more — the panel carries its own + title and count, and the two stacked said "By branch" twice. */ + } + >
@@ -206,9 +213,7 @@ export function ReportsPage() {
- -
-
+ ) : null} ) : tab === 'sales' ? ( @@ -273,7 +278,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) { return ( - + - +
@@ -460,7 +465,7 @@ function MoverList({ return ( - + {rows.length === 0 ? ( @@ -500,43 +505,7 @@ function MoverList({ ); } -function TabButton({ - label, - icon, - isActive, - onClick, -}: { - label: string; - icon: React.ReactNode; - isActive: boolean; - onClick: () => void; -}) { - return ( - - ); -} +/* The page-local `TabButton` lived here — see `components/TabBar`. */ function Th({ children }: { children: React.ReactNode; }) { return ( diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index d9b5575..821844a 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -4,12 +4,13 @@ import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, UserX } from 'lucide-react'; import type { DeliveryRow, OrderRow, PosSale, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; +import { SearchInput } from '@/components/SearchInput'; +import { Tab, TabBar } from '@/components/TabBar'; import { useDeliveries, useOrders, @@ -37,6 +38,7 @@ import { shortAge } from '../posStatus'; import { AssignBar } from '../AssignBar'; import { assignability, assignedFrom } from '../assignDelivery'; import { useSelection } from '@/components/useSelection'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { isCounterSale } from '../branchStats'; @@ -44,6 +46,21 @@ import './deliveries.css'; type Tab = 'orders' | 'deliveries' | 'counter'; +/** + * What the one search box matches, per view. + * + * The page used to draw three separate boxes, one inside each view, and the + * only thing that differed between them was this line — the value behind them + * was already a single `keyword`, filtering orders and deliveries through the + * query and counter bills client-side. So the boxes were three renderings of + * one control, and the placeholder is what survives of them. + */ +const SEARCH_HINT: Record = { + orders: 'Order ID, customer…', + deliveries: 'Order ID, rider…', + counter: 'Invoice, till, cashier…', +}; + /** * The bill number a counter sale was imported under. * @@ -287,43 +304,58 @@ export function SalesPage() { + } tabs={ - - + } - badge={(allOrders.length || 0) + (allDeliveries.length || 0) || undefined} - isActive={tab !== 'counter'} - onClick={() => setTab(tab === 'counter' ? 'orders' : tab)} + count={allOrders.length || undefined} + isActive={tab === 'orders'} + onClick={() => setTab('orders')} /> - - {tab !== 'counter' ? ( - <> - } - badge={allOrders.length || undefined} - isActive={tab === 'orders'} - onClick={() => setTab('orders')} - /> - } - badge={allDeliveries.length || undefined} - isActive={tab === 'deliveries'} - onClick={() => setTab('deliveries')} - /> - - ) : null} - - } + count={allDeliveries.length || undefined} + isActive={tab === 'deliveries'} + onClick={() => setTab('deliveries')} + /> + } - badge={billRows.length || undefined} + count={billRows.length || undefined} isActive={tab === 'counter'} onClick={() => setTab('counter')} + isGroupStart /> - + } /> @@ -367,9 +399,6 @@ export function SalesPage() { status={status} counts={tabCounts} onStatus={setStatus} - search={ - - } /> {/* Above the table, not below it and not in a drawer. Assigning is something you do WHILE reading the day's orders — @@ -451,9 +480,6 @@ export function SalesPage() { status={status} counts={tabCounts} onStatus={setStatus} - search={ - - } /> - {/* No status strip here: a counter bill is rung and done, so it - has no lifecycle to filter by. The search keeps the row. */} - - - + {/* No status strip and no search row: a counter bill is rung and + done, so it has no lifecycle to filter by, and the search is in + the page header with the tabs — one box for all three views. */} ; onStatus: (key: StatusKey) => void; - search: React.ReactNode; }) { return ( - - - {tabs.map((entry) => ( - onStatus(entry.key)} - /> - ))} - - {search} - + + {tabs.map((entry) => ( + onStatus(entry.key)} + /> + ))} + ); } @@ -649,7 +684,12 @@ function OrdersTable({ } return ( - + } + >
@@ -773,8 +813,7 @@ function OrdersTable({
- -
+ ); } @@ -807,7 +846,12 @@ function DeliveriesTable({ } return ( - + } + >
@@ -948,8 +992,7 @@ function DeliveriesTable({
- -
+ ); } @@ -980,7 +1023,12 @@ function BillsTable({ } return ( - + } + >
@@ -1062,8 +1110,7 @@ function BillsTable({
- -
+ ); } @@ -1094,29 +1141,8 @@ function clockOf(value: string | number | undefined): string { }); } -function Search({ - value, - onChange, - placeholder, -}: { - value: string; - onChange: (next: string) => void; - placeholder: string; -}) { - return ( -
- -
- ); -} +/* The page-local search box lived here. It is `components/SearchInput` + now, in the header beside the tabs, and there is one of it. */ /** A status chip in the lifecycle's own colour. */ function Chip({ label, color }: { label: string | undefined; color: string }) { @@ -1222,117 +1248,15 @@ function TotalsBar({ ); } -function StatusTab({ - label, - n, - isActive, - onClick, -}: { - label: string; - n: number; - isActive: boolean; - onClick: () => void; -}) { - return ( - - ); -} +/* The page-local `StatusTab` lived here — it is the `sm` size of + `components/TabBar` now. */ -function TabButton({ - label, - icon, - isActive, - onClick, - badge, -}: { - label: string; - icon: React.ReactNode; - isActive: boolean; - onClick: () => void; - badge?: number; -}) { - return ( - - ); -} +/* The page-local `TabButton` lived here. It is `components/TabBar` now — + one control for the six pages that had each grown their own. */ function Loading({ what }: { what: string }) { return ( - + Reading {what}… @@ -1344,7 +1268,7 @@ function Loading({ what }: { what: string }) { function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { return ( - + {icon} diff --git a/src/features/store-admin/pages/TerminalsPage.tsx b/src/features/store-admin/pages/TerminalsPage.tsx index 7c70af5..d8c8f8f 100644 --- a/src/features/store-admin/pages/TerminalsPage.tsx +++ b/src/features/store-admin/pages/TerminalsPage.tsx @@ -26,6 +26,7 @@ import { useCounterLabels, type CounterLabels } from '../counterLabels'; import { clockTime, spokenAge, UNASSIGNED } from '../posStatus'; import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems'; import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -198,13 +199,14 @@ export function TerminalsPage() { * * A hairline and a radius, no fill. Every block here used to be a white * `elevation="low"` card, which on a tinted page reads as a stack of raised - * slabs — and the console has been moving away from that everywhere else. - * Defined once so the edges cannot drift apart the next time one of these is - * edited. + * slabs — this page went flat first, and the rest of the console has since + * followed: the card tokens in `index.css` are now that treatment, applied + * everywhere. What is left here is the shared card minus the fill, so the + * page's own tint reads through. */ const panelStyle: React.CSSProperties = { - borderRadius: 14, - border: '1px solid var(--color-line)', + borderRadius: 'var(--card-radius)', + border: 'var(--card-border)', }; /* ── Groups ──────────────────────────────────────────────────────────────── */ @@ -662,19 +664,21 @@ function FineTable({ ) : null} - -
- - data={paged.rows} - columns={columns} - idKey="key" - density="balanced" - hasHover - dividers="rows" - /> -
- -
+ } + > + + data={paged.rows} + columns={columns} + idKey="key" + density="balanced" + hasHover + dividers="rows" + /> +
); } diff --git a/src/features/store-admin/pages/UsersPage.tsx b/src/features/store-admin/pages/UsersPage.tsx index 72bbe26..0747255 100644 --- a/src/features/store-admin/pages/UsersPage.tsx +++ b/src/features/store-admin/pages/UsersPage.tsx @@ -4,12 +4,13 @@ import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Selector } from '@astryxdesign/core/Selector'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, Monitor, Plus, UserCog, Users } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import type { RiderRosterRow } from '@/api/deliveries'; import type { PosUser, StaffInfo } from '@/api/types'; import { PageHeader } from '@/components/PageHeader'; +import { Tab, TabBar } from '@/components/TabBar'; import { SectionHeader } from '@/components/SectionHeader'; import { usePosRoles, @@ -23,6 +24,7 @@ import { branchLabel } from '../format'; import { PersonDrawer, TillDrawer } from '../PeopleDrawers'; import { RiderDrawer } from '../RiderDrawer'; import { isUnplaced } from '../staffPlacement'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -165,21 +167,21 @@ export function UsersPage() { /> } tabs={ - - + } - badge={staff.data?.length || undefined} + count={staff.data?.length || undefined} isActive={group === 'store'} onClick={() => { setGroup('store'); setRoleFilter(''); }} /> - } - badge={tillRows.length || undefined} + count={tillRows.length || undefined} isActive={group === 'till'} onClick={() => { setGroup('till'); @@ -191,17 +193,17 @@ export function UsersPage() { and not a till — so listing them together would invite an edit that moves someone between logins that do not exist for each other. */} - } - badge={riderRows.length || undefined} + count={riderRows.length || undefined} isActive={group === 'rider'} onClick={() => { setGroup('rider'); setRoleFilter(''); }} /> - + } /> @@ -226,17 +228,13 @@ export function UsersPage() { />
) : null} -
- -
+ } /> @@ -279,6 +277,9 @@ export function UsersPage() { row={editingTill.row === 'new' ? null : editingTill.row} tenantid={tenantid} locationid={editingTill.locationid} + branchName={ + branches.find((entry) => entry.locationid === editingTill.locationid)?.locationname ?? '' + } roles={roles.data ?? []} shifts={shifts.data ?? []} onClose={() => setEditingTill(null)} @@ -325,7 +326,7 @@ function RiderTable({ if (isLoading) { return ( - + Reading riders… @@ -337,7 +338,7 @@ function RiderTable({ if (rows.length === 0) { return ( - + @@ -354,7 +355,12 @@ function RiderTable({ } return ( - + } + >
@@ -411,8 +417,7 @@ function RiderTable({
- -
+ ); } @@ -445,7 +450,12 @@ function StaffTable({ } return ( - + } + >
@@ -504,8 +514,7 @@ function StaffTable({
- -
+ ); } @@ -534,7 +543,12 @@ function TillTable({ } return ( - + } + >
@@ -598,8 +612,7 @@ function TillTable({
- -
+ ); } @@ -664,68 +677,11 @@ function StatusChip({ status }: { status: string | undefined }) { ); } -function TabButton({ - label, - icon, - isActive, - onClick, - badge, -}: { - label: string; - icon: React.ReactNode; - isActive: boolean; - onClick: () => void; - badge?: number; -}) { - return ( - - ); -} +/* The page-local `TabButton` lived here — see `components/TabBar`. */ function Loading({ what }: { what: string }) { return ( - + Reading {what}… @@ -737,7 +693,7 @@ function Loading({ what }: { what: string }) { function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { return ( - + {icon} diff --git a/src/features/store-admin/pages/deliveries.css b/src/features/store-admin/pages/deliveries.css index 0aeeea8..3d718c7 100644 --- a/src/features/store-admin/pages/deliveries.css +++ b/src/features/store-admin/pages/deliveries.css @@ -24,7 +24,7 @@ flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--color-brand); - border-radius: 12px; + border-radius: var(--card-radius); background: var(--color-brand-tint); } @@ -84,8 +84,8 @@ .size-list { display: flex; flex-direction: column; - border: 1px solid var(--color-line); - border-radius: 10px; + border: var(--card-border); + border-radius: var(--card-radius); overflow: hidden; } .size-row { @@ -139,9 +139,9 @@ .size-add { padding: 13px; - border: 1px solid var(--color-line); - border-radius: 10px; - background: var(--color-surface-subtle); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg-subtle); } /* ── Health score ───────────────────────────────────────────────────────── */ @@ -155,7 +155,7 @@ align-items: baseline; gap: 2px; padding: 5px 11px; - border-radius: 10px; + border-radius: var(--card-radius); font: 700 22px/1 var(--font-sans); font-variant-numeric: tabular-nums; } @@ -196,7 +196,7 @@ gap: 8px; padding: 9px 12px; border: 1px solid #f2d9a8; - border-radius: 10px; + border-radius: var(--card-radius); background: #fdf6e8; color: #8a5a00; font: 500 12.5px/1.4 var(--font-sans); @@ -204,8 +204,8 @@ .health-allergens svg { flex: none; } .health-facts { - border: 1px solid var(--color-line); - border-radius: 10px; + border: var(--card-border); + border-radius: var(--card-radius); overflow: hidden; } .health-facts > div { @@ -259,8 +259,8 @@ .stop-list { display: flex; flex-direction: column; - border: 1px solid var(--color-line); - border-radius: 10px; + border: var(--card-border); + border-radius: var(--card-radius); overflow: hidden; } .stop-row { @@ -331,7 +331,7 @@ flex-wrap: wrap; padding: 12px 14px; border: 1px solid #f2d9a8; - border-radius: 10px; + border-radius: var(--card-radius); background: #fdf6e8; color: #8a5a00; } diff --git a/src/features/store-admin/pages/dispatch.css b/src/features/store-admin/pages/dispatch.css index e39753f..542db15 100644 --- a/src/features/store-admin/pages/dispatch.css +++ b/src/features/store-admin/pages/dispatch.css @@ -2,23 +2,40 @@ Curated palette with deep purple/indigo accents (#662582), glassmorphic elements, live telemetry cards and interactive map timelines. */ +/* ── Dispatch's palette is now the console's ──────────────────────────────── + + These fourteen names are kept, and every one of them is an ALIAS. About + fifty rules in this file reference them, so pointing the names at the shared + tokens restyles the whole page at once — and, more to the point, makes it + impossible for Dispatch to drift away from the rest of the console again. + + It had drifted. This file arrived as a port of another product and brought + that product's palette with it: `#e2e8f0` for a hairline where the console + uses `#e0e4ea`, `#1e293b` for body text where the console uses `#0f172a`, + `#f8fafc` for a sunken fill against the console's `#fafbfc`. Each is a + shade off the real token — close enough to look like a rendering + inconsistency rather than a decision, and far enough that a Dispatch card + beside a Console card were visibly two different whites. + + The two shadow steps are now the card's, which is to say nothing at all. + `--dispatch-shadow-lg` is referenced by no rule in this file and is gone + rather than aliased. */ :root { - --dispatch-bg: #ffffff; - --dispatch-bg-sub: #f8fafc; - --dispatch-card-bg: #ffffff; - --dispatch-border: #e2e8f0; - --dispatch-border-active: #662582; - --dispatch-text: #1e293b; - --dispatch-text-muted: #64748b; - --dispatch-accent: #662582; + --dispatch-bg: var(--color-surface); + --dispatch-bg-sub: var(--color-surface-subtle); + --dispatch-card-bg: var(--card-bg); + --dispatch-border: var(--color-line); + --dispatch-border-active: var(--color-brand); + --dispatch-text: var(--color-ink-1); + --dispatch-text-muted: var(--color-ink-3); + --dispatch-accent: var(--color-brand); --dispatch-accent-light: #9255ab; - --dispatch-accent-soft: rgba(102, 37, 130, 0.08); - --dispatch-success: #10b981; - --dispatch-warning: #f59e0b; - --dispatch-error: #ef4444; - --dispatch-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06); - --dispatch-shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08); - --dispatch-shadow-lg: 0 12px 30px rgba(102, 37, 130, 0.12); + --dispatch-accent-soft: color-mix(in oklab, var(--color-brand) 8%, transparent); + --dispatch-success: var(--color-success, #1f9d55); + --dispatch-warning: var(--color-warning, #b7860b); + --dispatch-error: var(--color-error, #d64545); + --dispatch-shadow-sm: var(--card-shadow); + --dispatch-shadow-md: var(--card-shadow); } .dispatch-container { @@ -30,221 +47,28 @@ font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); } -/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── */ -.dispatch-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 6px 14px; - min-height: 44px; - background: var(--dispatch-card-bg); - border: 1px solid var(--dispatch-border); - border-radius: 10px; - box-shadow: var(--dispatch-shadow-sm); - flex-wrap: wrap; -} +/* ── Top Operations Header (xpressconsole #hdr) ───────────────────────────── -.logo-section { - display: flex; - align-items: center; - gap: 10px; -} + GONE, because it was never rendered. -.logo-badge { - width: 28px; - height: 28px; - border-radius: 8px; - background: linear-gradient(135deg, #662582, #9255ab); - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - font-size: 11.5px; - color: #ffffff; - box-shadow: 0 2px 6px rgba(102, 37, 130, 0.25); -} + This file arrived as a port of another product, and it brought that + product's whole operations header with it: `.dispatch-header`, a logo lockup, + a city picker, a demo/live toggle, a live-GPS chip, a running clock, and + `.date-chip` — a pill with prev/next arrows for stepping through days. -.logo-name { - font-size: 15px; - font-weight: 800; - color: var(--dispatch-text); - letter-spacing: -0.02em; -} + Not one of those class names appears in any markup in this repository. The + Dispatch page reads the date from `useDateScope()`, which is the range + picker in the app's own top bar; two comments in `DispatchPage.tsx` already + tell the reader to "change the date range in the top bar". So the header was + 215 lines of CSS for a component that does not exist, and `.date-chip` was a + date filter nobody can see. -.logo-name em { - color: var(--dispatch-accent); - font-style: normal; - margin-left: 2px; -} + If the ported header is wanted later it is in the git history, but it would + need rebuilding against this console's own controls rather than restoring: + the date range, the branch scope and the account menu are all in the app + shell, and a second set of them on one page is the drift this pass has been + removing everywhere else. */ -.logo-city-wrap { - position: relative; - display: inline-block; -} - -.logo-city { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 999px; - background: var(--dispatch-accent-soft); - border: 1px solid rgba(102, 37, 130, 0.25); - color: var(--dispatch-accent); - font-size: 11px; - font-weight: 700; - cursor: pointer; - transition: all 0.15s ease; -} - -.logo-city:hover { - background: rgba(102, 37, 130, 0.14); - border-color: rgba(102, 37, 130, 0.45); -} - -.demo-toggle-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(102, 37, 130, 0.06)); - border: 1px solid rgba(16, 185, 129, 0.35); - color: #065f46; - font-size: 11px; - font-weight: 700; - cursor: pointer; - transition: all 0.15s ease; - font-family: inherit; -} - -.demo-toggle-chip:hover { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(102, 37, 130, 0.12)); - box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15); -} - -.toggle-dot { - width: 7px; - height: 7px; - border-radius: 50%; -} - -.toggle-dot.is-demo { - background: var(--dispatch-success); - box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3); - animation: pulse-ring 1.8s infinite; -} - -.toggle-dot.is-live { - background: var(--dispatch-warning); -} - -.hdr-stats { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.hdr-chip { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 999px; - font-size: 11.5px; - font-weight: 700; - background: var(--dispatch-bg-sub); - border: 1px solid var(--dispatch-border); - color: var(--dispatch-text); -} - -.live-gps-chip { - background: rgba(16, 185, 129, 0.08); - border-color: rgba(16, 185, 129, 0.3); - color: #065f46; -} - -.live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--dispatch-success); - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); - animation: pulse-ring 1.8s infinite; -} - -.strat-stat-orders { - background: var(--dispatch-accent-soft); - border-color: rgba(102, 37, 130, 0.2); - color: var(--dispatch-accent); -} - -.strat-stat-profit { - background: rgba(16, 185, 129, 0.08); - border-color: rgba(16, 185, 129, 0.25); - color: #059669; -} - -/* Date Chip */ -.date-chip { - display: inline-flex; - align-items: center; - padding: 1px 3px; - background: #ffffff; - border: 1px solid var(--dispatch-border); - border-radius: 999px; -} - -.date-nav-btn { - display: grid; - place-items: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: none; - border: 0; - color: var(--dispatch-text-muted); - cursor: pointer; - transition: all 0.15s; -} - -.date-nav-btn:hover { - background: var(--dispatch-bg-sub); - color: var(--dispatch-accent); -} - -.date-chip-main { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 0 5px; - font-size: 11px; - font-weight: 700; -} - -.today-badge { - font-size: 8.5px; - font-weight: 800; - padding: 1px 5px; - border-radius: 999px; - background: var(--dispatch-accent); - color: #ffffff; - letter-spacing: 0.04em; -} - -.hdr-clock { - font-family: 'JetBrains Mono', monospace, monospace; - font-size: 12px; - font-weight: 700; - padding: 4px 10px; - background: #0f172a; - color: #38bdf8; - border-radius: 6px; - letter-spacing: 0.06em; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4); -} /* ── Strategy & View Mode Bar (xpressconsole #strat-row) ───────────────────── */ .strat-row { @@ -256,7 +80,7 @@ min-height: 42px; background: var(--dispatch-card-bg); border: 1px solid var(--dispatch-border); - border-radius: 10px; + border-radius: var(--card-radius); box-shadow: var(--dispatch-shadow-sm); flex-wrap: wrap; } @@ -454,23 +278,21 @@ .dispatch-container .rcard, .rcard { - background: #ffffff; + background: var(--card-bg); border: 1px solid var(--dispatch-border); - border-radius: 14px; + border-radius: var(--card-radius); padding: 14px 16px; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); position: relative; text-align: left; user-select: none; } +/* Hover is the line, not a lift -- see the note on `.pcard` in index.css. */ .dispatch-container .rcard:hover, .rcard:hover { - transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); - border-color: rgba(102, 37, 130, 0.35); + border-color: var(--dispatch-accent); } .dispatch-container .rcard.is-focused, @@ -645,10 +467,10 @@ .dispatch-card-elevated, .dispatch-focus-card, .dispatch-map-card { - background: #ffffff !important; - border: 1px solid var(--dispatch-border) !important; - border-radius: 16px !important; - box-shadow: var(--dispatch-shadow-md) !important; + background: var(--card-bg) !important; + border: var(--card-border) !important; + border-radius: var(--card-radius) !important; + box-shadow: var(--card-shadow) !important; overflow: hidden; } @@ -707,7 +529,7 @@ padding: 10px 14px; background: var(--dispatch-bg-sub); border: 1px solid var(--dispatch-border); - border-radius: 12px; + border-radius: var(--card-radius); } .rl-item { @@ -774,19 +596,16 @@ justify-content: space-between; gap: 8px; padding: 10px 12px; - border-radius: 12px; + border-radius: var(--card-radius); border: 1px solid var(--dispatch-border); - background: #ffffff; + background: var(--card-bg); cursor: pointer; - transition: all 0.18s ease; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + transition: border-color 0.18s ease, background 0.18s ease; } .timeline-step-card:hover, .stop-timeline-card:hover { border-color: var(--dispatch-accent); - transform: translateY(-2px); - box-shadow: 0 8px 18px rgba(102, 37, 130, 0.12); } .timeline-step-card.is-current { @@ -981,7 +800,7 @@ flex-wrap: wrap; padding: 12px 14px; border: 1px solid rgba(102, 37, 130, 0.25); - border-radius: 12px; + border-radius: var(--card-radius); background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03)); } diff --git a/src/features/store-user/BulkRequestDialog.tsx b/src/features/store-user/BulkRequestDialog.tsx index 75a0e2a..d434b53 100644 --- a/src/features/store-user/BulkRequestDialog.tsx +++ b/src/features/store-user/BulkRequestDialog.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; @@ -9,7 +8,7 @@ import { errorMessage } from '@/api/client'; import { stockApi } from '@/api/stock'; import type { Product } from '@/api/types'; import { Drawer } from '@/features/store-admin/Drawer'; -import { DrawerButton } from '@/features/store-admin/drawerKit'; +import { Alert, DrawerButton } from '@/features/store-admin/drawerKit'; import { queryKeys } from '@/queries/keys'; /** @@ -178,13 +177,7 @@ export function BulkRequestDialog({ ))}
- {problem ? ( - - - {problem} - - - ) : null} + {problem ? {problem} : null}
diff --git a/src/features/store-user/RequestDialog.tsx b/src/features/store-user/RequestDialog.tsx index 87c8165..d465ebc 100644 --- a/src/features/store-user/RequestDialog.tsx +++ b/src/features/store-user/RequestDialog.tsx @@ -11,7 +11,7 @@ import { queryKeys } from '@/queries/keys'; import { money } from '@/features/store-admin/format'; import { effectivePrice, imagesOf } from '@/features/store-admin/productState'; import { Drawer } from '@/features/store-admin/Drawer'; -import { DrawerButton } from '@/features/store-admin/drawerKit'; +import { Alert, DrawerButton } from '@/features/store-admin/drawerKit'; import { Thumb } from './ui'; const QUICK = [10, 25, 50, 100] as const; @@ -141,9 +141,7 @@ export function RequestDialog({ {ask.isError ? ( - - That did not go through. Check your connection and try again. - + That did not go through. Check your connection and try again. ) : null} diff --git a/src/features/store-user/pages/StoreCustomersPage.tsx b/src/features/store-user/pages/StoreCustomersPage.tsx index bfff324..ab7c1ea 100644 --- a/src/features/store-user/pages/StoreCustomersPage.tsx +++ b/src/features/store-user/pages/StoreCustomersPage.tsx @@ -2,17 +2,19 @@ import { useMemo, useState } from 'react'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Mail, MapPin, Phone, Repeat, ShoppingBag, Users } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { customerLocality, customerName, type CustomerInfo } from '@/api/customers'; import { KpiCard } from '@/components/KpiCard'; import { PageHeader } from '@/components/PageHeader'; import { useCustomers, useOrders } from '@/queries/hooks'; import { useBranchScope } from '@/features/store-admin/BranchScope'; import { Drawer } from '@/features/store-admin/Drawer'; +import { DrawerCard, Row as DrawerRow, Section } from '@/features/store-admin/drawerKit'; import { count } from '@/features/store-admin/format'; import { Td, Th } from '../ui'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -126,17 +128,13 @@ export function StoreCustomersPage() { - -
+ } /> @@ -185,7 +183,11 @@ export function StoreCustomersPage() { {topLocality ? ` · ${count(topLocality.n)} in ${topLocality.area}` : ''} · orders counted over their whole history - + } + >
- -
+
)} @@ -364,60 +365,47 @@ function CustomerDrawer({ width={420} onClose={onClose} > - - - - } label="Phone" value={customer.contactno || '—'} /> - } label="Email" value={customer.email || '—'} /> - } label="Address" value={address || '—'} /> - - - - - } - label="Orders" - value={orders === 0 ? `None ${rangeLabel}` : `${count(orders)} ${rangeLabel}`} - /> - {/* 97% of customers have coordinates. Shown as a reference rather - than a map: this is a drawer, and a rider's app is where a pin - actually gets used. */} - } - label="Map ref" - value={ - customer.latitude && customer.longitude - ? `${customer.latitude}, ${customer.longitude}` - : 'Not placed' - } - /> - - - + {/* Built from the drawer kit, not from Astryx cards and a `Line` of its + own. The local version set its label column to a fixed 74px and its + text one size down, so this drawer's rows sat at a different height + and a different weight from every other drawer's — and an address long + enough to wrap pushed its value out of the panel. */} +
+ + } /> + } /> + {/* Stacked: an address is prose, and right-aligning six comma-joined + parts against a 74px label is how it ended up unreadable. */} + } isStacked /> + +
+ +
+ + } + {...(orders === 0 ? { tone: 'muted' as const } : {})} + /> + {/* 97% of customers have coordinates. Shown as a reference rather + than a map: this is a drawer, and a rider's app is where a pin + actually gets used. */} + } + {...(customer.latitude && customer.longitude ? {} : { tone: 'muted' as const })} + /> + +
); } -function Line({ - icon, - label, - value, -}: { - icon: React.ReactNode; - label: string; - value: string; -}) { - return ( - - {icon} - - {label} - - - {value} - - - ); -} function Initials({ name }: { name: string }) { const initials = @@ -451,7 +439,7 @@ function Initials({ name }: { name: string }) { function Note({ title, body }: { title: string; body?: string }) { return ( - + {title} diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index 50e0c49..f465d6d 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -5,12 +5,13 @@ import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; import { Selector } from '@astryxdesign/core/Selector'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; import { Boxes, ClipboardList, PackageSearch } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { stockApi } from '@/api/stock'; import type { Product, StockRequest, StockStatementRow } from '@/api/types'; import { PageHeader } from '@/components/PageHeader'; +import { Tab, TabBar } from '@/components/TabBar'; import { queryKeys } from '@/queries/keys'; import { useAppAisles, @@ -34,7 +35,8 @@ import { canAskAgain, requestState, stockHealth, } from '../requestState'; -import { Chip, TabButton, Td, Th, Thumb } from '../ui'; +import { Chip, Td, Th, Thumb } from '../ui'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -155,41 +157,37 @@ export function StoreProductsPage() { title="Products" actions={ tab === 'requests' ? null : ( -
- -
+ ) } tabs={ - - + } isActive={tab === 'catalogue'} onClick={() => setTab('catalogue')} /> - } isActive={tab === 'stock'} onClick={() => setTab('stock')} /> - } isActive={tab === 'requests'} onClick={() => setTab('requests')} - badge={waiting || undefined} + count={waiting || undefined} /> - + } /> @@ -211,7 +209,16 @@ export function StoreProductsPage() { )} - {open ? setOpen(null)} /> : null} + {open ? ( + /* The row shows a Stock request chip; the drawer opened from it shows + the same one, off the same map — see `request` on ProductDrawer. */ + setOpen(null)} + /> + ) : null} {askingMany && askingMany.length > 0 && tenantid && locationid ? ( ) : null} - + } + >
- -
+
)} @@ -482,8 +493,14 @@ function CatalogueRow({ {unit || '—'} - - {money(effectivePrice(product))} + {/* An em dash for an unpriced product, not "₹0". Zero is a price a till + would ring up; no price at all is why it cannot. The merchant's own + products table and this product's drawer both say so — this cell was + the one printing a figure that does not exist. */} + 0}> + + {effectivePrice(product) > 0 ? money(effectivePrice(product)) : '—'} + {count(stock)} @@ -550,7 +567,12 @@ function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading: Today’s ledger. Opening is what was on the shelf before today — stock received today counts under In and becomes tomorrow’s opening. - + } + >
- -
+ ); } @@ -674,7 +695,12 @@ function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: b return ( - + } + >
- -
+
); } @@ -760,7 +785,7 @@ function stamp(value: string | undefined): string { function Note({ title, body }: { title: string; body?: string }) { return ( - + {title} diff --git a/src/features/store-user/pages/StoreStaffPage.tsx b/src/features/store-user/pages/StoreStaffPage.tsx index 49697eb..131b812 100644 --- a/src/features/store-user/pages/StoreStaffPage.tsx +++ b/src/features/store-user/pages/StoreStaffPage.tsx @@ -22,6 +22,7 @@ import { useBranchScope } from '@/features/store-admin/BranchScope'; import { TillDrawer } from '@/features/store-admin/PeopleDrawers'; import { count } from '@/features/store-admin/format'; import { Td, Th } from '../ui'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; @@ -163,7 +164,7 @@ export function StoreStaffPage() { A suspended cashier is refused by the till, and now that these rows are asked for rather than hidden, the page can say so. */} {suspended > 0 ? ( - + @@ -199,7 +200,11 @@ export function StoreStaffPage() { ? ` · ${count(supervisors)} supervisor${supervisors === 1 ? '' : 's'} at this outlet` : ' · no supervisor at this outlet'} - + } + >
- -
+
)} @@ -425,7 +429,7 @@ function AccessChip({ person }: { person: PosUser }) { function Note({ title, body }: { title: string; body?: string }) { return ( - + {title} diff --git a/src/features/store-user/ui.tsx b/src/features/store-user/ui.tsx index 97e3f54..92558c2 100644 --- a/src/features/store-user/ui.tsx +++ b/src/features/store-user/ui.tsx @@ -11,64 +11,8 @@ import { ImageOff } from 'lucide-react'; * merchant pages already went through. */ -/** A page tab, as used in the Store Admin's PageHeader tab row. */ -export function TabButton({ - label, - icon, - isActive, - onClick, - badge, -}: { - label: string; - icon: ReactNode; - isActive: boolean; - onClick: () => void; - badge?: number; -}) { - return ( - - ); -} +/* The sixth and last copy of `TabButton` lived here, exported for the Store + user pages. Every one of them now imports `components/TabBar`. */ /** A header action drawn like the tabs, so the two rows read as one bar. */ export function BarAction({ diff --git a/src/features/uploads/UploadDetailDrawer.tsx b/src/features/uploads/UploadDetailDrawer.tsx index 643b8e2..ce5333e 100644 --- a/src/features/uploads/UploadDetailDrawer.tsx +++ b/src/features/uploads/UploadDetailDrawer.tsx @@ -5,7 +5,6 @@ import { CircleDot, Circle, Download, - FileSpreadsheet, Info, PackageCheck, } from 'lucide-react'; @@ -79,7 +78,28 @@ export function UploadDetailDrawer({ return ( 0 ? ` · ${totals.rejected} rejected` : '' + }`} + meta={ + <> + + + {[showTenant ? receipt.tenantname : null, receipt.locationname, receipt.uploadedname] + .filter(Boolean) + .join(' · ')} + + + } width={540} onClose={onClose} isFooterFilled={products.length === 0} @@ -109,33 +129,6 @@ export function UploadDetailDrawer({ } > - {/* ── The file ──────────────────────────────────────────────────────── */} -
- - - -
- {receipt.filename || 'Spreadsheet'} - - {totals.rows} row{totals.rows === 1 ? '' : 's'} - {totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''} - -
- -
- - {[ - showTenant ? receipt.tenantname : null, - receipt.locationname, - receipt.uploadedname, - when(receipt.created), - ] - .filter(Boolean) - .join(' · ')} - -
-
- {/* ── Summary ───────────────────────────────────────────────────────── */} @@ -251,6 +244,11 @@ export function UploadDetailDrawer({
+ {/* The table's Uploaded on column. It used to be in the block of + header this drawer drew for itself, and it is the one fact from + that block the bar has no room for — the meta line there carries + who and where, and a fourth item would push the title out. */} + diff --git a/src/features/uploads/UploadsPanel.tsx b/src/features/uploads/UploadsPanel.tsx index cce3a94..3086866 100644 --- a/src/features/uploads/UploadsPanel.tsx +++ b/src/features/uploads/UploadsPanel.tsx @@ -34,6 +34,7 @@ import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { FileSpreadsheet, UploadCloud } from 'lucide-react'; +import { SearchInput } from '@/components/SearchInput'; import { errorMessage } from '@/api/client'; import { fetchBatch, @@ -53,6 +54,7 @@ import { readStoredRows } from './shelveRows'; import { parseProductSheet } from '@/features/nearle-admin/import/parseProductSheet'; import { shelveBatch } from '@/features/nearle-admin/import/shelve'; import { SheetDropzone } from '@/components/SheetDropzone'; +import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { UploadDetailDrawer } from './UploadDetailDrawer'; @@ -350,13 +352,12 @@ export function UploadsPanel({
- setSearch(event.target.value)} + onChange={setSearch} + placeholder="File name…" + width={230} /> {/* "Check again" stood here. It is gone because the page reads the moving rows by itself now — every fifteen seconds while something is @@ -404,7 +405,12 @@ export function UploadsPanel({ ) : ( - + } + >
@@ -484,8 +490,7 @@ export function UploadsPanel({
- -
+ )} {open ? ( diff --git a/src/features/uploads/uploads.css b/src/features/uploads/uploads.css index cee0c58..b65f24e 100644 --- a/src/features/uploads/uploads.css +++ b/src/features/uploads/uploads.css @@ -53,19 +53,29 @@ .up-tab[data-active='true'] span { background: var(--color-brand); color: #fff; } .up-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; } -.up-search, .up-date { +/* `.up-search` was here. The toolbar uses `components/SearchInput` now, like + every other search in the console. + + The date field stays, and takes the search box's measurements so the two sit + together: the same 38px, the same corner and the same hairline. It was 11px + where the search is `--card-radius-sm`, which beside each other read as two + different controls. */ +.up-date { height: 38px; padding: 0 12px; - border: 1px solid var(--color-line); - border-radius: 11px; - background: var(--color-surface); - font: 400 13.5px/1 var(--font-sans); - color: var(--color-ink-1); + border: var(--card-border); + border-radius: var(--card-radius-sm); + background: var(--card-bg); + box-shadow: var(--card-shadow); + font: 400 13px/1 var(--font-sans); + font-variant-numeric: tabular-nums; + color: var(--color-ink-2); +} +.up-date:focus { + outline: none; + border-color: var(--color-brand); + box-shadow: 0 0 0 3px var(--color-brand-tint); } -.up-search { min-width: 220px; } -.up-date { font-variant-numeric: tabular-nums; color: var(--color-ink-2); } -.up-search:focus, .up-date:focus { outline: 2px solid var(--color-brand); outline-offset: -1px; } -.up-search::placeholder { color: var(--color-ink-4); } /* ── Table ──────────────────────────────────────────────────────────────── */ @@ -114,32 +124,10 @@ white-space: nowrap; } -/* ── Drawer: the file header ────────────────────────────────────────────── */ -/* This is what makes it an Upload Details workspace rather than the generic - drawer: the file itself is the subject, so it gets an icon and its own block - above everything. */ -.up-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; } - -.up-head-icon { - width: 44px; - height: 44px; - border-radius: 12px; - display: grid; - place-items: center; - flex: none; - background: var(--color-brand-tint); - color: var(--color-brand); -} - -.up-head-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; } -.up-head-name { - font: 600 17px/1.3 var(--font-sans); - color: var(--color-ink-1); - overflow-wrap: anywhere; -} -.up-head-sub { font: 400 13px/1.4 var(--font-sans); color: var(--color-ink-3); } -.up-head-meta { font: 400 12.5px/1.45 var(--font-sans); color: var(--color-ink-4); } -.up-head-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +/* The drawer's file header used to live here — an icon, the filename and the + status chip, drawn as a second header inside the body because the real one + only said "Upload details". The bar names the file now, so these rules have + no markup left to style. */ /* ── Drawer: the journey ────────────────────────────────────────────────── */ @@ -238,7 +226,7 @@ has to go and fix a spreadsheet. */ .up-reject { border: 1px solid color-mix(in oklab, var(--color-error) 26%, transparent); - border-radius: 12px; + border-radius: var(--card-radius); background: color-mix(in oklab, var(--color-error) 5%, transparent); overflow: hidden; } diff --git a/src/index.css b/src/index.css index 5981908..46bfa60 100644 --- a/src/index.css +++ b/src/index.css @@ -50,6 +50,56 @@ --color-surface-sunken: #f2f4f7; --color-line: #e0e4ea; + /* ── The card ─────────────────────────────────────────────────────────── + + ONE card for the whole console: a white fill, a hairline, a 12px corner + and NO SHADOW. Separation comes from the line alone, so the page reads as + a single plane with regions marked on it rather than as a tray of raised + slabs — which is what it had become. A table inside a flat card reads as + the table's container; inside a floating one it reads as a tile that + happens to have a grid in it. + + ── Why these are tokens and not four numbers typed per surface ───────── + + The app had grown FIVE card radii — 10, 12, 14, 16 and 18 — plus three + unrelated shadow recipes, across surfaces that are all the same object: + `.kpi`, `.panel`, `.pcard`, `.rcard`, `.ob-frame`, `.sp-card`, + `.dispatch-map-card` and a dozen more. Each was right on the day it was + written and none of them agreed. Anything card-shaped now reads these. + + `--card-radius` is kept in step with Astryx's `--radius-container` BY + HAND rather than aliased to it, because the generated theme sheet is + scoped to `[data-astryx-theme]` and these tokens have to resolve outside + it too — on the login, and inside a drawer. The two numbers are the same + number; `src/theme/nearle.ts` is where the decision is recorded. + + ── The edge ──────────────────────────────────────────────────────────── + + A hairline PLUS a 1px micro-lift at 4%. Not a blur. A wide soft shadow + makes a panel float, and a page of five floating panels is a tray of + slabs; one crisp pixel is enough to separate the panel from the canvas + and to make the hairline read as an edge rather than as a drawn line. + + A bigger shadow does still exist in the app, deliberately, and belongs to + things that genuinely float ABOVE the page: the drawer panel, a map + popup, a chart tooltip. A card is not one of those. + + ── The edge is only half of it ───────────────────────────────────────── + + These tokens paint a box. What makes the box read as designed rather than + as a default is the ANATOMY inside it — a header band that names the + panel and counts its rows, a body, a footer band that owns the pager. + That lives in `components/Panel.tsx`, and a card that is just these + tokens wrapped round a table is the bare rectangle this replaced. */ + --card-radius: 14px; + --card-radius-sm: 10px; /* a card nested inside another card */ + --card-border: 1px solid var(--color-line); + --card-bg: var(--color-surface); + --card-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + /* The fill for a card that is deliberately quieter than the page — an + explanatory block, a disabled region. Still flat, still hairlined. */ + --card-bg-subtle: var(--color-surface-subtle); + /* Neutral ramp — the ink scale every surface and label reads from. */ --color-slate-50: #f7f7f7; --color-slate-100: #eff1f4; @@ -133,6 +183,111 @@ body { } } +/* ── Scrolling ────────────────────────────────────────────────────────────── + + One scrolling behaviour for the whole console — the page, every panel that + scrolls inside it, and every side drawer. + + ── What `scroll-behavior: smooth` does and does not do ───────────────────── + + It animates PROGRAMMATIC scrolls: `scrollIntoView`, `scrollTo`, a jump to a + `#hash`, and the browser's own move when focus lands on something off + screen. It does NOT touch the mouse wheel, a trackpad or a touch drag — + those are the operating system's and are already smooth. So this is the + difference between a page that JUMPS when you tab into a field below the + fold, or when the uploads drawer jumps you to its rejection panel, and one + that travels there. + + Declared here rather than per component so nothing is left out: a container + that scrolls and is not in the list below is the one that will feel wrong. + + Accessibility is already handled — the reduced-motion block above resets + `scroll-behavior` to `auto !important` for everything, so a person who has + asked for less motion gets the instant jump back. + + ── The two properties on `html` that are not about smoothness ───────────── + + `scroll-padding-top` is the sticky header's height. Without it, anything + scrolled to by the browser lands UNDER the 56px bar — the field you tabbed + to is behind the nav, which reads as the page having scrolled to the wrong + place. + + `scrollbar-gutter: stable` reserves the scrollbar's width whether or not + there is one. It stops the whole layout shifting sideways when a short page + becomes a long one, when a filter empties a table, and — the reason it is + here — when a drawer opens and locks the page behind it. */ + +html { + scroll-behavior: smooth; + /* 56px bar + its 1px rule + 8px so the target is not flush against it. */ + scroll-padding-top: 65px; + scrollbar-gutter: stable; +} + +/* Every panel that scrolls inside the page. + + `:where()` keeps the specificity at zero, so any of these can still set its + own behaviour without an `!important` fight. */ +:where( + .table-scroll, /* every table, sideways */ + .rail-scroll, /* the catalogue's category rail */ + .bulkreq-list, /* the bulk stock request */ + .district-list, /* choosing a partner's district */ + .kpi-strip, /* the console's KPI row, sideways */ + .chart2, /* the console's bar chart, sideways */ + .rail-filter-chips, /* dispatch's filter chips, sideways */ + .stops-timeline-carousel +) { + scroll-behavior: smooth; +} +/* The side drawers are not in that list. `.drawer-body` sets its own, beside + the `overscroll-behavior` it already had — the drawer system owns its + scrolling in `drawer.css`, and splitting half of it across two files is how + one of the two gets changed alone. */ + +/* ── One scrollbar ────────────────────────────────────────────────────────── + + Both properties are inherited, so declaring them on `html` reaches every + scroll container in the app — including the ones inside drawers, which is + how a drawer's scrollbar stops being a 15px platform-grey slab beside a + 520px panel. + + The webkit block below says the same thing again for Chrome, Edge and + Safari, where it wins over the standard properties. It is not redundant: + only the pseudo-elements can round the thumb and give it a hover state, and + a bar that responds to the pointer is most of what reads as "considered" + rather than "default". */ +html { + scrollbar-width: thin; + scrollbar-color: var(--color-line) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + border-radius: 999px; + background: var(--color-line); + /* Drawn inside a transparent border, which is the only way to inset a + webkit thumb — it leaves the bar reading as a 6px rail rather than a 10px + slab, while keeping a 10px target for the pointer. */ + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-ink-4); + background-clip: padding-box; +} +/* Where the two bars meet. Left at the platform default it is a small grey + square in the corner of every scrolling panel. */ +::-webkit-scrollbar-corner { + background: transparent; +} + /* Numeric columns line up only with tabular figures. Used by every KPI value and every money/count cell in a table. */ .tabular { @@ -681,9 +836,9 @@ main { } .catalogue-rail { - border: 1px solid var(--color-line); - border-radius: 14px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); position: sticky; top: 72px; /* The clip lives here, on the element that owns the radius. */ @@ -694,10 +849,10 @@ main { padding: 6px; max-height: calc(100dvh - 98px); overflow-y: auto; - /* A thin, quiet scrollbar. The default is wide enough to read as a second - column beside a 216px rail. */ - scrollbar-width: thin; - scrollbar-color: var(--color-line) transparent; + /* 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 + `html` reaches here. This rail was the only place that had been fixed, and + it is why every other panel's bar looked like a different product's. */ } .rail-scroll::-webkit-scrollbar { @@ -810,16 +965,18 @@ main { display: flex; flex-direction: column; min-width: 0; - border: 1px solid var(--color-line); - border-radius: 14px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); overflow: hidden; - transition: border-color .18s, box-shadow .18s; + transition: border-color .18s; } +/* Hover is a stronger LINE, not a lift. In a flat system a card that rises on + hover is the only thing on the page with a z-axis, which reads as a bug + rather than as feedback. */ .pcard:hover { - border-color: color-mix(in oklab, var(--color-brand) 38%, transparent); - box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22); + border-color: var(--color-brand); } .pcard-media { @@ -1278,9 +1435,9 @@ main { width: 100%; text-align: left; padding: 20px; - border: 1px solid var(--color-line); - border-radius: 14px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); cursor: pointer; transition: border-color .18s, background .18s, transform .18s; } @@ -1300,8 +1457,8 @@ main { read. */ .ob-drop { border: 1.5px dashed var(--color-slate-300); - border-radius: 14px; - background: var(--color-surface-subtle); + border-radius: var(--card-radius); + background: var(--card-bg-subtle); padding: 32px 20px; text-align: center; transition: border-color .18s, background .18s; @@ -1359,9 +1516,9 @@ main { align-items: center; gap: 10px; padding: 10px 12px; - border-radius: 10px; - background: var(--color-surface-subtle); - border: 1px solid var(--color-line); + border-radius: var(--card-radius-sm); + background: var(--card-bg-subtle); + border: var(--card-border); } .ob-flow-icon { /* Sized for a 17px glyph rather than the two digits that used to sit here. */ @@ -1403,9 +1560,9 @@ main { body (the step's own fields) and foot (Back / Continue). The bands are separated by rules rather than gaps so the card reads as one object. */ .ob-frame { - border: 1px solid var(--color-line); - border-radius: 18px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); overflow: hidden; } @@ -1465,7 +1622,7 @@ main { .ob-returnbar { padding: 12px 16px; border: 1px solid var(--color-brand); - border-radius: 14px; + border-radius: var(--card-radius); background: var(--color-brand-tint); } @@ -1499,9 +1656,9 @@ main { gap: 12px; align-items: start; padding: 14px 16px; - border: 1px solid var(--color-line); - border-radius: 14px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); } /* Text renders inline, so the title and its detail ran together on one line @@ -1540,7 +1697,7 @@ main { gap: 10px; align-items: flex-start; padding: 12px 14px; - border-radius: 12px; + border-radius: var(--card-radius); border: 1px solid var(--color-warning-line, #e8d9a8); background: var(--color-warning-tint, #fdf7e6); color: var(--color-ink-1); @@ -1593,9 +1750,9 @@ main { something is picked, then takes the brand tint and shows its actions. */ .bulkbar { padding: 10px 14px; - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); transition: background .15s ease, border-color .15s ease; } @@ -1685,9 +1842,9 @@ main { gap: 12px; align-items: center; padding: 10px 12px; - border: 1px solid var(--color-line); - border-radius: 10px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius-sm); + background: var(--card-bg); } .qty-step { @@ -1737,9 +1894,9 @@ main { gap: 12px; align-items: start; padding: 14px 16px; - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface-subtle); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg-subtle); } .ob-guide[data-tone='warn'] { @@ -1814,7 +1971,10 @@ main { laptop and sit under the panel. The brief is explicit that the panel is not to be touched, and a background is not worth the risk of moving it. */ background: color-mix(in oklab, var(--color-brand) 3%, #ffffff); - border-radius: 16px; + /* The card radius, even though this is a tinted REGION rather than a card: + it holds cards, and a 16px region around 12px cards reads as a rounding + error rather than as a nesting. */ + border-radius: var(--card-radius); padding: 18px; } @media (max-width: 720px) { .sp-page { padding: 12px; } } @@ -1827,10 +1987,9 @@ main { gap: 16px; align-items: center; padding: 18px 20px; - border: 1px solid var(--color-line); - border-radius: 16px; - background: var(--color-surface); - box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); } /* The store, not the person. A shop mark rather than an avatar — this record @@ -1946,10 +2105,9 @@ main { @media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } } .sp-card { - border: 1px solid var(--color-line); - border-radius: 14px; - background: var(--color-surface); - box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); overflow: hidden; } .sp-card[data-span='full'] { grid-column: 1 / -1; } @@ -2078,9 +2236,9 @@ main { .district-list { max-height: 208px; overflow-y: auto; - border: 1px solid var(--color-line); - border-radius: 12px; - background: var(--color-surface); + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); } .district-row { diff --git a/src/theme/nearle.css b/src/theme/nearle.css index d2d406a..44ef053 100644 --- a/src/theme/nearle.css +++ b/src/theme/nearle.css @@ -1,7 +1,7 @@ /* * @generated by `astryx theme build` — do not edit manually. - * Source: src/theme/nearle.ts - * Command: astryx theme build src/theme/nearle.ts + * Source: src\theme\nearle.ts + * Command: astryx theme build src\theme\nearle.ts * CLI: @astryxdesign/cli@0.4.5 * Core: @astryxdesign/core@0.4.5 */ @@ -88,9 +88,9 @@ --font-family-heading: Sora, ui-sans-serif, system-ui, sans-serif; --font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; --radius-element: 12px; - --radius-container: 16px; - --radius-page: 16px; - --radius-chat: 16px; + --radius-container: 14px; + --radius-page: 14px; + --radius-chat: 14px; --text-body-size: 14px; --text-label-size: 14px; --text-large-size: 17px; diff --git a/src/theme/nearle.d.ts b/src/theme/nearle.d.ts index 6ca08ee..6a7c234 100644 --- a/src/theme/nearle.d.ts +++ b/src/theme/nearle.d.ts @@ -1,7 +1,7 @@ /** * @generated by `astryx theme build` — do not edit manually. - * Source: src/theme/nearle.ts - * Command: astryx theme build src/theme/nearle.ts + * Source: src\theme\nearle.ts + * Command: astryx theme build src\theme\nearle.ts * CLI: @astryxdesign/cli@0.4.5 * Core: @astryxdesign/core@0.4.5 */ diff --git a/src/theme/nearle.js b/src/theme/nearle.js index 1759773..bc8dca8 100644 --- a/src/theme/nearle.js +++ b/src/theme/nearle.js @@ -1,7 +1,7 @@ /** * @generated by `astryx theme build` — do not edit manually. - * Source: src/theme/nearle.ts - * Command: astryx theme build src/theme/nearle.ts + * Source: src\theme\nearle.ts + * Command: astryx theme build src\theme\nearle.ts * CLI: @astryxdesign/cli@0.4.5 * Core: @astryxdesign/core@0.4.5 */ @@ -21,9 +21,9 @@ export const nearleTheme = { "--font-family-heading": "Sora, ui-sans-serif, system-ui, sans-serif", "--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace", "--radius-element": "12px", - "--radius-container": "16px", - "--radius-page": "16px", - "--radius-chat": "16px", + "--radius-container": "14px", + "--radius-page": "14px", + "--radius-chat": "14px", "--text-body-size": "14px", "--text-label-size": "14px", "--text-large-size": "17px", diff --git a/src/theme/nearle.ts b/src/theme/nearle.ts index 049ecc8..6805d7b 100644 --- a/src/theme/nearle.ts +++ b/src/theme/nearle.ts @@ -50,16 +50,33 @@ export const nearleTheme = defineTheme({ }, tokens: { - // ── Radius, matched to KROW ────────────────────────────────────────── - // KROW uses exactly three radii: 16px on cards, 12px on controls, full on - // pills. Astryx defaults to 8px on controls and 12px on cards, with - // page/chat at 28px — that 28px is where the odd oversized surfaces in the - // old console came from. `--radius-inner` (4px) is left alone: KROW does - // use a 4px radius on its smallest inline markers. + // ── Radius ─────────────────────────────────────────────────────────── + // ONE card radius for the whole console, and this is where it is set: + // `--radius-container` is what Astryx's `Card` rounds by, so every one of + // the 110 cards in the app takes its corner from this line. The + // hand-rolled surfaces read the same number through `--card-radius` in + // `index.css`, which is defined from this token. + // + // 14. The app had grown five card radii — 10, 12, 14, 16 and 18 — across + // surfaces that are all the same object, and none of them agreed. + // + // 14 rather than 12 because the card is a FRAMED panel now, with a tinted + // header band and a footer band inside the same clip: at 12 the band's + // corner and the panel's corner were close enough to look like a + // mis-rounding, and the header's fill reads better against a slightly + // softer edge. A plain box is happier at 12; this is not a plain box — + // see `components/Panel.tsx`. + // + // `--radius-element` stays at 12: a control is one step tighter than the + // panel that holds it, which is what keeps a button inside a card from + // looking like a small card. + // + // `--radius-inner` (4px) is left alone — Astryx uses it on the smallest + // inline markers, which are not cards. '--radius-element': '12px', - '--radius-container': '16px', - '--radius-page': '16px', - '--radius-chat': '16px', + '--radius-container': '14px', + '--radius-page': '14px', + '--radius-chat': '14px', // Control heights are NOT overridden: Astryx's `--size-element-md` is // already 32px, which is exactly KROW's control height.