feat(ui): add design tokens and visual style guide (T02)

This commit is contained in:
Paul Huliganga 2026-03-26 17:49:34 -04:00
parent 3931455e64
commit 4af20eaf91
7 changed files with 295 additions and 70 deletions

View File

@ -0,0 +1,139 @@
# Visual Style Guide (T02)
## Purpose
This document defines the Recipe Manager visual design tokens and how to apply them consistently across pages/components.
Primary source of truth:
- `frontend/src/theme.ts`
- `frontend/src/index.css` (CSS custom properties)
- `frontend/tailwind.config.js` (Tailwind token mapping)
---
## 1) Color Tokens
### Brand + Semantic
- `colors.primary`: `#2563eb`
- `colors.primaryDark`: `#1d4ed8`
- `colors.primaryLight`: `#dbeafe`
- `colors.accent`: `#9333ea`
- `colors.success`: `#15803d`
- `colors.warning`: `#ca8a04`
- `colors.error`: `#dc2626`
### Surfaces + Text
- `colors.bg`: `#f4f7fb`
- `colors.bgAlt`: `#edf2f7`
- `colors.surface`: `#ffffff`
- `colors.surfaceMuted`: `#f8fafc`
- `colors.border`: `#dbe3ef`
- `colors.text`: `#1f2937`
- `colors.textHeading`: `#0f172a`
- `colors.textDim`: `#64748b`
### Usage
- Primary actions: `primary` / `primaryDark` for hover.
- Validation or status badges/alerts: `success`, `warning`, `error` with light backgrounds where needed.
- Use `surface` + `border` for cards/forms.
- Use `textHeading` for section/page headings and `text`/`textDim` for body/supporting copy.
---
## 2) Typography Tokens
### Families
- Sans: `typography.fontFamily.sans`
- Heading: `typography.fontFamily.heading`
- Mono: `typography.fontFamily.mono`
### Scale
- `xs` 0.75rem
- `sm` 0.875rem
- `base` 1rem
- `lg` 1.125rem
- `xl` 1.25rem
- `2xl` 1.5rem
- `3xl` 1.875rem
- `4xl` 2.25rem
### Guidance
- Page titles: `3xl4xl`
- Section titles: `xl2xl`
- Body text: `base`
- Helper/meta text: `sm`
---
## 3) Spacing Tokens
- `xxs` 0.25rem
- `xs` 0.5rem
- `sm` 0.75rem
- `md` 1rem
- `lg` 1.5rem
- `xl` 2rem
- `2xl` 2.5rem
- `3xl` 3rem
### Guidance
- Tight control spacing (chips/icons): `xxsxs`
- Form controls/content clusters: `smmd`
- Section/page spacing: `lg2xl`
---
## 4) Radius Tokens
- `xs` 0.375rem
- `sm` 0.5rem
- `md` 0.75rem
- `lg` 1rem
- `xl` 1.25rem
- `full` 9999px
### Guidance
- Inputs/buttons: `md`
- Cards/containers: `lg`
- Pills/tags: `full`
---
## 5) Shadow Tokens
- `shadows.subtle`: low emphasis hover/elevation
- `shadows.card`: default card elevation
- `shadows.hover`: raised interactive state
- `shadows.focus`: focus ring treatment
### Guidance
- Prefer `card` for panels/surfaces.
- Use `subtle` for lightweight interactive surfaces.
- Keep `hover` limited to strong CTAs/cards.
---
## 6) Tailwind Mapping
Tailwind config maps tokenized CSS variables for:
- `colors` (`primary`, `accent`, `success`, `warning`, `error`, `surface`, `muted`, `border`)
- `fontFamily`
- `fontSize`
- `spacing`
- `borderRadius`
- `boxShadow`
This keeps utility classes aligned with global tokens and avoids hardcoding values in component markup.
---
## 7) Implementation Notes (T02)
Updated to consume tokens where practical:
- `frontend/src/theme.ts`: expanded token definitions and shared `designTokens` export.
- `frontend/src/index.css`: added token-backed CSS variables (colors, type scale, spacing, radius, shadows).
- `frontend/tailwind.config.js`: switched extension values to CSS-variable/token-backed mappings.
- `frontend/src/components/Toast.tsx`: semantic status colors + radius/shadow from tokens.
- `frontend/src/components/RecipeCard.tsx`: recipe accent palette sourced from tokens.
- `frontend/src/components/TagSelector.tsx`: default tag color sourced from tokens.
Scope intentionally kept minimal/non-breaking to support upcoming visual tasks (T04T07).

