mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 12:29:01 +00:00
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:
1 parent
0b8b60a2ab
commit
bf67ae655d
6 files changed
+81
-39
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)';
|
||||
|
||||
Reference in new issue
Block a user