feat(ui): add subtle motion and icon polish

This commit is contained in:
Paul Huliganga 2026-03-26 16:36:34 -04:00
parent 79d10730a2
commit 012a5362bb
6 changed files with 96 additions and 42 deletions

View File

@ -19,6 +19,33 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | null>(null);
function BookIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="h-4 w-4" aria-hidden="true">
<path d="M5 5.5A2.5 2.5 0 0 1 7.5 3H19v16H7.5A2.5 2.5 0 0 0 5 21V5.5Z" />
<path d="M5 21h14" />
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
}
function LinkIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="h-4 w-4" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L10.7 5.23" />
<path d="M14 11a5 5 0 0 0-7.07 0L4.8 13.12a5 5 0 0 0 7.07 7.07l1.42-1.42" />
</svg>
);
}
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
@ -38,10 +65,11 @@ function App() {
};
const linkClass = (path: string) => {
const base = `px-4 py-2 rounded-full text-sm font-semibold transition-colors shadow-sm`;
const base =
'group/nav relative inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-200 ease-out outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-safe:hover:-translate-y-0.5';
return isActive(path)
? `${base} bg-blue-100 text-blue-700`
: `${base} text-gray-700 hover:bg-gray-100`;
? `${base} bg-blue-100 text-blue-700 shadow-sm`
: `${base} text-slate-700 hover:bg-slate-100 hover:text-slate-900`;
};
return (
@ -52,21 +80,28 @@ function App() {
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/85 shadow-sm backdrop-blur-md dark:border-slate-700/60 dark:bg-slate-900/70">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center justify-between h-16 gap-3">
<div className="flex items-center">
<Link to="/" className="flex-shrink-0">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Recipe Manager</h1>
<Link
to="/"
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-200 hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
>
<span className="text-xl" aria-hidden="true">🍽</span>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">Recipe Manager</h1>
</Link>
</div>
<nav className="flex space-x-3">
<nav aria-label="Primary" className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
<Link to="/" className={linkClass('/')}>
Recipes
<BookIcon />
<span>Recipes</span>
</Link>
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
Add Recipe
<PlusIcon />
<span>Add Recipe</span>
</Link>
<Link to="/import/url" className={linkClass('/import/url')}>
Import URL
<LinkIcon />
<span>Import URL</span>
</Link>
</nav>
</div>

View File

@ -35,7 +35,7 @@ function emojiForRecipe(recipe: Recipe) {
function MetaChip({ label, value, emoji }: { label: string; value: string; emoji: string }) {
return (
<div className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/80 bg-slate-50 px-2.5 py-1 text-xs text-slate-700">
<div className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/80 bg-slate-50 px-2.5 py-1 text-xs text-slate-700 transition-all duration-200 group-hover:border-slate-300 group-hover:bg-white">
<span aria-hidden="true">{emoji}</span>
<span className="font-medium">{value}</span>
<span className="text-slate-500">{label}</span>
@ -50,7 +50,7 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
return (
<Link
to={`/recipe/${recipe.id}`}
className="group block overflow-hidden border border-slate-200/80 bg-white/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-600"
className="group block overflow-hidden border border-slate-200/80 bg-white/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
>
<div
@ -59,16 +59,16 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
background: `linear-gradient(135deg, color-mix(in srgb, ${accent} 30%, white) 0%, color-mix(in srgb, ${accent} 14%, #f8fafc) 55%, #ffffff 100%)`,
}}
>
<div className="absolute left-4 top-4 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur-sm">
<div className="absolute left-4 top-4 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur-sm transition-transform duration-200 group-hover:scale-[1.02]">
{tags[0]?.name ?? 'Homemade'}
</div>
<div className="absolute inset-0 flex items-center justify-center text-6xl opacity-90" aria-hidden="true">
<div className="absolute inset-0 flex items-center justify-center text-6xl opacity-90 transition-transform duration-300 group-hover:scale-105" aria-hidden="true">
{emojiForRecipe(recipe)}
</div>
</div>
<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 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">{recipe.title}</h3>
{recipe.description ? (
<p className="mb-3 line-clamp-2 text-sm text-gray-600">{recipe.description}</p>
@ -87,7 +87,7 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
{tags.slice(0, 4).map((tag) => (
<span
key={tag.id}
className="rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-sm"
className="rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-sm transition-transform duration-200 group-hover:scale-[1.02]"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
>
{tag.name}
@ -99,7 +99,10 @@ export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
<div className="mt-auto flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-gray-500">
<span>{recipe.last_cooked_at ? `Cooked ${formatDate(recipe.last_cooked_at)}` : 'Not cooked yet'}</span>
<span className="font-medium text-blue-600 group-hover:underline">View recipe </span>
<span className="inline-flex items-center gap-1 font-medium text-blue-600">
View recipe
<span className="transition-transform duration-200 group-hover:translate-x-0.5" aria-hidden="true"></span>
</span>
</div>
</div>
</Link>

View File

@ -121,7 +121,8 @@ button:focus-visible,
.btn:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
select:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
@ -176,6 +177,17 @@ textarea::placeholder {
border-radius: 7px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 480px) {
.max-w-7xl,
.max-w-6xl,

View File

@ -218,8 +218,8 @@ export function ImportUrlPage() {
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-base shadow-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
</div>
<button type="submit" disabled={loading} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50">
{loading ? 'Importing…' : 'Import URL'}
<button type="submit" disabled={loading} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{loading ? 'Importing…' : '🔗 Import URL'}
</button>
</form>
@ -267,10 +267,10 @@ export function ImportUrlPage() {
</div>
<div className="mt-2 flex gap-3">
<button type="submit" disabled={isSaving} className="rounded-lg bg-green-600 px-4 py-2 font-medium text-white shadow hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50">
{isSaving ? 'Saving…' : 'Save Recipe'}
<button type="submit" disabled={isSaving} className="rounded-lg bg-green-600 px-4 py-2 font-medium text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-green-700 focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
{isSaving ? 'Saving…' : '💾 Save Recipe'}
</button>
<Link to="/recipe/new" className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 shadow-sm hover:bg-gray-50">Open full editor</Link>
<Link to="/recipe/new" className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2">📝 Open full editor</Link>
</div>
</form>
) : (

View File

@ -210,23 +210,23 @@ export function RecipeDetailPage() {
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<button onClick={() => setIsEditing(true)} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700">Edit Recipe</button>
<Link to={`/recipe/${recipe.id}/cook`} className="rounded-lg bg-green-600 px-4 py-2.5 text-center font-semibold text-white shadow transition-colors hover:bg-green-700">Cook Mode</Link>
<button onClick={() => setIsEditing(true)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"><span aria-hidden="true"></span> Edit Recipe</button>
<Link to={`/recipe/${recipe.id}/cook`} className="inline-flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-center font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-green-700 focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"><span aria-hidden="true">🍳</span>Cook Mode</Link>
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="rounded-lg bg-red-600 px-4 py-2.5 font-semibold text-white shadow transition-colors hover:bg-red-700">Delete Recipe</button>
<button onClick={() => setDeleteConfirm(true)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-700 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"><span aria-hidden="true">🗑</span>Delete Recipe</button>
) : (
<div className="grid grid-cols-2 gap-2 sm:col-span-2 lg:col-span-2">
<button
onClick={handleDelete}
disabled={isDeleting}
className="rounded-lg bg-red-700 px-3 py-2.5 text-sm font-semibold text-white shadow transition-colors hover:bg-red-800 disabled:cursor-not-allowed disabled:bg-gray-400"
className="rounded-lg bg-red-700 px-3 py-2.5 text-sm font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-800 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{isDeleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="rounded-lg bg-slate-200 px-3 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
className="rounded-lg bg-slate-200 px-3 py-2.5 text-sm font-semibold text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-slate-300 focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
@ -303,7 +303,7 @@ export function RecipeDetailPage() {
)}
<div className="mt-8 text-center">
<Link to="/" className="font-medium text-primary hover:text-blue-700"> Back to all recipes</Link>
<Link to="/" className="inline-flex items-center gap-1 font-medium text-primary transition-colors hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 rounded-sm"> Back to all recipes</Link>
</div>
</div>
);

View File

@ -64,16 +64,18 @@ export function RecipeListPage() {
<div className="flex flex-wrap gap-3">
<Link
to="/recipe/new"
className="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow transition-colors hover:bg-blue-700"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
style={{ borderRadius: radius.md }}
>
<span aria-hidden="true"></span>
Add Recipe
</Link>
<a
href="#recipes-grid"
className="rounded-lg border border-slate-200 bg-white px-5 py-2.5 font-semibold text-slate-700 transition-colors hover:bg-slate-50"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-5 py-2.5 font-semibold text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
style={{ borderRadius: radius.md }}
>
<span aria-hidden="true">📚</span>
Browse Recipes
</a>
</div>
@ -96,10 +98,11 @@ export function RecipeListPage() {
</div>
<Link
to="/recipe/new"
className="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white shadow transition-colors hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
style={{ borderRadius: radius.md }}
>
+ New Recipe
<span aria-hidden="true"></span>
New Recipe
</Link>
</div>
@ -117,7 +120,7 @@ export function RecipeListPage() {
<button
type="button"
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors hover:text-gray-600 focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Clear search"
>
@ -126,7 +129,7 @@ export function RecipeListPage() {
</div>
<button
type="submit"
className="rounded-lg border border-gray-200 bg-gray-100 px-6 py-2 font-semibold text-gray-700 transition-colors hover:bg-gray-200"
className="rounded-lg border border-gray-200 bg-gray-100 px-6 py-2 font-semibold text-gray-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
style={{ borderRadius: radius.md }}
>
Search
@ -140,8 +143,8 @@ export function RecipeListPage() {
onClick={() => setSelectedTagId(null)}
className={
selectedTagId === null
? 'rounded-full bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-colors'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-colors hover:bg-gray-200'
? 'rounded-full bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-all duration-150 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-all duration-150 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
}
style={{ borderRadius: radius.full }}
>
@ -153,8 +156,8 @@ export function RecipeListPage() {
onClick={() => setSelectedTagId(tag.id)}
className={
selectedTagId === tag.id
? 'rounded-full px-3 py-1.5 text-sm font-semibold text-white shadow outline-none'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-colors hover:bg-gray-200'
? 'rounded-full px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-all duration-150 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-all duration-150 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
}
style={{ backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full }}
>
@ -171,7 +174,7 @@ export function RecipeListPage() {
{selectedTagId !== null && (
<span className="rounded bg-blue-50 px-2 py-1 text-blue-700">Tag: {tags.find((t) => t.id === selectedTagId)?.name}</span>
)}
<button onClick={handleClearFilters} className="font-medium text-blue-600 hover:text-blue-700">
<button onClick={handleClearFilters} className="font-medium text-blue-600 transition-colors hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 rounded-sm">
Clear all filters
</button>
</div>
@ -207,9 +210,10 @@ export function RecipeListPage() {
{!searchQuery && (
<Link
to="/recipe/new"
className="inline-block rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white shadow transition-colors hover:bg-blue-700"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
style={{ borderRadius: radius.md }}
>
<span aria-hidden="true">🍳</span>
Add Your First Recipe
</Link>
)}
@ -228,7 +232,7 @@ export function RecipeListPage() {
<button
onClick={loadMore}
disabled={loading}
className="rounded-lg border bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
className="rounded-lg border bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: radius.md }}
>
{loading ? 'Loading...' : 'Load More'}