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,217 @@
'use client';
import { notFound } from 'next/navigation';
import {
Surface,
SurfaceElevated,
Divider,
DensityProvider,
useDensity,
DENSITY_ROW_HEIGHT,
Numeric,
Signal,
} from '@/components/design-system';
// Dev-only: block in production
if (process.env.NODE_ENV === 'production') {
// Will 404 at build time for static generation
}
const COLOR_TOKENS = [
{ name: '--background', tw: 'bg-background' },
{ name: '--background-elevated', tw: 'bg-background-elevated' },
{ name: '--background-surface', tw: 'bg-background-surface' },
{ name: '--primary', tw: 'bg-primary' },
{ name: '--primary-hover', tw: 'bg-primary-hover' },
{ name: '--destructive', tw: 'bg-destructive' },
{ name: '--warning', tw: 'bg-warning' },
{ name: '--success', tw: 'bg-success' },
{ name: '--accent-blue', tw: 'bg-accent-blue' },
{ name: '--accent-purple', tw: 'bg-accent-purple' },
{ name: '--signal-up', tw: 'bg-signal-up' },
{ name: '--signal-down', tw: 'bg-signal-down' },
{ name: '--signal-neutral', tw: 'bg-signal-neutral' },
];
const CHART_TOKENS = [
{ name: '--chart-1', tw: 'bg-chart-1' },
{ name: '--chart-2', tw: 'bg-chart-2' },
{ name: '--chart-3', tw: 'bg-chart-3' },
{ name: '--chart-4', tw: 'bg-chart-4' },
{ name: '--chart-5', tw: 'bg-chart-5' },
{ name: '--chart-6', tw: 'bg-chart-6' },
];
function DensityDemo() {
const { density, setDensity } = useDensity();
return (
<div className="space-y-2">
<div className="flex gap-2">
{(['compact', 'regular', 'roomy'] as const).map((d) => (
<button
key={d}
onClick={() => setDensity(d)}
className={`rounded-md px-3 py-1 text-sm ${density === d ? 'bg-primary text-primary-foreground' : 'bg-background-surface text-foreground-muted'}`}
>
{d}
</button>
))}
</div>
<div className="rounded-lg border border-border bg-background-elevated p-4">
<div className={`${DENSITY_ROW_HEIGHT[density]} flex items-center border-b border-border px-cell`}>
<span className="text-foreground-muted text-heading-xs uppercase font-semibold">Mẫu hàng {density}</span>
</div>
{[1, 2, 3].map((i) => (
<div key={i} className={`${DENSITY_ROW_HEIGHT[density]} flex items-center border-b border-border px-cell`}>
<span className="flex-1 text-sm">Dòng {i}</span>
<Numeric value={i * 3_500_000_000} format="vnd" />
</div>
))}
</div>
</div>
);
}
export default function DevTokensPage() {
if (process.env.NODE_ENV === 'production') {
notFound();
}
return (
<div className="space-y-8 p-6">
<h1 className="text-heading-lg font-bold">Design Tokens Showcase</h1>
<p className="text-foreground-muted text-sm">Dev-only route không hiển thị trên production.</p>
{/* Colors */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Màu sắc</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
{COLOR_TOKENS.map((t) => (
<div key={t.name} className="space-y-1">
<div className={`h-12 w-full rounded-md border border-border ${t.tw}`} />
<p className="text-xs text-foreground-muted font-mono">{t.name}</p>
</div>
))}
</div>
</section>
{/* Chart palette */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Chart Palette</h2>
<div className="flex gap-2">
{CHART_TOKENS.map((t) => (
<div key={t.name} className="space-y-1 flex-1">
<div className={`h-10 w-full rounded-md ${t.tw}`} />
<p className="text-[10px] text-foreground-muted font-mono text-center">{t.name}</p>
</div>
))}
</div>
</section>
{/* Typography */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Typography</h2>
<div className="space-y-2">
<p className="text-heading-xl font-bold">heading-xl (1.875rem)</p>
<p className="text-heading-lg font-bold">heading-lg (1.5rem)</p>
<p className="text-heading-md font-semibold">heading-md (1.125rem)</p>
<p className="text-heading-sm font-semibold">heading-sm (0.875rem)</p>
<p className="text-heading-xs font-semibold uppercase">heading-xs (0.75rem uppercase tracking)</p>
<Divider className="my-2" />
<p className="font-mono text-data-lg">data-lg: 1.250.000.000 </p>
<p className="font-mono text-data-md">data-md: 850.000.000 </p>
<p className="font-mono text-data-sm">data-sm: 450.000.000 </p>
<p className="font-mono text-ticker">ticker: Q1 +2.4%</p>
</div>
</section>
{/* Shadows */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Elevation (Shadow)</h2>
<div className="flex gap-6">
{['elevation-0', 'elevation-1', 'elevation-2', 'elevation-3'].map((s) => (
<div
key={s}
className={`flex h-20 w-32 items-center justify-center rounded-lg bg-background-elevated text-xs text-foreground-muted shadow-${s}`}
>
{s}
</div>
))}
</div>
</section>
{/* Signals */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Signal</h2>
<div className="flex gap-3">
<Signal direction="up" label="+2.4%" />
<Signal direction="down" label="-1.3%" />
<Signal direction="neutral" label="0.0%" />
</div>
</section>
{/* Glow shadows */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Glow Shadows</h2>
<div className="flex gap-6">
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-background-elevated text-xs text-signal-up shadow-glow-up">
glow-up
</div>
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-background-elevated text-xs text-signal-down shadow-glow-down">
glow-down
</div>
</div>
</section>
{/* Surfaces */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Surface</h2>
<div className="flex gap-4">
<Surface className="p-4 border border-border">
<p className="text-sm">Surface (flat)</p>
</Surface>
<SurfaceElevated className="p-4">
<p className="text-sm">SurfaceElevated</p>
</SurfaceElevated>
</div>
</section>
{/* Numeric */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Numeric</h2>
<div className="space-y-1">
<div className="flex items-center gap-4">
<span className="text-sm text-foreground-muted w-20">VND:</span>
<Numeric value={3_500_000_000} />
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-foreground-muted w-20">Percent:</span>
<Numeric value={12.5} format="percent" />
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-foreground-muted w-20">Compact:</span>
<Numeric value={1_250_000_000} format="compact" />
</div>
</div>
</section>
{/* Density */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Density</h2>
<DensityProvider>
<DensityDemo />
</DensityProvider>
</section>
{/* Tick flash animations */}
<section>
<h2 className="text-heading-md font-semibold mb-4">Tick Flash</h2>
<div className="flex gap-4">
<div className="tick-flash-up rounded-md px-4 py-2 text-sm">Flash Up</div>
<div className="tick-flash-down rounded-md px-4 py-2 text-sm">Flash Down</div>
</div>
<p className="text-xs text-foreground-muted mt-2">Refresh page to replay animation. Disabled with prefers-reduced-motion.</p>
</section>
</div>
);
}

View File

@@ -39,6 +39,21 @@
--input: 214.3 31.8% 91.4%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
/* Chart palette (light) */
--chart-1: 200 90% 45%;
--chart-2: 142 65% 38%;
--chart-3: 38 95% 48%;
--chart-4: 280 65% 50%;
--chart-5: 0 75% 50%;
--chart-6: 180 60% 40%;
/* Motion */
--duration-xs: 80ms;
--duration-sm: 150ms;
--duration-md: 240ms;
--ease-standard: cubic-bezier(.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(.3, 0, 0, 1);
}
.dark {
@@ -78,6 +93,16 @@
--ring: 142 72% 42%;
}
.dark {
/* Chart palette (dark) */
--chart-1: 200 90% 60%;
--chart-2: 142 70% 50%;
--chart-3: 38 95% 60%;
--chart-4: 280 70% 65%;
--chart-5: 0 75% 60%;
--chart-6: 180 65% 50%;
}
* {
@apply border-border;
}
@@ -135,28 +160,44 @@
animation-play-state: paused;
}
/* Signal flash for price updates */
@keyframes signal-flash-up {
0%,
/* Signal flash for price updates (tick-flash per design tokens) */
@keyframes tick-up {
0% {
background-color: hsl(var(--signal-up) / 0.18);
}
100% {
background-color: transparent;
}
30% {
background-color: hsl(var(--signal-up-bg) / 0.2);
}
}
@keyframes signal-flash-down {
0%,
@keyframes tick-down {
0% {
background-color: hsl(var(--signal-down) / 0.18);
}
100% {
background-color: transparent;
}
30% {
background-color: hsl(var(--signal-down-bg) / 0.2);
}
}
.tick-flash-up {
animation: tick-up 480ms ease-out;
}
.tick-flash-down {
animation: tick-down 480ms ease-out;
}
/* Legacy aliases */
.flash-up {
animation: signal-flash-up 1s ease-out;
animation: tick-up 480ms ease-out;
}
.flash-down {
animation: signal-flash-down 1s ease-out;
animation: tick-down 480ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.tick-flash-up,
.tick-flash-down,
.flash-up,
.flash-down {
animation: none;
}
.animate-ticker {
animation: none;
}
}