feat(web): design tokens, Tailwind config, base components (TEC-3057)

- Add chart palette, motion, and z-index CSS vars to globals.css
- Replace custom theme-provider with next-themes (dark default)
- Extend tailwind.config.ts with heading fonts, spacing (row-compact,
  row-roomy, sidebar), chart colors, elevation shadows, glow shadows,
  transition timing, pill border-radius, z-index scale
- Update tick-flash animations to match design token spec (480ms)
- Add prefers-reduced-motion support for all animations
- Create base design-system components:
  Surface, SurfaceElevated, Divider, DensityProvider/useDensity,
  Numeric (VND/percent/compact formatting), Signal (up/down/neutral pill)
- Add dev-only /dev/tokens showcase route (404 in production)
- Update theme-provider tests to match next-themes integration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 03:19:40 +07:00
parent e1beda2573
commit 7d6fcb4d8d
13 changed files with 665 additions and 138 deletions

View File

@@ -0,0 +1,70 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
export type DensityMode = 'compact' | 'regular' | 'roomy';
interface DensityContextValue {
density: DensityMode;
setDensity: (mode: DensityMode) => void;
}
const STORAGE_KEY = 'goodgo.density';
const DensityContext = createContext<DensityContextValue>({
density: 'regular',
setDensity: () => {},
});
export function useDensity() {
return useContext(DensityContext);
}
/** Row height in Tailwind spacing tokens per density mode. */
export const DENSITY_ROW_HEIGHT: Record<DensityMode, string> = {
compact: 'h-row-compact', // 32px
regular: 'h-row', // 36px
roomy: 'h-row-roomy', // 44px
};
/** Cell padding classes per density mode. */
export const DENSITY_CELL_PADDING: Record<DensityMode, string> = {
compact: 'px-2 py-1', // 4px 8px
regular: 'px-2.5 py-1.5', // 6px 10px
roomy: 'px-3 py-2.5', // 10px 12px
};
/** Data font size per density mode. */
export const DENSITY_DATA_FONT: Record<DensityMode, string> = {
compact: 'text-data-sm',
regular: 'text-data-md',
roomy: 'text-data-md',
};
export function DensityProvider({
defaultDensity = 'regular',
children,
}: {
defaultDensity?: DensityMode;
children: React.ReactNode;
}) {
const [density, setDensityState] = useState<DensityMode>(defaultDensity);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as DensityMode | null;
if (stored === 'compact' || stored === 'regular' || stored === 'roomy') {
setDensityState(stored);
}
}, []);
const setDensity = useCallback((mode: DensityMode) => {
setDensityState(mode);
localStorage.setItem(STORAGE_KEY, mode);
}, []);
return (
<DensityContext.Provider value={{ density, setDensity }}>
{children}
</DensityContext.Provider>
);
}

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
export interface DividerProps extends React.HTMLAttributes<HTMLDivElement> {
/** Use border-strong variant for headers/section separators. */
strong?: boolean;
/** Orientation. Default horizontal. */
orientation?: 'horizontal' | 'vertical';
}
export function Divider({
strong = false,
orientation = 'horizontal',
className,
...props
}: DividerProps) {
return (
<div
role="separator"
aria-orientation={orientation}
className={cn(
strong ? 'bg-border-strong' : 'bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
);
}

View File

@@ -18,3 +18,34 @@ export type { DashboardLayoutProps } from './dashboard-layout';
export { TickerStrip } from './ticker-strip';
export type { TickerStripProps, TickerItem } from './ticker-strip';
export { Badge } from './badge';
export type { BadgeProps } from './badge';
export { StatusChip } from './status-chip';
export type { StatusChipProps, PropertyStatus } from './status-chip';
export { DensityToggle } from './density-toggle';
export type { DensityToggleProps } from './density-toggle';
export { EmptyState } from './empty-state';
export type { EmptyStateProps } from './empty-state';
export { Skeleton } from './skeleton';
export type { SkeletonProps } from './skeleton';
export { KpiCard } from './kpi-card';
export type { KpiCardProps } from './kpi-card';
export { Surface, SurfaceElevated } from './surface';
export { Divider } from './divider';
export type { DividerProps } from './divider';
export { DensityProvider, useDensity, DENSITY_ROW_HEIGHT, DENSITY_CELL_PADDING, DENSITY_DATA_FONT } from './density-provider';
export type { DensityMode } from './density-provider';
export { Numeric } from './numeric';
export type { NumericProps } from './numeric';
export { Signal } from './signal';
export type { SignalDirection, SignalProps } from './signal';

View File

@@ -0,0 +1,64 @@
import { cn } from '@/lib/utils';
export interface NumericProps extends React.HTMLAttributes<HTMLSpanElement> {
/** The numeric value to display. */
value: number;
/** Format style. Default 'vnd'. */
format?: 'vnd' | 'percent' | 'decimal' | 'compact';
/** Number of fraction digits for percent/decimal. Default 1 for percent, 0 for vnd. */
fractionDigits?: number;
}
const vndFormatter = new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
maximumFractionDigits: 0,
});
const compactFormatter = new Intl.NumberFormat('vi-VN', {
notation: 'compact',
maximumFractionDigits: 1,
});
function formatValue(
value: number,
format: NumericProps['format'],
fractionDigits?: number,
): string {
switch (format) {
case 'percent':
return `${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits ?? 1)}%`;
case 'decimal':
return new Intl.NumberFormat('vi-VN', {
minimumFractionDigits: fractionDigits ?? 0,
maximumFractionDigits: fractionDigits ?? 2,
}).format(value);
case 'compact':
return compactFormatter.format(value);
case 'vnd':
default:
return vndFormatter.format(value);
}
}
/**
* Numeric display — right-aligned, tabular-nums, formatted for VND/percent.
* Automatically sets `data-numeric` for global tabular-nums styling.
*/
export function Numeric({
value,
format = 'vnd',
fractionDigits,
className,
...props
}: NumericProps) {
return (
<span
data-numeric
className={cn('text-right font-mono tabular-nums', className)}
{...props}
>
{formatValue(value, format, fractionDigits)}
</span>
);
}

View File

@@ -0,0 +1,46 @@
import { ArrowDown, ArrowUp, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
export type SignalDirection = 'up' | 'down' | 'neutral';
export interface SignalProps {
/** Direction of the signal. */
direction: SignalDirection;
/** Text label shown inside the pill. */
label?: string;
/** Additional class names. */
className?: string;
}
const directionStyles: Record<SignalDirection, string> = {
up: 'bg-signal-up/10 text-signal-up',
down: 'bg-signal-down/10 text-signal-down',
neutral: 'bg-signal-neutral/10 text-signal-neutral',
};
const icons: Record<SignalDirection, React.ElementType> = {
up: ArrowUp,
down: ArrowDown,
neutral: Minus,
};
/**
* Signal pill — shows direction (up/down/neutral) with arrow icon and optional label.
* Uses `--signal-*` design tokens.
*/
export function Signal({ direction, label, className }: SignalProps) {
const Icon = icons[direction];
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium',
directionStyles[direction],
className,
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{label && <span>{label}</span>}
</span>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
/** Flat surface — uses `--background` (app bg). */
export function Surface({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg bg-background text-foreground', className)}
{...props}
/>
);
}
/** Elevated surface — uses `--background-elevated` with shadow-elevation-1. */
export function SurfaceElevated({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'rounded-lg bg-background-elevated text-foreground shadow-elevation-1',
className,
)}
{...props}
/>
);
}