refactor(ui): semantic color tokens and Lucide icon unification

Centralize color token system in globals.css, update theme.js with semantic naming, replace icon library references with Lucide, rename vn-surface class, and update all component references.
This commit is contained in:
tiennm99 committed 2026-09-01 17:17:06 +07:00
1 parent 0b8b60a2ab
commit bf67ae655d
6 files changed
+81 -39

No files matched your search

+17 -27
View File
@@ -6,37 +6,24 @@ import { Skeleton } from '@/components/ui/skeleton';
import { formatDistance, calculateScore, bandsForBbox } from '../../lib/game';
import { getRegion, isRegion } from '../../lib/regions';
// Keyed by the round score the distance would earn, so the tint tracks the
// scoring ladder instead of re-typing its thresholds.
const DISTANCE_COLORS = {
5: 'text-green-700 dark:text-green-300',
4: 'text-blue-700 dark:text-blue-300',
3: 'text-yellow-700 dark:text-yellow-300',
2: 'text-orange-700 dark:text-orange-300',
};
// Graded against the BOARD's own regional ladder, mirroring how the boards
// are credited: 2km is red on a district board and green on the country one.
// are credited: 2km is amber on a district board and green on the country
// one. Three semantic states, not a rainbow -- good, close, far -- because
// finer steps carried no meaning a reader could recover.
function getDistanceColor(distance, bands) {
return DISTANCE_COLORS[calculateScore(distance, bands)] ?? 'text-red-700 dark:text-red-300';
}
function getScoreColor(score) {
if (score >= 50) return 'text-purple-700 dark:text-purple-300';
if (score >= 25) return 'text-green-700 dark:text-green-300';
if (score >= 15) return 'text-blue-700 dark:text-blue-300';
if (score >= 10) return 'text-yellow-700 dark:text-yellow-300';
if (score >= 5) return 'text-orange-700 dark:text-orange-300';
return 'text-red-700 dark:text-red-300';
const score = calculateScore(distance, bands);
if (score >= 4) return 'text-success';
if (score >= 2) return 'text-warning';
return 'text-danger';
}
// Medal tint for the podium. The rank number stays visible alongside it, so the
// placing never depends on reading the colour or an emoji glyph.
function getMedalClass(rank) {
switch (rank) {
case 1: return 'text-amber-500';
case 2: return 'text-slate-400';
case 3: return 'text-orange-700 dark:text-orange-400';
case 1: return 'text-rank-gold';
case 2: return 'text-rank-silver';
case 3: return 'text-rank-bronze';
default: return '';
}
}
@@ -84,7 +71,7 @@ export default function LeaderboardList({ data, loading, currentUsername, type,
key={key}
className={`flex items-center justify-between p-3 rounded-lg transition-all ${
isUser
? 'bg-amber-100/70 dark:bg-amber-950/40 border-2 border-amber-500 shadow-md'
? 'bg-brand-subtle/70 border-2 border-brand shadow-md'
: entry.rank <= 3
? 'bg-brand-subtle/40 border border-brand/20'
: 'bg-muted/50 hover:bg-muted'
@@ -98,10 +85,10 @@ export default function LeaderboardList({ data, loading, currentUsername, type,
<span className="text-base font-bold tabular-nums">#{entry.rank}</span>
</div>
<div>
<div className={`font-semibold ${isUser ? 'text-amber-900 dark:text-amber-200' : 'text-foreground'}`}>
<div className={`font-semibold ${isUser ? 'text-brand-subtle-foreground' : 'text-foreground'}`}>
{entry.username}
{isUser && (
<Badge className="ml-2 bg-amber-700 text-white text-xs">YOU</Badge>
<Badge className="ml-2 bg-brand text-brand-foreground text-xs">YOU</Badge>
)}
</div>
{isDistance && entry.timestamp && (
@@ -111,8 +98,11 @@ export default function LeaderboardList({ data, loading, currentUsername, type,
)}
</div>
</div>
{/* Score totals carry no tint: unlike a distance, a running total
has no ladder to grade against, and the old size-keyed rainbow
encoded nothing a reader could recover. */}
<Badge variant="secondary" className={`text-lg font-bold tabular-nums ${
isDistance ? getDistanceColor(entry.distance, distanceBands) : getScoreColor(entry.score)
isDistance ? getDistanceColor(entry.distance, distanceBands) : ''
}`}>
{isDistance ? formatDistance(entry.distance) : entry.score}
</Badge>
+15 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from 'react';
import { Monitor, Moon, Sun } from 'lucide-react';
import {
THEMES,
DEFAULT_THEME,
@@ -10,6 +11,14 @@ import {
watchSystemTheme,
} from '../../lib/theme';
// Lucide, matching the rest of the chrome's icon language (Trophy, Wrench,
// ArrowLeft ...); emoji ignored currentColor and read noisier than the rest.
const THEME_ICONS = {
light: Sun,
dark: Moon,
system: Monitor,
};
export default function ThemeToggle({ className = '' }) {
const [theme, setTheme] = useState(DEFAULT_THEME);
// The stored choice only exists on the client, so the first paint has to
@@ -46,6 +55,7 @@ export default function ThemeToggle({ className = '' }) {
>
{THEMES.map((option) => {
const selected = mounted && theme === option.value;
const Icon = THEME_ICONS[option.value];
return (
<button
key={option.value}
@@ -54,14 +64,13 @@ export default function ThemeToggle({ className = '' }) {
aria-label={option.label}
title={option.label}
onClick={() => handleSelect(option.value)}
className={`flex h-11 w-9 items-center justify-center rounded-lg text-base leading-none outline-none transition-[background-color,opacity] focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
selected ? 'bg-brand shadow-sm' : 'opacity-55 hover:opacity-100 hover:bg-muted'
className={`flex h-11 w-11 items-center justify-center rounded-lg outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
selected
? 'bg-brand text-brand-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{/* Emoji ignore currentColor, so the selected state cannot be
carried by text colour. Opacity gives a cue that works on a
glyph the palette cannot reach. */}
<span aria-hidden="true">{option.emoji}</span>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
+1 -1
View File
@@ -11,7 +11,7 @@ import DebugNav from './DebugNav';
// manages its own panes.
export default function DebugLayout({ children }) {
return (
<div className="flex h-dvh flex-col vn-gradient-bg">
<div className="flex h-dvh flex-col vn-surface">
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border bg-card px-3 py-2 pt-[calc(0.5rem+env(safe-area-inset-top))] shadow-sm sm:px-4">
<div className="flex items-center gap-1">
<Button
+1 -1
View File
@@ -5,7 +5,7 @@ import GameClient from '../components/GameClient';
function GameLoadingFallback() {
return (
<div className="min-h-dvh flex items-center justify-center vn-gradient-bg">
<div className="min-h-dvh flex items-center justify-center vn-surface">
<div role="status" aria-live="polite" className="text-center space-y-4 animate-fade-in-up">
<div className="w-12 h-12 border-4 border-border border-t-brand rounded-full animate-spin mx-auto" aria-hidden="true" />
<p className="text-foreground text-lg font-medium">Loading game...</p>
+42 -1
View File
@@ -49,6 +49,15 @@
--color-brand-subtle: var(--brand-subtle);
--color-brand-subtle-foreground: var(--brand-subtle-foreground);
--color-surface: var(--surface);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-danger: var(--danger);
--color-danger-foreground: var(--danger-foreground);
--color-rank-gold: var(--rank-gold);
--color-rank-silver: var(--rank-silver);
--color-rank-bronze: var(--rank-bronze);
--shadow-xs: var(--elevation-xs);
--shadow-sm: var(--elevation-sm);
--shadow-md: var(--elevation-md);
@@ -107,6 +116,24 @@
/* Page background, one step off --background so cards read as raised. */
--surface: oklch(0.977 0.002 250);
/* Semantic result colours. Every good/near/far judgement goes through these
rather than raw Tailwind greens and ambers, so light and dark stay tuned
in one place. Doubles as text tint (on surface) and chip background (with
its -foreground), like --brand does. */
--success: oklch(0.55 0.15 150);
--success-foreground: #ffffff;
/* Deliberately darker than Tailwind amber-600: white text on it must clear
large-text contrast, which amber-600 did not. */
--warning: oklch(0.6 0.13 75);
--warning-foreground: #ffffff;
--danger: oklch(0.577 0.245 27.325);
--danger-foreground: #ffffff;
/* Podium metal tints; the rank number always renders beside them. */
--rank-gold: oklch(0.77 0.16 85);
--rank-silver: oklch(0.71 0.02 260);
--rank-bronze: oklch(0.56 0.12 60);
/* Elevation. A single scale for buttons, cards and dialogs so raised things
agree on how raised they are. */
--elevation-xs: 0 1px 2px 0 rgb(0 0 0 / 5%);
@@ -164,6 +191,18 @@
--brand-subtle-foreground: oklch(0.86 0.11 27);
--surface: oklch(0.115 0 0);
/* Like --brand: lightened rather than inverted, with dark on-colour text. */
--success: oklch(0.75 0.15 150);
--success-foreground: oklch(0.145 0 0);
--warning: oklch(0.8 0.13 80);
--warning-foreground: oklch(0.145 0 0);
--danger: oklch(0.704 0.191 22.216);
--danger-foreground: oklch(0.145 0 0);
--rank-gold: oklch(0.83 0.16 85);
--rank-silver: oklch(0.75 0.02 260);
--rank-bronze: oklch(0.7 0.12 60);
/* Dark shadows read as nothing against a dark ground, so they carry more
opacity and add a faint top highlight to suggest a lit edge instead. */
--elevation-xs: 0 1px 2px 0 rgb(0 0 0 / 40%);
@@ -209,7 +248,9 @@
z-index: 9999 !important;
}
.vn-gradient-bg {
/* Page ground, one step off --background. Named for what it is: a flat
surface (an earlier iteration was a gradient; the name outlived it). */
.vn-surface {
background: var(--surface);
}
+5 -3
View File
@@ -8,10 +8,12 @@
export const THEME_STORAGE_KEY = 'vngeoguessr_theme';
export const DEFAULT_THEME = 'system';
// Icons live with the toggle component (Lucide, following currentColor);
// this module stays free of UI imports.
export const THEMES = [
{ value: 'light', emoji: '☀️', label: 'Light theme' },
{ value: 'dark', emoji: '🌙', label: 'Dark theme' },
{ value: 'system', emoji: '⚙️', label: 'Match system theme' },
{ value: 'light', label: 'Light theme' },
{ value: 'dark', label: 'Dark theme' },
{ value: 'system', label: 'Match system theme' },
];
const DARK_QUERY = '(prefers-color-scheme: dark)';