Skip to content

Commit 4727fd5

Browse files
committed
refactor(web): refine dashboard UI hierarchy
Improve shared component spacing, typography, controls, tables, feedback states, and accessibility. Simplify sidebar and explore navigation, consolidate dashboard actions and status ownership, and expand the onboarding experience.
1 parent 64c1963 commit 4727fd5

43 files changed

Lines changed: 681 additions & 750 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

web/src/__tests__/mobile.viewport.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,15 @@ describe('mobile.viewport :: shared primitives at 375px', () => {
143143
});
144144
});
145145

146-
it('PageHeader stacks vertically on mobile, row on ≥ sm', () => {
146+
it('PageHeader stacks vertically until the large-screen action layout', () => {
147147
patchMatchMedia(() => false);
148148
const { container } = render(
149149
<I18nextProvider i18n={i18n}>
150150
<PageHeader title="Charging" subtitle="14 sessions" actions={<button>Export</button>} />
151151
</I18nextProvider>,
152152
);
153153
// The flex container is the first descendant of the FadeIn wrapper.
154-
const flex = container.querySelector('.flex.flex-col.sm\\:flex-row') as HTMLElement | null;
154+
const flex = container.querySelector('.flex.flex-col.lg\\:flex-row') as HTMLElement | null;
155155
expect(flex).not.toBeNull();
156156
// Subtitle uses a readable text-sm size regardless of viewport (no
157157
// shrunken text-xs on mobile — PageHeader subtitles stay legible).

web/src/components/charts/ChartContainer.tsx

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { cn } from '@/lib/cn';
55
import { Spinner } from '@/components/feedback/Spinner';
66
import { EmptyState } from '@/components/feedback/EmptyState';
77
import { SectionErrorBoundary } from '@/components/feedback/SectionErrorBoundary';
8-
import { Button } from '@/components/ui/Button';
9-
import { Text } from '@/components/ui/Typography';
10-
import { FullscreenButton } from '@/components/ui/FullscreenButton';
8+
import { Button, FullscreenButton, Heading, Text } from '@/components/ui';
119
import { VisuallyHidden } from '@/components/a11y';
1210
import { useChartExport } from '@/hooks/useChartExport';
1311
import { downloadCSV, objectsToCSV, defaultExportFilename, type CsvCellValue } from '@/lib/csvExport';
@@ -363,7 +361,7 @@ export const ChartContainer = forwardRef<HTMLDivElement, ChartContainerProps>(
363361
// ignored the active theme entirely — on all 140 presets a chart
364362
// frame rendered generic white/near-black instead of the palette's
365363
// own surface, so charts never matched the panels beside them.
366-
'group rounded-panel border border-[var(--panel-border)] bg-[var(--panel-bg)] p-4 shadow-panel',
364+
'group rounded-panel border border-[var(--panel-border)] bg-[var(--panel-bg)] p-5 shadow-panel',
367365
// Tailwind preflight already removes default <figure> margins;
368366
// re-state `m-0` defensively so any consumer override of preflight
369367
// doesn't shift the chart vertical rhythm.
@@ -379,19 +377,13 @@ export const ChartContainer = forwardRef<HTMLDivElement, ChartContainerProps>(
379377
className,
380378
)}
381379
>
382-
<div className="mb-4 flex items-start justify-between border-b border-[var(--border-subtle)] pb-3">
383-
<div>
384-
<Text
385-
as="h3"
386-
id={titleId}
387-
size="sm"
388-
weight="semibold"
389-
color="primary"
390-
>
380+
<div className="mb-5 flex items-start justify-between gap-4 border-b border-[var(--border-subtle)] pb-4">
381+
<div className="min-w-0">
382+
<Heading level="panel" id={titleId}>
391383
{title}
392-
</Text>
384+
</Heading>
393385
{subtitle && (
394-
<Text as="p" variant="caption">{subtitle}</Text>
386+
<Text as="p" variant="caption" className="mt-1 leading-relaxed">{subtitle}</Text>
395387
)}
396388
</div>
397389
<div

web/src/components/data-display/MetricBar.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,29 @@ import { fmtNumber } from '../../lib/numberFormat'
1717
export function MetricBar({ value, max, color, label, sublabel }: {
1818
value: number; max: number; color: string; label: string; sublabel?: string
1919
}) {
20-
const pct = Math.min((value / max) * 100, 100)
20+
const safeValue = Number.isFinite(value) ? value : 0
21+
const safeMax = Number.isFinite(max) && max > 0 ? max : 0
22+
const pct = safeMax > 0 ? Math.min(Math.max((safeValue / safeMax) * 100, 0), 100) : 0
23+
const boundedValue = safeMax > 0 ? Math.min(Math.max(safeValue, 0), safeMax) : 0
2124
return (
22-
<div>
23-
<div className="flex items-center justify-between mb-1.5">
24-
<span className="text-xs font-medium text-[var(--text-secondary)]">{label}</span>
25-
<span className="text-xs font-mono" style={{ color }}>{sublabel ?? fmtNumber(value)}</span>
25+
<div
26+
role="progressbar"
27+
aria-label={label}
28+
aria-valuemin={0}
29+
aria-valuemax={safeMax}
30+
aria-valuenow={boundedValue}
31+
>
32+
<div className="mb-2 flex items-center justify-between gap-3">
33+
<span className="text-sm font-medium text-[var(--text-secondary)]">{label}</span>
34+
<span className="font-mono text-sm" style={{ color }}>{sublabel ?? fmtNumber(safeValue)}</span>
2635
</div>
27-
<div className="h-2 rounded-full bg-[var(--surface-2)] overflow-hidden">
36+
<div className="h-2.5 overflow-hidden rounded-pill bg-[var(--surface-2)]">
2837
<motion.div
29-
className="h-full rounded-full"
38+
className="h-full rounded-pill"
3039
initial={{ width: 0 }}
3140
animate={{ width: `${pct}%` }}
3241
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
33-
style={{ background: `linear-gradient(90deg, ${color}99, ${color})`, boxShadow: `0 0 8px ${color}40` }}
42+
style={{ background: `linear-gradient(90deg, ${color}99, ${color})` }}
3443
/>
3544
</div>
3645
</div>

web/src/components/data-display/MetricCard.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { type ReactNode } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { cn } from '../../lib/cn'
44
import { type NeonColor, neonColorMap } from '../../lib/tokens'
5-
import { Card } from '../ui/Card'
6-
import { HelpTooltip, type HelpTooltipProps } from '../ui/HelpTooltip'
5+
import { Card, HelpTooltip, Text, type HelpTooltipProps } from '@/components/ui'
76
import { Delta, type DeltaProps } from './Delta'
87

98
/**
@@ -52,13 +51,17 @@ export function MetricCard({ label, value, icon, color = 'cyan', change, delta,
5251
<Card
5352
padding="none"
5453
data-role="metric-card"
55-
className={cn('min-h-24 p-4', className)}
54+
className={cn('min-h-28 p-5', className)}
5655
>
57-
<div className="flex items-start justify-between gap-3">
56+
<div className="flex items-start justify-between gap-4">
5857
<div className="min-w-0 flex-1">
59-
<p
58+
<Text
59+
as="p"
60+
size="sm"
61+
weight="medium"
62+
color="secondary"
6063
data-role="metric-label"
61-
className="flex items-center gap-1 truncate text-xs font-medium leading-snug text-[var(--text-secondary)]"
64+
className="flex items-center gap-1.5 truncate leading-snug"
6265
>
6366
<span className="truncate">{label}</span>
6467
{help && (
@@ -68,22 +71,26 @@ export function MetricCard({ label, value, icon, color = 'cyan', change, delta,
6871
ariaLabel={help.ariaLabel ?? t('metricCard.moreInfoAbout', 'More info about {{label}}', { label })}
6972
/>
7073
)}
71-
</p>
72-
<p
74+
</Text>
75+
<Text
76+
as="p"
77+
size="3xl"
78+
weight="semibold"
79+
color="primary"
7380
data-role="metric-value"
74-
className="mt-3 text-2xl font-semibold leading-tight tracking-[-0.025em] text-[var(--text-primary)]"
81+
className="mt-3 leading-tight tracking-[-0.025em] tabular-nums"
7582
>
7683
{value}
77-
</p>
84+
</Text>
7885
{subtitle && (
79-
<p data-role="metric-subtitle" className="mt-1.5 truncate text-2xs text-[var(--text-muted)]">
86+
<Text as="p" variant="caption" data-role="metric-subtitle" className="mt-1.5 truncate">
8087
{subtitle}
81-
</p>
88+
</Text>
8289
)}
8390
{change && !delta && (
84-
<p className={cn('mt-1 text-2xs font-medium', change.positive ? 'text-emerald-300' : 'text-rose-300')}>
91+
<Text as="p" size="xs" weight="medium" className={cn('mt-1.5', change.positive ? 'text-emerald-300' : 'text-rose-300')}>
8592
{change.positive ? '↑' : '↓'} {change.value}
86-
</p>
93+
</Text>
8794
)}
8895
{delta && (
8996
<div className="mt-1">
@@ -95,7 +102,7 @@ export function MetricCard({ label, value, icon, color = 'cyan', change, delta,
95102
<div
96103
data-role="metric-icon"
97104
data-color={color}
98-
className="flex shrink-0 items-center justify-center rounded-shape-md border border-[var(--border-default)] bg-[var(--surface-2)] p-2"
105+
className="flex shrink-0 items-center justify-center rounded-shape-lg border border-[var(--border-default)] bg-[var(--surface-2)] p-2.5 shadow-e1"
99106
>
100107
<div className={c.text}>{icon}</div>
101108
</div>

web/src/components/data-display/StatCard.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe('StatCard — core rendering', () => {
5151
const { container } = render(
5252
<StatCard label="X" value={1} className="col-span-2" />,
5353
);
54-
expect(card(container)).toHaveClass('flex', 'flex-col', 'gap-1', 'col-span-2');
54+
expect(card(container)).toHaveClass('flex', 'flex-col', 'gap-2', 'min-h-28', 'p-5', 'col-span-2');
5555
});
5656
});
5757

@@ -117,7 +117,7 @@ describe('StatCard — trend', () => {
117117
/>,
118118
);
119119
const row = screen.getByText('+12%').closest('div') as HTMLElement;
120-
expect(row).toHaveClass('text-green-600');
120+
expect(row).toHaveClass('text-emerald-700', 'dark:text-emerald-300');
121121
// Direction is conveyed by a word too, not colour alone (WCAG 1.4.1).
122122
expect(within(row).getByText('increased')).toBeInTheDocument();
123123
const glyph = within(row).getByText('↑');
@@ -133,7 +133,7 @@ describe('StatCard — trend', () => {
133133
/>,
134134
);
135135
const row = screen.getByText('-8%').closest('div') as HTMLElement;
136-
expect(row).toHaveClass('text-red-600');
136+
expect(row).toHaveClass('text-rose-700', 'dark:text-rose-300');
137137
expect(within(row).getByText('decreased')).toBeInTheDocument();
138138
expect(within(row).getByText('↓')).toBeInTheDocument();
139139
});
@@ -159,8 +159,8 @@ describe('StatCard — trend', () => {
159159
/>,
160160
);
161161
const row = screen.getByText('-5%').closest('div') as HTMLElement;
162-
expect(row).toHaveClass('text-green-600');
163-
expect(row).not.toHaveClass('text-red-600');
162+
expect(row).toHaveClass('text-emerald-700', 'dark:text-emerald-300');
163+
expect(row).not.toHaveClass('text-rose-700');
164164
});
165165

166166
it('omits the trend row when no trend is supplied', () => {

web/src/components/data-display/StatCard.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type ReactNode } from 'react';
22
import { useTranslation } from 'react-i18next';
3-
import { Card } from '@/components/ui/Card';
3+
import { Card, MetricValue, Text } from '@/components/ui';
44
import { Skeleton } from '@/components/feedback/Skeleton';
55
import { cn } from '@/lib/cn';
66
import { VisuallyHidden } from '@/components/a11y';
@@ -74,38 +74,44 @@ export function StatCard({ label, value, unit, icon, trend, sublabel, loading, c
7474
: t('statCard.trend.unchanged', 'no change');
7575

7676
return (
77-
<Card className={cn('flex flex-col gap-1', className)}>
78-
<div className="flex items-center justify-between">
79-
<span className="text-sm font-medium text-[var(--text-muted)]">{label}</span>
77+
<Card className={cn('flex min-h-28 flex-col gap-2 p-5', className)}>
78+
<div className="flex items-center justify-between gap-3">
79+
<Text as="span" size="sm" weight="medium" color="secondary">{label}</Text>
8080
{icon ? (
81-
<span aria-hidden="true" className="text-[var(--text-muted)]">
81+
<span
82+
aria-hidden="true"
83+
className="rounded-shape-md border border-[var(--border-default)] bg-[var(--surface-2)] p-2 text-[var(--text-muted)]"
84+
>
8285
{icon}
8386
</span>
8487
) : null}
8588
</div>
8689
<div className="flex items-baseline gap-1">
87-
<span className="text-2xl font-bold">{displayValue}</span>
90+
<MetricValue>{displayValue}</MetricValue>
8891
{unit && valueIsPresent ? (
89-
<span className="text-sm text-[var(--text-muted)]">{unit}</span>
92+
<Text as="span" size="sm" color="muted">{unit}</Text>
9093
) : null}
9194
</div>
9295
{trend ? (
93-
<div
96+
<Text
97+
as="div"
98+
size="xs"
99+
weight="medium"
94100
className={cn(
95-
'flex items-center gap-1 text-xs',
101+
'flex items-center gap-1',
96102
trend.positive
97-
? 'text-green-600'
103+
? 'text-emerald-700 dark:text-emerald-300'
98104
: direction === 'flat'
99105
? 'text-[var(--text-muted)]'
100-
: 'text-red-600',
106+
: 'text-rose-700 dark:text-rose-300',
101107
)}
102108
>
103109
<span aria-hidden="true">{TREND_GLYPH[direction]}</span>
104110
<VisuallyHidden>{trendDescription}</VisuallyHidden>
105111
<span>{trend.value ?? ''}</span>
106-
</div>
112+
</Text>
107113
) : null}
108-
{sublabel ? <span className="text-xs text-[var(--text-muted)]">{sublabel}</span> : null}
114+
{sublabel ? <Text as="span" variant="caption">{sublabel}</Text> : null}
109115
</Card>
110116
);
111117
}

web/src/components/feedback/AlertBanner.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ describe('AlertBanner', () => {
4848

4949
it('maps each variant to theme-safe border, title, and body colours', () => {
5050
const cases: Array<[AlertVariant, RegExp, string, string, string, string]> = [
51-
['info', /border-neon-cyan/, 'text-cyan-800', 'dark:text-cyan-300', 'text-cyan-800', 'dark:text-cyan-200'],
52-
['success', /border-neon-green/, 'text-emerald-800', 'dark:text-emerald-300', 'text-emerald-800', 'dark:text-emerald-200'],
53-
['warning', /border-neon-amber/, 'text-amber-800', 'dark:text-amber-300', 'text-amber-800', 'dark:text-amber-200'],
54-
['danger', /border-neon-red/, 'text-rose-800', 'dark:text-rose-300', 'text-rose-800', 'dark:text-rose-200'],
51+
['info', /border-neon-cyan/, 'text-cyan-900', 'dark:text-cyan-200', 'text-cyan-800', 'dark:text-cyan-100'],
52+
['success', /border-neon-green/, 'text-emerald-900', 'dark:text-emerald-200', 'text-emerald-800', 'dark:text-emerald-100'],
53+
['warning', /border-neon-amber/, 'text-amber-900', 'dark:text-amber-200', 'text-amber-800', 'dark:text-amber-100'],
54+
['danger', /border-neon-red/, 'text-rose-900', 'dark:text-rose-200', 'text-rose-800', 'dark:text-rose-100'],
5555
]
5656
for (const [variant, borderRe, lightTitle, darkTitle, lightBody, darkBody] of cases) {
5757
const { container, unmount } = render(

web/src/components/feedback/AlertBanner.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type ReactNode, type HTMLAttributes } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { cn } from '../../lib/cn'
4+
import { Button } from '@/components/ui'
45
import { X } from 'lucide-react'
56

67
export type AlertVariant = 'info' | 'success' | 'warning' | 'danger'
@@ -20,10 +21,10 @@ export interface AlertBannerProps extends HTMLAttributes<HTMLDivElement> {
2021
}
2122

2223
const alertVariantMap: Record<AlertVariant, { border: string; bg: string; text: string; titleText: string }> = {
23-
info: { border: 'border-neon-cyan/20', bg: 'bg-neon-cyan/5', text: 'text-cyan-800 dark:text-cyan-200', titleText: 'text-cyan-800 dark:text-cyan-300' },
24-
success: { border: 'border-neon-green/20', bg: 'bg-neon-green/5', text: 'text-emerald-800 dark:text-emerald-200', titleText: 'text-emerald-800 dark:text-emerald-300' },
25-
warning: { border: 'border-neon-amber/20', bg: 'bg-neon-amber/5', text: 'text-amber-800 dark:text-amber-200', titleText: 'text-amber-800 dark:text-amber-300' },
26-
danger: { border: 'border-neon-red/20', bg: 'bg-neon-red/5', text: 'text-rose-800 dark:text-rose-200', titleText: 'text-rose-800 dark:text-rose-300' },
24+
info: { border: 'border-neon-cyan/25', bg: 'bg-neon-cyan/10', text: 'text-cyan-800 dark:text-cyan-100', titleText: 'text-cyan-900 dark:text-cyan-200' },
25+
success: { border: 'border-neon-green/25', bg: 'bg-neon-green/10', text: 'text-emerald-800 dark:text-emerald-100', titleText: 'text-emerald-900 dark:text-emerald-200' },
26+
warning: { border: 'border-neon-amber/25', bg: 'bg-neon-amber/10', text: 'text-amber-800 dark:text-amber-100', titleText: 'text-amber-900 dark:text-amber-200' },
27+
danger: { border: 'border-neon-red/25', bg: 'bg-neon-red/10', text: 'text-rose-800 dark:text-rose-100', titleText: 'text-rose-900 dark:text-rose-200' },
2728
}
2829

2930
/**
@@ -49,24 +50,26 @@ export function AlertBanner({ variant, title, children, onClose, icon, className
4950
const { t } = useTranslation()
5051
const v = alertVariantMap[variant] ?? alertVariantMap.info
5152
return (
52-
<div className={cn('flex items-start gap-3 rounded-xl border p-4 backdrop-blur-sm', v.border, v.bg, className)} {...props}>
53+
<div className={cn('flex items-start gap-3.5 rounded-panel border p-4 shadow-e1 backdrop-blur-sm', v.border, v.bg, className)} {...props}>
5354
{icon && <div className={cn('shrink-0 mt-0.5', v.titleText)} aria-hidden>{icon}</div>}
5455
<div className="flex-1 min-w-0">
55-
{title && <p className={cn('text-sm font-medium', v.titleText)}>{title}</p>}
56-
<div className={cn('text-xs', v.text, title && 'mt-0.5')}>{children}</div>
56+
{title && <p className={cn('text-sm font-semibold', v.titleText)}>{title}</p>}
57+
<div className={cn('text-sm leading-relaxed', v.text, title && 'mt-1')}>{children}</div>
5758
</div>
5859
{onClose && (
59-
<button
60+
<Button
6061
type="button"
62+
variant="ghost"
63+
size="sm"
6164
onClick={onClose}
6265
aria-label={closeLabel ?? t('common.dismiss', 'Dismiss')}
6366
className={cn(
64-
'shrink-0 rounded-lg p-1.5 transition-colors hover:bg-[var(--surface-2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-current',
67+
'h-8 w-8 shrink-0 rounded-shape-md p-0 transition-colors hover:bg-[var(--surface-2)] focus-visible:ring-current',
6568
v.text,
6669
)}
6770
>
68-
<X className="h-3.5 w-3.5" aria-hidden />
69-
</button>
71+
<X className="h-4 w-4" aria-hidden />
72+
</Button>
7073
)}
7174
</div>
7275
)

web/src/components/feedback/EmptyState.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,33 @@ interface EmptyStateProps {
1818
// shared Button constants rather than hand-copied, so the visual stays in
1919
// lock-step with the component library by construction — a re-skin of the
2020
// neutral variants now reaches this CTA automatically.
21-
const linkButtonClasses = cn(BUTTON_BASE, BUTTON_VARIANTS.secondary, 'h-8 px-3 text-xs');
21+
const linkButtonClasses = cn(BUTTON_BASE, BUTTON_VARIANTS.secondary, 'h-10 px-4 text-sm');
2222

2323
export function EmptyState({ icon, title, message, action, actionTo, className }: EmptyStateProps) {
2424
return (
2525
<div
2626
role="status"
27-
className={cn('flex flex-col items-center justify-center py-16 text-center', className)}
27+
className={cn('flex flex-col items-center justify-center px-4 py-16 text-center', className)}
2828
>
29-
{icon && <div className="mb-4 text-[var(--text-muted)]">{icon}</div>}
29+
{icon && (
30+
<div className="mb-5 rounded-shape-xl border border-[var(--border-default)] bg-[var(--surface-2)] p-3 text-[var(--theme-primary)] shadow-e1">
31+
{icon}
32+
</div>
33+
)}
3034
{title && (
31-
<Heading level="panel" className="mb-1">
35+
<Heading level="panel" className="mb-2">
3236
{title}
3337
</Heading>
3438
)}
35-
<Text variant="bodySm" as="p" className="mb-4 max-w-md">
39+
<Text variant="bodySm" as="p" className="mb-6 max-w-lg leading-relaxed">
3640
{message}
3741
</Text>
3842
{actionTo ? (
3943
<Link to={actionTo.to} className={linkButtonClasses}>
4044
{actionTo.label}
4145
</Link>
4246
) : action ? (
43-
<CtaButton onClick={action.onClick} variant="secondary" size="sm">
47+
<CtaButton onClick={action.onClick} variant="secondary" size="md">
4448
{action.label}
4549
</CtaButton>
4650
) : null}

0 commit comments

Comments
 (0)