View File

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Recipe, Tag } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
import { colors, radius, shadows } from '../theme'; import { colors, radius, recipeAccentPalette, shadows, typography } from '../theme';
interface RecipeCardProps { interface RecipeCardProps {
recipe: Recipe; recipe: Recipe;
@ -25,8 +25,7 @@ function formatDate(timestamp?: number): string {
function accentForRecipe(recipe: Recipe, tags: Tag[]) { function accentForRecipe(recipe: Recipe, tags: Tag[]) {
if (tags[0]?.color) return tags[0].color; if (tags[0]?.color) return tags[0].color;
const palette = ['#f97316', '#ef4444', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7']; return recipeAccentPalette[recipe.id % recipeAccentPalette.length];
return palette[recipe.id % palette.length];
} }
function emojiForRecipe(recipe: Recipe) { function emojiForRecipe(recipe: Recipe) {
@ -68,7 +67,12 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
</div> </div>
<div className="flex h-[calc(100%-10rem)] min-h-[210px] flex-col p-5"> <div className="flex h-[calc(100%-10rem)] min-h-[210px] flex-col p-5">
<h3 className="mb-1 line-clamp-2 text-lg font-bold text-gray-900 transition-colors duration-200 group-hover:text-blue-700">{recipe.title}</h3> <h3
className="mb-1 line-clamp-2 text-lg font-bold text-gray-900 transition-colors duration-200 group-hover:text-blue-700"
style={{ fontSize: typography.fontSize.lg }}
>
{recipe.title}
</h3>
{recipe.description ? ( {recipe.description ? (
<p className="mb-3 line-clamp-2 text-sm text-gray-600">{recipe.description}</p> <p className="mb-3 line-clamp-2 text-sm text-gray-600">{recipe.description}</p>

View File

@ -5,6 +5,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { useToastContext } from '../App'; import { useToastContext } from '../App';
import { colors } from '../theme';
import type { Tag } from '../types/recipe'; import type { Tag } from '../types/recipe';
interface TagSelectorProps { interface TagSelectorProps {
@ -17,14 +18,14 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const toast = useToastContext(); const toast = useToastContext();
const [showNewTagForm, setShowNewTagForm] = useState(false); const [showNewTagForm, setShowNewTagForm] = useState(false);
const [newTagName, setNewTagName] = useState(''); const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('#3B82F6'); const [newTagColor, setNewTagColor] = useState<string>(colors.primary);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const handleToggleTag = (tag: Tag) => { const handleToggleTag = (tag: Tag) => {
const isSelected = selectedTags.some(t => t.id === tag.id); const isSelected = selectedTags.some((t) => t.id === tag.id);
if (isSelected) { if (isSelected) {
onTagsChange(selectedTags.filter(t => t.id !== tag.id)); onTagsChange(selectedTags.filter((t) => t.id !== tag.id));
} else { } else {
onTagsChange([...selectedTags, tag]); onTagsChange([...selectedTags, tag]);
} }
@ -32,15 +33,15 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const handleCreateTag = async (e: React.FormEvent) => { const handleCreateTag = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newTagName.trim()) return; if (!newTagName.trim()) return;
setCreating(true); setCreating(true);
try { try {
const newTag = await addTag(newTagName.trim(), newTagColor); const newTag = await addTag(newTagName.trim(), newTagColor);
onTagsChange([...selectedTags, newTag]); onTagsChange([...selectedTags, newTag]);
setNewTagName(''); setNewTagName('');
setNewTagColor('#3B82F6'); setNewTagColor(colors.primary);
setShowNewTagForm(false); setShowNewTagForm(false);
toast.success(`Tag "${newTag.name}" created!`); toast.success(`Tag "${newTag.name}" created!`);
} catch (err) { } catch (err) {
@ -57,8 +58,8 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
if (error) { if (error) {
return ( return (
<div className="bg-red-50 border border-red-200 rounded-md p-3"> <div className="rounded-md border border-red-200 bg-red-50 p-3">
<p className="text-red-700 text-sm">Error loading tags: {error}</p> <p className="text-sm text-red-700">Error loading tags: {error}</p>
</div> </div>
); );
} }
@ -66,25 +67,18 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tags.map(tag => { {tags.map((tag) => {
const isSelected = selectedTags.some(t => t.id === tag.id); const isSelected = selectedTags.some((t) => t.id === tag.id);
return ( return (
<button <button
key={tag.id} key={tag.id}
type="button" type="button"
onClick={() => handleToggleTag(tag)} onClick={() => handleToggleTag(tag)}
className={` className={`
px-3 py-1 rounded-full text-sm font-medium transition-colors rounded-full px-3 py-1 text-sm font-medium transition-colors
${isSelected ${isSelected ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`} `}
style={ style={isSelected && tag.color ? { backgroundColor: tag.color } : {}}
isSelected && tag.color
? { backgroundColor: tag.color }
: {}
}
> >
{tag.name} {tag.name}
</button> </button>
@ -96,19 +90,19 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setShowNewTagForm(true)} onClick={() => setShowNewTagForm(true)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium" className="text-sm font-medium text-blue-600 hover:text-blue-700"
> >
+ Create new tag + Create new tag
</button> </button>
) : ( ) : (
<form onSubmit={handleCreateTag} className="flex gap-2 items-end"> <form onSubmit={handleCreateTag} className="flex items-end gap-2">
<div className="flex-1"> <div className="flex-1">
<input <input
type="text" type="text"
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name" placeholder="Tag name"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
autoFocus autoFocus
/> />
</div> </div>
@ -117,14 +111,14 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
type="color" type="color"
value={newTagColor} value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)} onChange={(e) => setNewTagColor(e.target.value)}
className="h-10 w-16 border border-gray-300 rounded-md cursor-pointer" className="h-10 w-16 cursor-pointer rounded-md border border-gray-300"
title="Tag color" title="Tag color"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={creating || !newTagName.trim()} disabled={creating || !newTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed" className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
> >
{creating ? 'Creating...' : 'Add'} {creating ? 'Creating...' : 'Add'}
</button> </button>
@ -133,9 +127,9 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
onClick={() => { onClick={() => {
setShowNewTagForm(false); setShowNewTagForm(false);
setNewTagName(''); setNewTagName('');
setNewTagColor('#3B82F6'); setNewTagColor(colors.primary);
}} }}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300" className="rounded-md bg-gray-200 px-4 py-2 text-gray-700 hover:bg-gray-300"
> >
Cancel Cancel
</button> </button>

View File

@ -4,6 +4,7 @@
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { colors, radius, shadows, spacing, typography } from '../theme';
export type ToastType = 'success' | 'error' | 'info' | 'warning'; export type ToastType = 'success' | 'error' | 'info' | 'warning';
@ -31,11 +32,11 @@ export function Toast({ message, onClose }: ToastProps) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [message.id, message.duration, onClose]); }, [message.id, message.duration, onClose]);
const bgColor = { const backgroundColor = {
success: 'bg-green-600', success: colors.success,
error: 'bg-red-600', error: colors.error,
info: 'bg-blue-600', info: colors.primary,
warning: 'bg-yellow-600', warning: colors.warning,
}[message.type]; }[message.type];
const icon = { const icon = {
@ -47,15 +48,26 @@ export function Toast({ message, onClose }: ToastProps) {
return ( return (
<div <div
className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center justify-between gap-4 min-w-[300px] max-w-[500px] animate-slide-in`} className="animate-slide-in flex min-w-[300px] max-w-[500px] items-center justify-between gap-4 px-6 py-4 text-white"
style={{
backgroundColor,
borderRadius: radius.lg,
boxShadow: shadows.card,
}}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl font-bold">{icon}</span> <span className="text-xl font-bold">{icon}</span>
<span className="font-medium">{message.message}</span> <span
className="font-medium"
style={{ fontSize: typography.fontSize.base, lineHeight: typography.lineHeight.normal }}
>
{message.message}
</span>
</div> </div>
<button <button
onClick={() => onClose(message.id)} onClick={() => onClose(message.id)}
className="text-white hover:text-gray-200 text-xl font-bold leading-none" className="text-xl font-bold leading-none text-white transition-colors hover:text-gray-200"
style={{ marginInlineStart: spacing.xs }}
aria-label="Close" aria-label="Close"
> >
× ×
@ -76,7 +88,7 @@ export function ToastContainer({ messages, onClose }: ToastContainerProps) {
if (messages.length === 0) return null; if (messages.length === 0) return null;
return ( return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2"> <div className="fixed right-4 top-4 z-50 flex flex-col gap-2">
{messages.map((message) => ( {messages.map((message) => (
<Toast key={message.id} message={message} onClose={onClose} /> <Toast key={message.id} message={message} onClose={onClose} />
))} ))}

View File

@ -8,6 +8,13 @@
--color-primary-light: #dbeafe; --color-primary-light: #dbeafe;
--color-accent: #9333ea; --color-accent: #9333ea;
--color-success: #15803d;
--color-success-light: #dcfce7;
--color-warning: #ca8a04;
--color-warning-light: #fef3c7;
--color-error: #dc2626;
--color-error-light: #fee2e2;
--text: #1f2937; --text: #1f2937;
--text-h: #0f172a; --text-h: #0f172a;
--text-dim: #64748b; --text-dim: #64748b;
@ -19,9 +26,33 @@
--border: #dbe3ef; --border: #dbe3ef;
--code-bg: #eef2f7; --code-bg: #eef2f7;
--radius-xs: 0.375rem;
--radius-sm: 0.5rem; --radius-sm: 0.5rem;
--radius-md: 0.75rem; --radius-md: 0.75rem;
--radius-lg: 1rem; --radius-lg: 1rem;
--radius-xl: 1.25rem;
--space-xxs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 2.5rem;
--space-3xl: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.65;
--shadow-subtle: 0 1px 2px rgba(15, 23, 42, 0.06); --shadow-subtle: 0 1px 2px rgba(15, 23, 42, 0.06);
--card-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); --card-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
@ -71,6 +102,7 @@ body {
margin: 0; margin: 0;
font-family: var(--sans); font-family: var(--sans);
color: var(--text); color: var(--text);
line-height: var(--line-height-normal);
background: var(--surface-gradient); background: var(--surface-gradient);
background-attachment: fixed; background-attachment: fixed;
} }

View File

@ -1,6 +1,7 @@
/** /**
* Centralized design tokens for the Recipe Manager frontend. * Global design tokens for the Recipe Manager frontend.
* Keep values semantic so UI primitives can evolve without broad refactors. *
* Keep these semantic and reusable so components can evolve without large refactors.
*/ */
export const colors = { export const colors = {
@ -8,9 +9,13 @@ export const colors = {
primaryDark: '#1d4ed8', primaryDark: '#1d4ed8',
primaryLight: '#dbeafe', primaryLight: '#dbeafe',
accent: '#9333ea', accent: '#9333ea',
success: '#15803d', success: '#15803d',
successLight: '#dcfce7',
warning: '#ca8a04', warning: '#ca8a04',
warningLight: '#fef3c7',
error: '#dc2626', error: '#dc2626',
errorLight: '#fee2e2',
bg: '#f4f7fb', bg: '#f4f7fb',
bgAlt: '#edf2f7', bgAlt: '#edf2f7',
@ -23,7 +28,7 @@ export const colors = {
textHeading: '#0f172a', textHeading: '#0f172a',
focusRing: '#2563eb', focusRing: '#2563eb',
}; } as const;
export const typography = { export const typography = {
fontFamily: { fontFamily: {
@ -39,6 +44,7 @@ export const typography = {
xl: '1.25rem', xl: '1.25rem',
'2xl': '1.5rem', '2xl': '1.5rem',
'3xl': '1.875rem', '3xl': '1.875rem',
'4xl': '2.25rem',
}, },
lineHeight: { lineHeight: {
tight: '1.2', tight: '1.2',
@ -50,8 +56,9 @@ export const typography = {
medium: 500, medium: 500,
semibold: 600, semibold: 600,
bold: 700, bold: 700,
extrabold: 800,
}, },
}; } as const;
export const spacing = { export const spacing = {
xxs: '0.25rem', xxs: '0.25rem',
@ -61,7 +68,8 @@ export const spacing = {
lg: '1.5rem', lg: '1.5rem',
xl: '2rem', xl: '2rem',
'2xl': '2.5rem', '2xl': '2.5rem',
}; '3xl': '3rem',
} as const;
export const radius = { export const radius = {
xs: '0.375rem', xs: '0.375rem',
@ -70,11 +78,22 @@ export const radius = {
lg: '1rem', lg: '1rem',
xl: '1.25rem', xl: '1.25rem',
full: '9999px', full: '9999px',
}; } as const;
export const shadows = { export const shadows = {
subtle: '0 1px 2px rgba(15, 23, 42, 0.06)', subtle: '0 1px 2px rgba(15, 23, 42, 0.06)',
card: '0 10px 30px rgba(15, 23, 42, 0.08)', card: '0 10px 30px rgba(15, 23, 42, 0.08)',
hover: '0 14px 34px rgba(15, 23, 42, 0.12)', hover: '0 14px 34px rgba(15, 23, 42, 0.12)',
focus: '0 0 0 3px rgba(37, 99, 235, 0.25)', focus: '0 0 0 3px rgba(37, 99, 235, 0.25)',
}; } as const;
export const recipeAccentPalette = ['#f97316', '#ef4444', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7'] as const;
export const designTokens = {
colors,
typography,
spacing,
radius,
shadows,
recipeAccentPalette,
} as const;

View File

@ -1,35 +1,60 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [
"./index.html", './index.html',
"./src/**/*.{js,ts,jsx,tsx}", './src/**/*.{js,ts,jsx,tsx}',
], ],
theme: { theme: {
extend: { extend: {
borderRadius: {
xs: '4px',
sm: '6px',
md: '10px',
lg: '16px',
full: '999px',
},
boxShadow: {
card: '0 2px 8px 0 rgba(28,30,34,0.08)',
hover: '0 4px 20px 0 rgba(28,30,34,0.16)',
},
colors: { colors: {
primary: '#2563eb', primary: 'var(--color-primary)',
accent: '#aa3bff', accent: 'var(--color-accent)',
success: '#16a34a', success: 'var(--color-success)',
warning: '#eab308', warning: 'var(--color-warning)',
error: '#dc2626', error: 'var(--color-error)',
surface: 'var(--surface)',
muted: 'var(--surface-muted)',
border: 'var(--border)',
}, },
fontFamily: { fontFamily: {
sans: ['system-ui', 'Segoe UI', 'Roboto', 'sans-serif'], sans: ['var(--sans)'],
heading: ['system-ui', 'Segoe UI', 'Roboto', 'sans-serif'], heading: ['var(--heading)'],
mono: ['ui-monospace', 'Consolas', 'monospace'], mono: ['var(--mono)'],
},
fontSize: {
xs: ['var(--font-size-xs)', { lineHeight: 'var(--line-height-normal)' }],
sm: ['var(--font-size-sm)', { lineHeight: 'var(--line-height-normal)' }],
base: ['var(--font-size-base)', { lineHeight: 'var(--line-height-normal)' }],
lg: ['var(--font-size-lg)', { lineHeight: 'var(--line-height-normal)' }],
xl: ['var(--font-size-xl)', { lineHeight: 'var(--line-height-tight)' }],
'2xl': ['var(--font-size-2xl)', { lineHeight: 'var(--line-height-tight)' }],
'3xl': ['var(--font-size-3xl)', { lineHeight: 'var(--line-height-tight)' }],
'4xl': ['var(--font-size-4xl)', { lineHeight: 'var(--line-height-tight)' }],
},
spacing: {
xxs: 'var(--space-xxs)',
xs: 'var(--space-xs)',
sm: 'var(--space-sm)',
md: 'var(--space-md)',
lg: 'var(--space-lg)',
xl: 'var(--space-xl)',
'2xl': 'var(--space-2xl)',
'3xl': 'var(--space-3xl)',
},
borderRadius: {
xs: 'var(--radius-xs)',
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
full: '9999px',
},
boxShadow: {
subtle: 'var(--shadow-subtle)',
card: 'var(--card-shadow)',
hover: 'var(--shadow-hover)',
}, },
}, },
}, },
plugins: [], plugins: [],
} };