fix(redesign): final-review fixes — palette races+a11y, avatar dark contrast, micro-label floor, drawer focus, pulse legend/labels/targets, warn contrast

26 adversarially-confirmed findings from the multi-agent whole-branch review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent fffcc2a05d
commit e9357bd8e6

@ -20,6 +20,7 @@ export function Layout() {
const [paletteOpen, setPaletteOpen] = useState(false)
const [clientHits, setClientHits] = useState<PaletteItem[]>([])
const queryTimer = useRef<number | undefined>(undefined)
const querySeq = useRef(0)
useEffect(() => {
if (!hasSession()) return
@ -42,10 +43,14 @@ export function Layout() {
// Close the mobile drawer when the route changes.
useEffect(() => { setMenuOpen(false) }, [location.pathname])
// Cancel any pending palette query on unmount.
useEffect(() => () => window.clearTimeout(queryTimer.current), [])
// Ctrl+K / Cmd+K opens the palette.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
if (e.repeat) return
e.preventDefault()
setPaletteOpen((v) => !v)
}
@ -68,17 +73,25 @@ export function Layout() {
const onPaletteQuery = (q: string) => {
window.clearTimeout(queryTimer.current)
if (q.trim().length < 2) { setClientHits([]); return }
if (q.trim().length < 2) { querySeq.current++; setClientHits([]); return }
const mySeq = ++querySeq.current
queryTimer.current = window.setTimeout(() => {
getClients(q)
.then((cs) => setClientHits(cs.slice(0, 8).map((c) => ({
.then((cs) => {
if (querySeq.current !== mySeq) return
setClientHits(cs.slice(0, 8).map((c) => ({
id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status,
}))))
.catch(() => setClientHits([]))
})))
})
.catch(() => {
if (querySeq.current !== mySeq) return
setClientHits([{ id: 'err:clients', label: 'Client search failed — is the server up?', group: 'Clients' }])
})
}, 200)
}
const onPaletteSelect = (item: PaletteItem) => {
if (item.id.startsWith('err:')) return
if (item.id.startsWith('nav:')) nav(item.id.slice(4))
else if (item.id.startsWith('act:')) nav(item.id.slice(4))
else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`)
@ -93,6 +106,7 @@ export function Layout() {
const name = displayName()
const av = avatarColors(name)
const badgeCount = queueCounts.queued + queueCounts.failed
const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}`
return (
<div className="hq-shell">
@ -110,7 +124,11 @@ export function Layout() {
<n.icon size={15} strokeWidth={1.8} aria-hidden />
<span className="lbl">{n.label}</span>
{n.to === '/' && badgeCount > 0 && (
<span className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`}>{badgeCount}</span>
<span
className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`}
title={badgeTitle}
aria-label={badgeTitle}
>{badgeCount}</span>
)}
</NavLink>
))}
@ -130,7 +148,7 @@ export function Layout() {
</div>
)}
<header className="hq-top">
<button type="button" className="wf hq-burger" aria-label="Menu" onClick={() => setMenuOpen((v) => !v)}>
<button type="button" className="wf hq-burger" aria-label="Menu" aria-expanded={menuOpen} onClick={() => setMenuOpen((v) => !v)}>
<Menu size={16} />
</button>
<button type="button" className="hq-search" onClick={() => setPaletteOpen(true)}>

@ -8,7 +8,7 @@
.hq-brand small { display: block; color: var(--text-dim); font-weight: 400; font-size: 11.5px; }
.hq-group { margin-bottom: 10px; }
.hq-eyebrow {
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); padding: 4px 16px;
}
.hq-side a {
@ -104,10 +104,11 @@
.hq-shell { grid-template-columns: 1fr; }
.hq-side {
position: fixed; inset: 0 auto 0 0; width: 240px; z-index: 40;
transform: translateX(-100%); transition: transform var(--speed) ease;
transform: translateX(-100%); visibility: hidden;
transition: transform var(--speed) ease, visibility 0s var(--speed);
box-shadow: var(--shadow-lg);
}
.hq-side.open { transform: translateX(0); }
.hq-side.open { transform: translateX(0); visibility: visible; transition: transform var(--speed) ease; }
.hq-scrim { display: block; position: fixed; inset: 0; z-index: 39; background: rgba(15, 14, 11, 0.4); }
.hq-burger { display: inline-flex; }
.hq-search { min-width: 0; flex: 1; }
@ -128,11 +129,11 @@ table.wf { display: block; overflow-x: auto; }
}
.pulse-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.pulse-head h3 { margin: 0; font-size: 15px; }
.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 9.5px; letter-spacing: 0.06em; color: var(--text-dim); }
.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 11px; letter-spacing: 0.06em; color: var(--text-dim); }
.pulse-legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.pulse-grid { display: grid; gap: 2px 0; margin-top: 12px; }
.pulse-lane {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-dim); padding: 6px 8px 6px 0; white-space: nowrap;
}
.pulse-cell {
@ -141,14 +142,16 @@ table.wf { display: block; overflow-x: auto; }
flex-wrap: wrap;
}
.pulse-dot {
position: relative;
width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
transition: transform 80ms;
}
.pulse-dot::before { content: ''; position: absolute; inset: -8px; }
.pulse-dot:hover { transform: scale(1.5); }
.pulse-more { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); }
.pulse-more { font-size: 11px; color: var(--text-dim); font-family: var(--mono); }
.pulse-month {
border-left: 1px solid var(--border);
font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); padding: 3px;
font-size: 11px; color: var(--text-dim); font-family: var(--mono); padding: 3px;
}
.pulse-readout {
margin-top: 10px; border-left: 2px solid var(--accent);

@ -24,8 +24,8 @@ export function PulseRibbon(props: {
<div className="pulse-head">
<h3>Relationship pulse last 12 months</h3>
<div className="pulse-legend">
{LANES.map((lane, i) => (
<span key={lane}><span className="dot" style={{ background: TONE_VAR[i === 0 ? 'accent' : i === 1 ? 'ok' : i === 2 ? 'warn' : 'err'] }} />{lane.toUpperCase()}</span>
{([['accent', 'ACTIVITY'], ['ok', 'PAID / POSITIVE'], ['warn', 'PENDING / RENEWAL'], ['err', 'NEEDS ATTENTION']] as const).map(([tone, label]) => (
<span key={tone}><span className="dot" style={{ background: TONE_VAR[tone] }} />{label}</span>
))}
</div>
</div>

@ -82,7 +82,7 @@ export function ClientDetail() {
})),
...(interactions.data ?? []).map((i): PulseEvent => ({
id: `int:${i.id}`, lane: 2, date: i.onDate,
label: `${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? `${i.notes.slice(0, 60)}` : ''}`,
label: `${i.outcome !== null ? i.outcome + ' · ' : ''}${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? `${i.notes.slice(0, 60)}` : ''}`,
tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn',
})),
...(amc.data ?? []).flatMap((a): PulseEvent[] => [
@ -129,6 +129,8 @@ export function ClientDetail() {
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<Button onClick={() => setTab('interactions')}>Log call</Button>
<Button onClick={() => setTab('payments')}>Record payment</Button>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
}

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { createClient, getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
@ -9,9 +9,13 @@ export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): {
} {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<string | undefined>()
const seqRef = useRef(0)
const reload = () => {
setError(undefined)
loader().then(setData).catch((e: Error) => setError(e.message))
const mySeq = ++seqRef.current
loader()
.then((d) => { if (seqRef.current === mySeq) setData(d) })
.catch((e: Error) => { if (seqRef.current === mySeq) setError(e.message) })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, deps)
@ -34,12 +38,19 @@ export function Clients() {
const nav = useNavigate()
const [q, setQ] = useState('')
const { data, error, reload } = useData(() => getClients(q), [q])
const [sp] = useSearchParams()
const [sp, setSp] = useSearchParams()
const [creating, setCreating] = useState(sp.get('new') === '1')
const [statusFilter, setStatusFilter] = useState('all')
useEffect(() => {
if (sp.get('new') === '1') { setCreating(true); setSp({}, { replace: true }) }
}, [sp, setSp])
const counts = new Map<string, number>()
for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1)
const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter)
const why = [
q !== '' ? ` matching “${q}` : '',
statusFilter !== 'all' ? ` with status ${statusFilter}` : '',
].filter((x) => x !== '').join('')
return (
<div className="wf-page">
@ -69,7 +80,7 @@ export function Clients() {
)}
{error !== undefined && <ErrorState message={error} onRetry={reload} />}
{data === undefined && error === undefined ? <Skeleton rows={6} />
: shown.length === 0 ? <EmptyState>No clients{q !== '' ? ` matching “${q}` : statusFilter !== 'all' ? ` with status ${statusFilter}` : ' yet — add the first one'}.</EmptyState> : (
: shown.length === 0 ? <EmptyState>No clients{why !== '' ? why : ' yet — add the first one'}.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
@ -115,7 +126,7 @@ function NewClientForm(props: { onCreated: (id: string) => void }) {
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Field label="Name"><input className="wf" value={f.name} autoFocus onChange={set('name')} /></Field>
<Field label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></Field>

@ -1,7 +1,7 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, FilterChips, Notice, PageHeader, Toolbar } from '@sims/ui'
import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import {
documentAction, getEmployees, getPipeline, isManagerial,
type Employee, type PipelineFilter, type PipelineRow, type PipelineStage,
@ -120,9 +120,9 @@ export function Pipeline() {
)}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{list.error !== undefined && <Notice tone="err">{list.error}</Notice>}
{list.error !== undefined && <ErrorState message={list.error} onRetry={list.reload} />}
{list.error !== undefined ? null
: rows === undefined ? <EmptyState>Loading</EmptyState>
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? <EmptyState>Nothing to chase the pipeline is clear.</EmptyState> : (
<>
<DataTable

@ -23,6 +23,6 @@ export function avatarColors(name: string): { bg: string; fg: string } {
const h = avatarHue(name)
return {
bg: `hsl(${h} 42% 50% / 0.16)`,
fg: `hsl(${h} 45% 38%)`,
fg: `light-dark(hsl(${h} 45% 34%), hsl(${h} 45% 74%))`,
}
}

@ -8,13 +8,12 @@ export function FilterChips(props: {
onChange: (key: string) => void
}) {
return (
<div className="wf-chips" role="tablist">
<div className="wf-chips" role="group">
{props.chips.map((c) => (
<button
key={c.key}
type="button"
role="tab"
aria-selected={c.key === props.active}
aria-pressed={c.key === props.active}
className={`wf-chip${c.key === props.active ? ' active' : ''}`}
onClick={() => props.onChange(c.key)}
>

@ -16,15 +16,22 @@ export function CommandPalette(props: {
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const prevFocus = useRef<HTMLElement | null>(null)
useEffect(() => {
if (props.open) { setQ(''); setSel(0); props.onQuery?.(''); inputRef.current?.focus() }
if (props.open) {
prevFocus.current = document.activeElement as HTMLElement | null
setQ(''); setSel(0); props.onQuery?.(''); inputRef.current?.focus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open])
useEffect(() => { if (!props.open) prevFocus.current?.focus() }, [props.open])
if (!props.open) return null
const hits = matchItems(props.items, q)
const cursor = hits.length === 0 ? 0 : Math.min(sel, hits.length - 1)
const groups: { name: string; items: { item: PaletteItem; index: number }[] }[] = []
hits.forEach((item, index) => {
const g = groups.find((x) => x.name === item.group)
@ -36,25 +43,37 @@ export function CommandPalette(props: {
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); props.onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, hits.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSel(Math.min(cursor + 1, Math.max(hits.length - 1, 0))) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(Math.max(cursor - 1, 0)) }
else if (e.key === 'Tab') { e.preventDefault() }
else if (e.key === 'Enter') {
e.preventDefault()
const item = hits[sel]
const item = hits[cursor]
if (item !== undefined) pick(item)
}
}
return (
<div className="wf-palette-back" onClick={props.onClose}>
<div className="wf-palette" onClick={(e) => e.stopPropagation()} onKeyDown={onKey}>
<div
className="wf-palette"
role="dialog"
aria-modal="true"
aria-label={props.placeholder ?? 'Command palette'}
onClick={(e) => e.stopPropagation()}
onKeyDown={onKey}
>
<input
ref={inputRef}
value={q}
placeholder={props.placeholder ?? 'Search…'}
role="combobox"
aria-controls="wf-palette-listbox"
aria-expanded="true"
aria-activedescendant={hits[cursor] !== undefined ? 'wf-pi-' + hits[cursor].id : undefined}
onChange={(e) => { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }}
/>
<div className="wf-palette-list">
<div className="wf-palette-list" role="listbox" id="wf-palette-listbox">
{hits.length === 0 && <div className="wf-palette-group">No matches</div>}
{groups.map((g) => (
<div key={g.name}>
@ -63,7 +82,10 @@ export function CommandPalette(props: {
<button
key={item.id}
type="button"
className={`wf-palette-item${index === sel ? ' sel' : ''}`}
role="option"
id={'wf-pi-' + item.id}
aria-selected={index === cursor}
className={`wf-palette-item${index === cursor ? ' sel' : ''}`}
onMouseEnter={() => setSel(index)}
onClick={() => pick(item)}
>

@ -8,13 +8,12 @@ export function Tabs(props: {
onChange: (key: string) => void
}) {
return (
<div className="wf-tabs" role="tablist">
<div className="wf-tabs">
{props.tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={t.key === props.active}
aria-current={t.key === props.active ? 'true' : undefined}
className={`wf-tab${t.key === props.active ? ' active' : ''}`}
onClick={() => props.onChange(t.key)}
>

@ -43,7 +43,7 @@
--text: #1a1a17;
--text-dim: #6b6a62;
--ok: #047857;
--warn: #a8702b;
--warn: #956325; /* 4.58:1 on --warn-soft #faf1e0 (hue-preserving darken of #a8702b) */
--err: #a64242;
--accent-soft: color-mix(in srgb, var(--accent) 10%, var(--bg-raised));
--ok-soft: #e2f4eb;
@ -331,7 +331,7 @@ table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }
}
.wf-palette-list { max-height: 46vh; overflow-y: auto; padding: 6px; }
.wf-palette-group {
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); padding: 8px 10px 4px;
}
.wf-palette-item {

Loading…
Cancel
Save