diff --git a/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx b/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx
new file mode 100644
index 0000000..a23fcab
--- /dev/null
+++ b/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx
@@ -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 (
+
+
+ {(['compact', 'regular', 'roomy'] as const).map((d) => (
+
+ ))}
+
+
+
+ Mẫu hàng — {density}
+
+ {[1, 2, 3].map((i) => (
+
+ Dòng {i}
+
+
+ ))}
+
+
+ );
+}
+
+export default function DevTokensPage() {
+ if (process.env.NODE_ENV === 'production') {
+ notFound();
+ }
+
+ return (
+
+
Design Tokens Showcase
+
Dev-only route — không hiển thị trên production.
+
+ {/* Colors */}
+
+ Màu sắc
+
+ {COLOR_TOKENS.map((t) => (
+
+ ))}
+
+
+
+ {/* Chart palette */}
+
+ Chart Palette
+
+ {CHART_TOKENS.map((t) => (
+
+ ))}
+
+
+
+ {/* Typography */}
+
+ Typography
+
+
heading-xl (1.875rem)
+
heading-lg (1.5rem)
+
heading-md (1.125rem)
+
heading-sm (0.875rem)
+
heading-xs (0.75rem uppercase tracking)
+
+
data-lg: 1.250.000.000 ₫
+
data-md: 850.000.000 ₫
+
data-sm: 450.000.000 ₫
+
ticker: Q1 +2.4%
+
+
+
+ {/* Shadows */}
+
+ Elevation (Shadow)
+
+ {['elevation-0', 'elevation-1', 'elevation-2', 'elevation-3'].map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+ {/* Signals */}
+
+
+ {/* Glow shadows */}
+
+ Glow Shadows
+
+
+ glow-up
+
+
+ glow-down
+
+
+
+
+ {/* Surfaces */}
+
+ Surface
+
+
+ Surface (flat)
+
+
+ SurfaceElevated
+
+
+
+
+ {/* Numeric */}
+
+ Numeric
+
+
+ VND:
+
+
+
+ Percent:
+
+
+
+ Compact:
+
+
+
+
+
+ {/* Density */}
+
+
+ {/* Tick flash animations */}
+
+ Tick Flash
+
+
Flash Up
+
Flash Down
+
+ Refresh page to replay animation. Disabled with prefers-reduced-motion.
+
+
+ );
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 6b4cf15..23072f3 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -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;
+ }
}
diff --git a/apps/web/components/design-system/density-provider.tsx b/apps/web/components/design-system/density-provider.tsx
new file mode 100644
index 0000000..55fc604
--- /dev/null
+++ b/apps/web/components/design-system/density-provider.tsx
@@ -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({
+ density: 'regular',
+ setDensity: () => {},
+});
+
+export function useDensity() {
+ return useContext(DensityContext);
+}
+
+/** Row height in Tailwind spacing tokens per density mode. */
+export const DENSITY_ROW_HEIGHT: Record = {
+ 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 = {
+ 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 = {
+ 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(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 (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/components/design-system/divider.tsx b/apps/web/components/design-system/divider.tsx
new file mode 100644
index 0000000..809ca7e
--- /dev/null
+++ b/apps/web/components/design-system/divider.tsx
@@ -0,0 +1,28 @@
+import { cn } from '@/lib/utils';
+
+export interface DividerProps extends React.HTMLAttributes {
+ /** 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 (
+
+ );
+}
diff --git a/apps/web/components/design-system/index.ts b/apps/web/components/design-system/index.ts
index 0072b43..c036395 100644
--- a/apps/web/components/design-system/index.ts
+++ b/apps/web/components/design-system/index.ts
@@ -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';
diff --git a/apps/web/components/design-system/numeric.tsx b/apps/web/components/design-system/numeric.tsx
new file mode 100644
index 0000000..67fd8f9
--- /dev/null
+++ b/apps/web/components/design-system/numeric.tsx
@@ -0,0 +1,64 @@
+import { cn } from '@/lib/utils';
+
+export interface NumericProps extends React.HTMLAttributes {
+ /** 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 (
+
+ {formatValue(value, format, fractionDigits)}
+
+ );
+}
diff --git a/apps/web/components/design-system/signal.tsx b/apps/web/components/design-system/signal.tsx
new file mode 100644
index 0000000..25ec376
--- /dev/null
+++ b/apps/web/components/design-system/signal.tsx
@@ -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 = {
+ 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 = {
+ 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 (
+
+
+ {label && {label}}
+
+ );
+}
diff --git a/apps/web/components/design-system/surface.tsx b/apps/web/components/design-system/surface.tsx
new file mode 100644
index 0000000..aa3898a
--- /dev/null
+++ b/apps/web/components/design-system/surface.tsx
@@ -0,0 +1,30 @@
+import { cn } from '@/lib/utils';
+
+/** Flat surface — uses `--background` (app bg). */
+export function Surface({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+/** Elevated surface — uses `--background-elevated` with shadow-elevation-1. */
+export function SurfaceElevated({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/providers/__tests__/theme-provider.spec.tsx b/apps/web/components/providers/__tests__/theme-provider.spec.tsx
index d41f876..307935b 100644
--- a/apps/web/components/providers/__tests__/theme-provider.spec.tsx
+++ b/apps/web/components/providers/__tests__/theme-provider.spec.tsx
@@ -3,35 +3,23 @@ import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ThemeProvider, useTheme } from '../theme-provider';
-// Provide a working localStorage mock for this test file
-const localStorageMock = (() => {
- let store: Record = {};
- return {
- getItem: vi.fn((key: string) => store[key] ?? null),
- setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
- removeItem: vi.fn((key: string) => { delete store[key]; }),
- clear: vi.fn(() => { store = {}; }),
- get length() { return Object.keys(store).length; },
- key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
- };
-})();
+// Mock next-themes
+const mockSetTheme = vi.fn();
+let mockTheme = 'dark';
+let mockResolvedTheme = 'dark';
-Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
-
-// Mock window.matchMedia (not implemented in jsdom)
-Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: vi.fn(),
- removeListener: vi.fn(),
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
-});
+vi.mock('next-themes', () => ({
+ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ useTheme: () => ({
+ theme: mockTheme,
+ resolvedTheme: mockResolvedTheme,
+ setTheme: (t: string) => {
+ mockSetTheme(t);
+ mockTheme = t;
+ mockResolvedTheme = t;
+ },
+ }),
+}));
// Test consumer component
function ThemeConsumer() {
@@ -46,8 +34,8 @@ function ThemeConsumer() {
describe('ThemeProvider', () => {
beforeEach(() => {
- document.documentElement.classList.remove('dark');
- localStorageMock.clear();
+ mockTheme = 'dark';
+ mockResolvedTheme = 'dark';
vi.clearAllMocks();
});
@@ -60,54 +48,7 @@ describe('ThemeProvider', () => {
expect(screen.getByText('Child content')).toBeInTheDocument();
});
- it('defaults to light theme', () => {
- render(
-
-
- ,
- );
- expect(screen.getByTestId('theme')).toHaveTextContent('light');
- });
-
- it('toggles theme to dark', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByText('Toggle'));
- expect(screen.getByTestId('theme')).toHaveTextContent('dark');
- });
-
- it('toggles theme back to light', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByText('Toggle'));
- await user.click(screen.getByText('Toggle'));
- expect(screen.getByTestId('theme')).toHaveTextContent('light');
- });
-
- it('persists theme to localStorage', async () => {
- const user = userEvent.setup();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByText('Toggle'));
- expect(localStorageMock.setItem).toHaveBeenCalledWith('goodgo-theme', 'dark');
- });
-
- it('loads stored theme from localStorage', () => {
- localStorageMock.getItem.mockReturnValueOnce('dark');
+ it('defaults to dark theme', () => {
render(
@@ -115,11 +56,37 @@ describe('ThemeProvider', () => {
);
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
+
+ it('toggles theme to light', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Toggle'));
+ expect(mockSetTheme).toHaveBeenCalledWith('light');
+ });
+
+ it('toggles theme back to dark', async () => {
+ mockTheme = 'light';
+ mockResolvedTheme = 'light';
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Toggle'));
+ expect(mockSetTheme).toHaveBeenCalledWith('dark');
+ });
});
describe('useTheme', () => {
- it('returns default values outside provider', () => {
+ it('returns dark as default outside provider', () => {
render();
- expect(screen.getByTestId('theme')).toHaveTextContent('light');
+ expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
});
diff --git a/apps/web/components/providers/theme-provider.tsx b/apps/web/components/providers/theme-provider.tsx
index 2a80bde..ff64047 100644
--- a/apps/web/components/providers/theme-provider.tsx
+++ b/apps/web/components/providers/theme-provider.tsx
@@ -1,51 +1,31 @@
'use client';
-import { createContext, useCallback, useContext, useEffect, useState } from 'react';
-
-type Theme = 'light' | 'dark';
-
-interface ThemeContextValue {
- theme: Theme;
- toggleTheme: () => void;
-}
-
-const ThemeContext = createContext({
- theme: 'light',
- toggleTheme: () => {},
-});
-
-export function useTheme() {
- return useContext(ThemeContext);
-}
-
-const STORAGE_KEY = 'goodgo-theme';
+import { ThemeProvider as NextThemesProvider, useTheme as useNextTheme } from 'next-themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
- const [theme, setTheme] = useState('light');
-
- useEffect(() => {
- const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
- if (stored === 'dark' || stored === 'light') {
- setTheme(stored);
- document.documentElement.classList.toggle('dark', stored === 'dark');
- } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
- setTheme('dark');
- document.documentElement.classList.add('dark');
- }
- }, []);
-
- const toggleTheme = useCallback(() => {
- setTheme((prev) => {
- const next = prev === 'light' ? 'dark' : 'light';
- localStorage.setItem(STORAGE_KEY, next);
- document.documentElement.classList.toggle('dark', next === 'dark');
- return next;
- });
- }, []);
-
return (
-
+
{children}
-
+
);
}
+
+/**
+ * Backward-compatible useTheme hook.
+ * Returns `theme` ('light' | 'dark') and `toggleTheme`.
+ */
+export function useTheme() {
+ const { theme, setTheme, resolvedTheme } = useNextTheme();
+ const current = (resolvedTheme ?? theme ?? 'dark') as 'light' | 'dark';
+
+ const toggleTheme = () => {
+ setTheme(current === 'dark' ? 'light' : 'dark');
+ };
+
+ return { theme: current, toggleTheme };
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 19fbe01..02ad2a0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,7 @@
"mapbox-gl": "^3.21.0",
"next": "^15.5.14",
"next-intl": "^4.9.0",
+ "next-themes": "^0.4.6",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.72.1",
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 5479b27..19a7ab3 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -15,12 +15,21 @@ const config: Config = {
'data-sm': ['0.75rem', { lineHeight: '1.2' }],
'data-md': ['0.875rem', { lineHeight: '1.3' }],
'data-lg': ['1.25rem', { lineHeight: '1.2' }],
+ 'heading-xs': ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.08em' }],
+ 'heading-sm': ['0.875rem', { lineHeight: '1.25rem' }],
+ 'heading-md': ['1.125rem', { lineHeight: '1.5rem' }],
+ 'heading-lg': ['1.5rem', { lineHeight: '1.875rem' }],
+ 'heading-xl': ['1.875rem', { lineHeight: '2.25rem' }],
},
spacing: {
cell: '0.5rem',
row: '2.25rem',
+ 'row-compact': '2rem',
+ 'row-roomy': '2.75rem',
'ticker-bar': '2rem',
'header-compact': '3rem',
+ sidebar: '15rem',
+ 'sidebar-collapsed': '3.5rem',
},
colors: {
border: 'hsl(var(--border))',
@@ -74,15 +83,44 @@ const config: Config = {
},
success: 'hsl(var(--success))',
warning: 'hsl(var(--warning))',
+ chart: {
+ 1: 'hsl(var(--chart-1))',
+ 2: 'hsl(var(--chart-2))',
+ 3: 'hsl(var(--chart-3))',
+ 4: 'hsl(var(--chart-4))',
+ 5: 'hsl(var(--chart-5))',
+ 6: 'hsl(var(--chart-6))',
+ },
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
+ pill: '9999px',
},
boxShadow: {
- 'elevation-1': '0 1px 2px rgba(0, 0, 0, 0.3)',
- 'elevation-2': '0 4px 12px rgba(0, 0, 0, 0.4)',
+ 'elevation-0': 'none',
+ 'elevation-1': '0 1px 2px rgba(0,0,0,.30), 0 0 0 1px hsl(var(--border))',
+ 'elevation-2': '0 4px 12px rgba(0,0,0,.40)',
+ 'elevation-3': '0 12px 32px rgba(0,0,0,.50)',
+ 'glow-up': '0 0 0 1px hsl(var(--signal-up) / .4), 0 0 12px hsl(var(--signal-up) / .25)',
+ 'glow-down': '0 0 0 1px hsl(var(--signal-down) / .4), 0 0 12px hsl(var(--signal-down) / .25)',
+ },
+ transitionTimingFunction: {
+ standard: 'cubic-bezier(.2,0,0,1)',
+ emphasized: 'cubic-bezier(.3,0,0,1)',
+ },
+ transitionDuration: {
+ '80': '80ms',
+ '240': '240ms',
+ },
+ zIndex: {
+ 'sticky-header': '30',
+ 'app-header': '40',
+ ticker: '45',
+ dropdown: '50',
+ modal: '60',
+ toast: '70',
},
},
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f00cf64..709993a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -334,6 +334,9 @@ importers:
next-intl:
specifier: ^4.9.0
version: 4.9.0(next@15.5.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@6.0.2)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18.3.0
version: 18.3.1
@@ -5639,6 +5642,12 @@ packages:
typescript:
optional: true
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
next@15.5.15:
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -13181,6 +13190,11 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
+ next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
next@15.5.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 15.5.15