feat(ui): F0 — Tailwind + design tokens + theme infrastructure
Install Tailwind v3, Radix primitives, cva/tailwind-merge/clsx, lucide-react. Add HSL design tokens (light + dark) with the Indigo #5b5bf0 accent isolated to a single --primary token (swappable for a future accent picker), darkMode 'class', anti-flash inline script, useTheme hook, and cn() helper. styles.css still loads for not-yet-migrated pages. Build green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const LS_KEY = 'ii:theme';
|
||||
|
||||
function getInitial() {
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
// Light-first product, but honor an explicit OS dark preference on first run.
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* Light/dark theme, persisted to localStorage and reflected as `.dark` on <html>.
|
||||
* Apply as early as possible to avoid a flash (see the inline script in index.html).
|
||||
*/
|
||||
export default function useTheme() {
|
||||
const [theme, setTheme] = useState(getInitial);
|
||||
|
||||
useEffect(() => {
|
||||
apply(theme);
|
||||
localStorage.setItem(LS_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => setTheme((t) => (t === 'dark' ? 'light' : 'dark')), []);
|
||||
|
||||
return { theme, setTheme, toggle };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
Design tokens. The ONLY place colors are defined. Change --primary / --ring
|
||||
to re-brand the whole app; a future user-facing accent picker can write these
|
||||
two variables at runtime. See docs/specs/ui-overhaul.md.
|
||||
*/
|
||||
@layer base {
|
||||
:root {
|
||||
/* Neutrals — warm-tinted slate (Notion-ish paper) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222 22% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--muted: 220 16% 96%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--border: 220 16% 90%;
|
||||
--input: 220 16% 90%;
|
||||
--ring: 245 75% 60%;
|
||||
|
||||
/* Brand accent — Indigo #5b5bf0 */
|
||||
--primary: 245 75% 59%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Semantic */
|
||||
--success: 152 56% 40%;
|
||||
--warning: 38 92% 50%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 32% 9%;
|
||||
--foreground: 220 18% 92%;
|
||||
--card: 224 28% 12%;
|
||||
--muted: 223 22% 17%;
|
||||
--muted-foreground: 220 12% 64%;
|
||||
--border: 223 20% 20%;
|
||||
--input: 223 20% 22%;
|
||||
--ring: 245 80% 66%;
|
||||
|
||||
--primary: 245 80% 67%;
|
||||
--primary-foreground: 224 32% 9%;
|
||||
|
||||
--success: 152 50% 50%;
|
||||
--warning: 38 92% 58%;
|
||||
--danger: 0 70% 60%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Subtle, modern scrollbars that respect the theme. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merge conditional class names, de-duplicating conflicting Tailwind classes. */
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Unsubscribe from './pages/Unsubscribe.jsx';
|
||||
import FolderView from './pages/FolderView.jsx';
|
||||
import SearchResults from './pages/SearchResults.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
|
||||
Reference in New Issue
Block a user