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:
@@ -5,6 +5,16 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>InboxIntel — Gmail analytics & cleanup</title>
|
<title>InboxIntel — Gmail analytics & cleanup</title>
|
||||||
|
<script>
|
||||||
|
// Apply the saved theme before first paint to avoid a flash of the wrong mode.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('ii:theme');
|
||||||
|
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2043
-1
File diff suppressed because it is too large
Load Diff
+20
-1
@@ -9,16 +9,35 @@
|
|||||||
"preview": "vite preview --host"
|
"preview": "vite preview --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.2.1",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.18",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||||
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
|
"@radix-ui/react-popover": "^1.1.18",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||||
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
|
"@radix-ui/react-switch": "^1.3.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.16",
|
||||||
|
"@radix-ui/react-toast": "^1.2.18",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.11",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"chart.js": "^4.4.3",
|
"chart.js": "^4.4.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.22.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-grid-layout": "^1.4.4",
|
"react-grid-layout": "^1.4.4",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"postcss": "^8.5.16",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
"vite": "^5.3.1"
|
"vite": "^5.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 FolderView from './pages/FolderView.jsx';
|
||||||
import SearchResults from './pages/SearchResults.jsx';
|
import SearchResults from './pages/SearchResults.jsx';
|
||||||
import Layout from './components/Layout.jsx';
|
import Layout from './components/Layout.jsx';
|
||||||
|
import './index.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// All colors reference CSS variables (defined in src/index.css) so the
|
||||||
|
// whole palette — including the accent — is swappable in one place and
|
||||||
|
// flips automatically between light and `.dark`.
|
||||||
|
background: 'hsl(var(--background) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border) / <alpha-value>)',
|
||||||
|
input: 'hsl(var(--input) / <alpha-value>)',
|
||||||
|
ring: 'hsl(var(--ring) / <alpha-value>)',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
success: 'hsl(var(--success) / <alpha-value>)',
|
||||||
|
warning: 'hsl(var(--warning) / <alpha-value>)',
|
||||||
|
danger: {
|
||||||
|
DEFAULT: 'hsl(var(--danger) / <alpha-value>)',
|
||||||
|
foreground: 'hsl(var(--danger-foreground) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: '0 1px 2px 0 hsl(var(--foreground) / 0.05)',
|
||||||
|
DEFAULT: '0 1px 3px 0 hsl(var(--foreground) / 0.08), 0 1px 2px -1px hsl(var(--foreground) / 0.06)',
|
||||||
|
md: '0 4px 12px -2px hsl(var(--foreground) / 0.10)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'fade-in': { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||||
|
'slide-up': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(6px)' },
|
||||||
|
to: { opacity: 1, transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'slide-in-right': {
|
||||||
|
from: { transform: 'translateX(100%)' },
|
||||||
|
to: { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fade-in 150ms ease-out',
|
||||||
|
'slide-up': 'slide-up 180ms ease-out',
|
||||||
|
'slide-in-right': 'slide-in-right 220ms ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user