fix(d26): accent picker off top bar + mobile UX pass + responsive billing

- The 7-swatch accent selector is removed from the top bar (it now lives only
  in Settings > Appearance); the bar keeps a single compact light/dark ModeToggle.
  This declutters the header and fixes most of the mobile congestion.
- Mobile pass (<=640px): search takes the bar, Ctrl-K hint + Gmail pill hidden,
  avatar-only user chip; pages get real padding + bottom room; toolbars/chips
  wrap with air; inputs full-width & 42px tall; stats 2-up; tables padded.
- Billing composer: client search + line-item cards go full-width and stack on
  phones (composer-search / composer-line classes) so no container is undersized.
typecheck + web build clean, web tests 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 9b920e50f9
commit bbe125892d

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { LogOut, Menu, Search, UserRound } from 'lucide-react' import { LogOut, Menu, Search, UserRound } from 'lucide-react'
import { import {
avatarColors, Badge, CommandPalette, initials, ThemeSwitcher, type PaletteItem, avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem,
} from '@sims/ui' } from '@sims/ui'
import { clearSession, displayName, getClients, getEmailStatus, getReminders, hasSession, role } from './api' import { clearSession, displayName, getClients, getEmailStatus, getReminders, hasSession, role } from './api'
import { NAV_GROUPS } from './nav' import { NAV_GROUPS } from './nav'
@ -204,7 +204,7 @@ export function Layout() {
{gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'} {gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'}
</span> </span>
)} )}
<ThemeSwitcher /> <ModeToggle />
{/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */} {/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */}
<span <span
className="hq-user-wrap" className="hq-user-wrap"

@ -155,8 +155,8 @@
.hq-search { min-width: 0; flex: 1; } .hq-search { min-width: 0; flex: 1; }
.hq-search span { display: none; } .hq-search span { display: none; }
.hq-user-name { display: none; } .hq-user-name { display: none; }
.hq-content { padding: 4px 12px 16px; } .hq-content { padding: 8px 14px 20px; }
.wf-page { padding: 16px 4px 24px; } .wf-page { padding: 14px 2px 28px; }
} }
/* Tables scroll inside their page rather than breaking the layout. */ /* Tables scroll inside their page rather than breaking the layout. */
.hq-content { overflow-x: hidden; } .hq-content { overflow-x: hidden; }
@ -275,3 +275,48 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
/* Stat cards go one-per-row so numbers stay legible. */ /* Stat cards go one-per-row so numbers stay legible. */
.wf-stats { grid-template-columns: 1fr 1fr; } .wf-stats { grid-template-columns: 1fr 1fr; }
} }
/* ============================================================================
Mobile experience pass (D26) the accent swatches are gone from the top bar
(now in Settings > Appearance), and phones get real breathing room. Targets
640px: the cramped-phone width the user tested on.
============================================================================ */
@media (max-width: 640px) {
/* Top bar: search is the star, everything else compact. */
.hq-top { gap: 8px; padding: 8px 12px; }
.hq-search { flex: 1; min-width: 0; height: 40px; font-size: 15px; }
.hq-search kbd { display: none; } /* the Ctrl-K hint is desktop-only */
.hq-pill { display: none; } /* Gmail status shows in the banner/Settings, not the phone bar */
.mode { padding: 6px 9px; font-size: 15px; }
.hq-user .hq-user-name { display: none; } /* avatar + role only */
.hq-user { padding: 4px 6px; gap: 5px; }
/* Pages: generous, single-column, full-width children. */
.wf-page { padding: 14px 14px 96px; } /* bottom room so content clears the thumb zone */
.wf-card { padding: 14px; }
.wf-page > *, .wf-card { max-width: 100% !important; }
/* Toolbars & filter chips wrap with air, don't jam into one line. */
.wf-toolbar { flex-wrap: wrap; gap: 8px; row-gap: 10px; }
.wf-toolbar > .wf, .wf-toolbar select.wf, .wf-toolbar input.wf { flex: 1 1 auto; min-width: 140px; }
.wf-chips { flex-wrap: wrap; gap: 6px; }
/* Inputs full width & tall enough to tap; 16px kills iOS focus-zoom (already set globally). */
input.wf, select.wf, textarea.wf { width: 100%; min-height: 42px; }
.wf-field { width: 100%; }
/* Stats: two-up so the numbers stay big and readable. */
.wf-stats { grid-template-columns: 1fr 1fr; gap: 10px; }
/* Dialogs already bottom-sheet (earlier pass); keep them roomy. */
.wf-dialog-body { padding: 14px 16px; }
/* Tables scroll, with a subtle hint that there's more sideways. */
table.wf { font-size: 13.5px; }
table.wf th, table.wf td { padding: 9px 10px; white-space: nowrap; }
/* The New-Document composer: every container fills the screen (no 340px caps). */
.wf-page .composer-search, .wf-page .composer-line { max-width: 100% !important; width: 100%; }
.wf-page .composer-line { flex-direction: column; align-items: stretch; }
.wf-page .composer-line > * { width: 100%; }
}

@ -180,7 +180,7 @@ export function NewDocument() {
<Button onClick={() => { setClient(undefined); setQ(''); commit() }}>Change</Button> <Button onClick={() => { setClient(undefined); setQ(''); commit() }}>Change</Button>
</Toolbar> </Toolbar>
) : ( ) : (
<div style={{ position: 'relative', maxWidth: 340 }}> <div className="composer-search" style={{ position: 'relative', maxWidth: 340 }}>
<input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} /> <input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} />
{hits.length > 0 && ( {hits.length > 0 && (
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)' }}> <div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)' }}>
@ -222,7 +222,7 @@ export function NewDocument() {
const editions = editionsOf(prices) const editions = editionsOf(prices)
const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code)) const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code))
return ( return (
<div key={i} className="wf-card" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}> <div key={i} className="wf-card composer-line" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Module"> <Field label="Module">
<select className="wf" value={l.moduleId} <select className="wf" value={l.moduleId}
onChange={(e) => { onChange={(e) => {

@ -11,7 +11,25 @@ const SWATCH: Record<Accent, string> = {
amber: '#b97e10', amber: '#b97e10',
} }
/** Compact mode toggle + accent picker; drops into any header. */ /** Just the light/dark toggle a single small button for the top bar. The full
* accent picker lives in Settings Appearance (keeps the header uncluttered). */
export function ModeToggle() {
const [mode, setMode] = useState(getThemeMode())
return (
<button
type="button" className="mode" aria-label="Toggle light/dark theme"
onClick={() => {
const next = mode === 'light' ? 'dark' : 'light'
setThemeMode(next)
setMode(next)
}}
>
{mode === 'light' ? '☾' : '☀'}
</button>
)
}
/** Compact mode toggle + accent picker; drops into any header (used in Settings). */
export function ThemeSwitcher() { export function ThemeSwitcher() {
const [mode, setMode] = useState(getThemeMode()) const [mode, setMode] = useState(getThemeMode())
const [accent, setAccentState] = useState(getAccent()) const [accent, setAccentState] = useState(getAccent())

Loading…
Cancel
Save