feat(frontend): recipe UI — filters, dismissal, load more, prep notes, nutrition chips

- Style/category filter panel with active chip display
- Dismiss (excluded_ids) support — recipes don't reappear until next fresh search
- Load more appends next batch without full re-fetch
- Prep notes 'Before you start:' section above directions
- Nutrition macro chips (kcal, fat, protein, carbs, fiber, sugar, sodium)
- Composables extracted for reuse
This commit is contained in:
pyr0ball 2026-04-02 22:12:45 -07:00
parent 1a493e0ad9
commit 1f819c4ee0
10 changed files with 1379 additions and 381 deletions

View file

@ -4,7 +4,13 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Kiwi — Pantry Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>

View file

@ -844,7 +844,6 @@
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1557,7 +1556,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -1721,7 +1719,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -1743,7 +1740,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -1825,7 +1821,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",

View file

@ -1,66 +1,149 @@
<template>
<div id="app">
<div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<!-- Desktop sidebar (hidden on mobile) -->
<aside class="sidebar" role="navigation" aria-label="Main navigation">
<!-- Wordmark + collapse toggle -->
<div class="sidebar-header">
<span class="wordmark-kiwi" @click="onWordmarkClick" style="cursor:pointer">Kiwi</span>
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :aria-label="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<button :class="['sidebar-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
<rect x="3" y="11" width="18" height="4" rx="1"/>
<rect x="3" y="18" width="18" height="3" rx="1"/>
</svg>
<span class="sidebar-label">Pantry</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
<line x1="8" y1="9" x2="16" y2="9"/>
<line x1="8" y1="13" x2="14" y2="13"/>
</svg>
<span class="sidebar-label">Receipts</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</svg>
<span class="sidebar-label">Recipes</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="sidebar-label">Settings</span>
</button>
</nav>
</aside>
<!-- Main area: header + content -->
<div class="app-body">
<!-- Mobile-only header -->
<header class="app-header">
<div class="container">
<h1>🥝 Kiwi</h1>
<p class="tagline">Smart Pantry Tracking & Recipe Suggestions</p>
<div class="header-inner">
<span class="wordmark-kiwi">Kiwi</span>
</div>
</header>
<main class="app-main">
<div class="container">
<!-- Tabs -->
<div class="tabs">
<button
:class="['tab', { active: currentTab === 'inventory' }]"
@click="switchTab('inventory')"
>
🏪 Inventory
</button>
<button
:class="['tab', { active: currentTab === 'receipts' }]"
@click="switchTab('receipts')"
>
🧾 Receipts
</button>
<button
:class="['tab', { active: currentTab === 'recipes' }]"
@click="switchTab('recipes')"
>
🍳 Recipes
</button>
<button
:class="['tab', { active: currentTab === 'settings' }]"
@click="switchTab('settings')"
>
Settings
</button>
</div>
<!-- Tab Content -->
<div v-show="currentTab === 'inventory'" class="tab-content">
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
<InventoryList />
</div>
<div v-show="currentTab === 'receipts'" class="tab-content">
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
<ReceiptsView />
</div>
<div v-show="currentTab === 'recipes'" class="tab-content">
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content">
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
</div>
</main>
<footer class="app-footer">
<div class="container">
<p>&copy; 2026 CircuitForge LLC</p>
</div>
</footer>
<!-- Mobile bottom nav only -->
<nav class="bottom-nav" role="navigation" aria-label="Main navigation">
<button :class="['nav-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')" aria-label="Pantry">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
<rect x="3" y="11" width="18" height="4" rx="1"/>
<rect x="3" y="18" width="18" height="3" rx="1"/>
</svg>
<span class="nav-label">Pantry</span>
</button>
<button :class="['nav-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')" aria-label="Receipts">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
<line x1="8" y1="9" x2="16" y2="9"/>
<line x1="8" y1="13" x2="14" y2="13"/>
</svg>
<span class="nav-label">Receipts</span>
</button>
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</svg>
<span class="nav-label">Recipes</span>
</button>
<button :class="['nav-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')" aria-label="Settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="nav-label">Settings</span>
</button>
</nav>
<!-- Easter egg: Kiwi bird sprite triggered by typing "kiwi" -->
<Transition name="kiwi-fade">
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
<div class="kiwi-bird" :class="kiwiDirection">
<!-- Kiwi bird SVG side profile, facing left by default (rtl walk) -->
<svg class="kiwi-svg" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Body plump oval -->
<ellipse cx="30" cy="38" rx="18" ry="15" fill="#8B6914" />
<!-- Head -->
<ellipse cx="46" cy="26" rx="10" ry="9" fill="#6B4F10" />
<!-- Long beak -->
<path d="M54 25 Q66 24 70 25 Q66 27 54 27Z" fill="#C8A96E" />
<!-- Eye -->
<circle cx="49" cy="23" r="2" fill="#1a1a1a" />
<circle cx="49.7" cy="22.3" r="0.6" fill="white" />
<!-- Wing texture lines -->
<path d="M18 32 Q24 28 34 30" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<path d="M16 37 Q22 33 32 35" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<!-- Legs -->
<line x1="24" y1="52" x2="22" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<line x1="34" y1="52" x2="36" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<!-- Feet -->
<path d="M18 60 L22 60 L24 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="M32 60 L36 60 L38 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<!-- Feather texture -->
<path d="M22 38 Q28 34 36 36" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
<path d="M20 43 Q26 39 34 41" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
</svg>
</div>
</div>
</Transition>
</div>
</template>
@ -71,11 +154,29 @@ import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory')
const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
// Wordmark click counter for chef mode easter egg
const wordmarkClicks = ref(0)
let wordmarkTimer: ReturnType<typeof setTimeout> | null = null
function onWordmarkClick() {
wordmarkClicks.value++
if (wordmarkTimer) clearTimeout(wordmarkTimer)
if (wordmarkClicks.value >= 5) {
wordmarkClicks.value = 0
document.querySelector('.wordmark-kiwi')?.classList.add('chef-spin')
setTimeout(() => document.querySelector('.wordmark-kiwi')?.classList.remove('chef-spin'), 800)
} else {
wordmarkTimer = setTimeout(() => { wordmarkClicks.value = 0 }, 1200)
}
}
async function switchTab(tab: Tab) {
currentTab.value = tab
@ -93,136 +194,322 @@ async function switchTab(tab: Tab) {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.wordmark-kiwi {
font-family: var(--font-display);
font-style: italic;
font-weight: 700;
color: var(--color-primary);
letter-spacing: -0.01em;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
/* ============================================
MOBILE LAYOUT (< 769px)
sidebar hidden, bottom nav visible
============================================ */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: 68px; /* bottom nav clearance */
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.sidebar { display: none; }
.app-body { display: contents; }
.app-header {
background: var(--gradient-primary);
color: white;
padding: var(--spacing-xl) 0;
box-shadow: var(--shadow-md);
background: var(--gradient-header);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-sm) var(--spacing-md);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
}
.app-header h1 {
font-size: 32px;
margin-bottom: 5px;
.header-inner {
display: flex;
align-items: center;
min-height: 44px;
}
.app-header .tagline {
font-size: 16px;
opacity: 0.9;
}
.header-inner .wordmark-kiwi { font-size: 24px; }
.app-main {
flex: 1;
padding: 20px 0;
padding: var(--spacing-md) 0 var(--spacing-xl);
}
.app-footer {
.container {
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.tab-content { min-height: 0; }
/* ---- Bottom nav ---- */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 200;
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
padding: var(--spacing-lg) 0;
text-align: center;
margin-top: var(--spacing-xl);
border-top: 1px solid var(--color-border);
}
.app-footer p {
font-size: var(--font-size-sm);
opacity: 0.8;
}
/* Tabs */
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: stretch;
padding-bottom: env(safe-area-inset-bottom, 0);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
}
.tab {
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 15px 30px;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.tab:hover {
background: rgba(255, 255, 255, 0.3);
}
.tab.active {
background: var(--color-bg-card);
color: var(--color-primary);
font-weight: 600;
}
.tab-content {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Mobile Responsive Breakpoints */
@media (max-width: 480px) {
.container {
padding: 0 12px;
}
.app-header h1 {
font-size: 24px;
}
.app-header .tagline {
font-size: 14px;
}
.tabs {
gap: 8px;
}
.tab {
padding: 12px 20px;
font-size: 14px;
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 4px 10px;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease;
border-radius: 0;
position: relative;
}
.nav-item::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--color-primary);
border-radius: 0 0 2px 2px;
transform: scaleX(0);
transition: transform 0.18s ease;
}
.nav-item:hover {
color: var(--color-text-secondary);
background: rgba(232, 168, 32, 0.06);
transform: none;
border-color: transparent;
}
.nav-item.active { color: var(--color-primary); }
.nav-item.active::before { transform: scaleX(1); }
.nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
.nav-label {
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1;
}
@media (max-width: 480px) {
.container { padding: 0 var(--spacing-sm); }
.app-main { padding: var(--spacing-sm) 0 var(--spacing-lg); }
}
/* ============================================
DESKTOP LAYOUT ( 769px)
sidebar visible, bottom nav hidden
============================================ */
@media (min-width: 769px) {
.bottom-nav { display: none; }
#app {
flex-direction: row;
padding-bottom: 0;
min-height: 100vh;
}
/* ---- Sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
width: 200px;
min-height: 100vh;
background: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
position: sticky;
top: 0;
flex-shrink: 0;
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 100;
}
.sidebar-collapsed .sidebar {
width: 56px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
min-height: 56px;
gap: var(--spacing-sm);
}
.sidebar-header .wordmark-kiwi {
font-size: 22px;
opacity: 1;
transition: opacity 0.15s ease, width 0.22s ease;
flex-shrink: 0;
}
.sidebar-collapsed .sidebar-header .wordmark-kiwi {
opacity: 0;
width: 0;
pointer-events: none;
}
.sidebar-toggle {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 6px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.sidebar-toggle:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-sm);
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 10px var(--spacing-sm);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
width: 100%;
text-align: left;
}
.sidebar-item:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-item.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.sidebar-item .nav-icon { width: 20px; height: 20px; flex-shrink: 0; }
.sidebar-label {
font-size: var(--font-size-sm);
font-weight: 600;
opacity: 1;
transition: opacity 0.12s ease;
overflow: hidden;
}
.sidebar-collapsed .sidebar-label {
opacity: 0;
width: 0;
pointer-events: none;
}
/* ---- Main body ---- */
.app-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; /* prevent overflow */
contents: unset;
}
.app-header { display: none; } /* wordmark lives in sidebar on desktop */
/* Override style.css #app max-width so sidebar spans full viewport */
#app {
max-width: none;
margin: 0;
}
.app-main {
flex: 1;
padding: var(--spacing-xl) 0;
}
.container {
max-width: 860px;
padding: 0 var(--spacing-lg);
}
}
@media (min-width: 481px) and (max-width: 768px) {
@media (min-width: 1200px) {
.container {
padding: 0 16px;
max-width: 960px;
padding: 0 var(--spacing-xl);
}
}
.app-header h1 {
font-size: 28px;
}
/* Easter egg: wordmark spin on 5× click */
@keyframes chefSpin {
0% { transform: rotate(0deg) scale(1); }
30% { transform: rotate(180deg) scale(1.3); }
60% { transform: rotate(340deg) scale(1.1); }
100% { transform: rotate(360deg) scale(1); }
}
.tab {
padding: 14px 25px;
}
.wordmark-kiwi.chef-spin {
display: inline-block;
animation: chefSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Kiwi bird transition */
.kiwi-fade-enter-active,
.kiwi-fade-leave-active {
transition: opacity 0.4s ease;
}
.kiwi-fade-enter-from,
.kiwi-fade-leave-to {
opacity: 0;
}
</style>

View file

@ -281,15 +281,25 @@
<p class="text-muted text-sm" style="margin-top: var(--spacing-md)">Loading pantry</p>
</div>
<!-- Empty State -->
<div v-else-if="!loading && filteredItems.length === 0" class="empty-state">
<!-- Empty State: clean slate (no items at all) -->
<div v-else-if="!loading && filteredItems.length === 0 && store.items.length === 0" class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<rect x="6" y="10" width="36" height="6" rx="2"/>
<rect x="6" y="21" width="36" height="6" rx="2"/>
<rect x="6" y="32" width="36" height="6" rx="2"/>
</svg>
<p class="text-secondary">No items found.</p>
<p class="text-muted text-sm">Scan a barcode or add manually above.</p>
<p class="text-secondary">Clean slate.</p>
<p class="text-muted text-sm">Your pantry is ready for anything scan a barcode or add an item above.</p>
</div>
<!-- Empty State: filter has no matches -->
<div v-else-if="!loading && filteredItems.length === 0" class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<circle cx="20" cy="20" r="12"/>
<line x1="29" y1="29" x2="42" y2="42"/>
</svg>
<p class="text-secondary">Nothing matches that filter.</p>
<p class="text-muted text-sm">Try a different location or status.</p>
</div>
<!-- Inventory shelf list -->

View file

@ -154,17 +154,50 @@
</p>
</details>
<!-- Suggest Button -->
<!-- Cuisine Style (Level 3+ only) -->
<div v-if="recipesStore.level >= 3" class="form-group">
<label class="form-label">Cuisine Style <span class="text-muted text-xs">(Level 3+)</span></label>
<div class="flex flex-wrap gap-xs">
<button
class="btn btn-primary btn-lg w-full"
v-for="style in cuisineStyles"
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
>{{ style.label }}</button>
</div>
</div>
<!-- Category Filter (Level 12 only) -->
<div v-if="recipesStore.level <= 2" class="form-group">
<label class="form-label">Category <span class="text-muted text-xs">(optional)</span></label>
<input
class="form-input"
v-model="categoryInput"
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins"
@blur="recipesStore.category = categoryInput.trim() || null"
@keydown.enter="recipesStore.category = categoryInput.trim() || null"
/>
</div>
<!-- Suggest Button -->
<div class="suggest-row">
<button
class="btn btn-primary btn-lg flex-1"
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
@click="handleSuggest"
>
<span v-if="recipesStore.loading">
<span v-if="recipesStore.loading && !isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes
</span>
<span v-else>Suggest Recipes</span>
</button>
<button
v-if="recipesStore.dismissedCount > 0"
class="btn btn-ghost btn-sm"
@click="recipesStore.clearDismissed()"
title="Show all dismissed recipes again"
>Clear dismissed ({{ recipesStore.dismissedCount }})</button>
</div>
<!-- Empty pantry nudge -->
<p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs">
@ -218,10 +251,17 @@
<!-- Header row -->
<div class="flex-between mb-sm">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs">
<div class="flex flex-wrap gap-xs" style="align-items:center">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
<button
v-if="recipe.id"
class="btn-dismiss"
@click="recipesStore.dismiss(recipe.id)"
title="Hide this recipe"
aria-label="Dismiss recipe"
></button>
</div>
</div>
@ -308,6 +348,16 @@
</div>
</details>
<!-- Prep notes -->
<div v-if="recipe.prep_notes && recipe.prep_notes.length > 0" class="prep-notes mb-sm">
<p class="text-sm font-semibold">Before you start:</p>
<ul class="prep-notes-list mt-xs">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-note-item">
{{ note }}
</li>
</ul>
</div>
<!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary">
@ -322,6 +372,20 @@
</div>
</div>
<!-- Load More -->
<div v-if="recipesStore.result.suggestions.length > 0" class="load-more-row">
<button
class="btn btn-secondary"
:disabled="recipesStore.loading"
@click="handleLoadMore"
>
<span v-if="recipesStore.loading && isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Loading
</span>
<span v-else>Load more recipes</span>
</button>
</div>
<!-- Grocery list summary -->
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
<h3 class="text-lg font-bold mb-sm">Shopping List</h3>
@ -364,6 +428,8 @@ const inventoryStore = useInventoryStore()
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const categoryInput = ref('')
const isLoadingMore = ref(false)
const levels = [
{ value: 1, label: '1 — From Pantry' },
@ -372,6 +438,14 @@ const levels = [
{ value: 4, label: '4 — Wildcard 🎲' },
]
const cuisineStyles = [
{ id: 'italian', label: 'Italian' },
{ id: 'mediterranean', label: 'Mediterranean' },
{ id: 'east_asian', label: 'East Asian' },
{ id: 'latin', label: 'Latin' },
{ id: 'eastern_european', label: 'Eastern European' },
]
// Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => {
const sorted = [...inventoryStore.items]
@ -462,9 +536,16 @@ function onNutritionInput(key: NutritionKey, e: Event) {
// Suggest handler
async function handleSuggest() {
isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value)
}
async function handleLoadMore() {
isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value)
isLoadingMore.value = false
}
onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
@ -543,6 +624,58 @@ onMounted(async () => {
margin-right: var(--spacing-sm);
}
.btn-dismiss {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 12px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-dismiss:hover {
color: var(--color-error, #dc2626);
background: var(--color-error-bg, #fee2e2);
transform: none;
}
.suggest-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.btn-ghost {
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
white-space: nowrap;
}
.btn-ghost:hover {
color: var(--color-primary);
background: transparent;
transform: none;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.load-more-row {
display: flex;
justify-content: center;
margin-bottom: var(--spacing-md);
}
.collapsible {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
@ -577,6 +710,17 @@ details[open] .collapsible-summary::before {
border-bottom: none;
}
.prep-notes-list {
padding-left: var(--spacing-lg);
list-style-type: disc;
}
.prep-note-item {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
.directions-list {
padding-left: var(--spacing-lg);
}

View file

@ -0,0 +1,132 @@
import { ref, onMounted, onUnmounted } from 'vue'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KIWI_WORD = ['k','i','w','i']
// Module-level shared state — single instance across all component uses
const neonMode = ref(false)
const kiwiVisible = ref(false)
const kiwiDirection = ref<'ltr' | 'rtl'>('rtl') // bird enters from right by default
const NEON_VARS: Record<string, string> = {
'--color-bg-primary': '#070011',
'--color-bg-secondary': '#0f001f',
'--color-bg-elevated': '#160028',
'--color-bg-card': '#160028',
'--color-bg-input': '#0f001f',
'--color-primary': '#ff006e',
'--color-text-primary': '#f0e6ff',
'--color-text-secondary': '#c090ff',
'--color-text-muted': '#7040a0',
'--color-border': 'rgba(255, 0, 110, 0.22)',
'--color-border-focus': '#ff006e',
'--color-info': '#00f5ff',
'--color-info-bg': 'rgba(0, 245, 255, 0.10)',
'--color-info-border': 'rgba(0, 245, 255, 0.30)',
'--color-info-light': '#00f5ff',
'--color-success': '#39ff14',
'--color-success-bg': 'rgba(57, 255, 20, 0.10)',
'--color-success-border': 'rgba(57, 255, 20, 0.30)',
'--color-success-light': '#39ff14',
'--color-warning': '#ffbe0b',
'--color-warning-bg': 'rgba(255, 190, 11, 0.10)',
'--color-warning-border': 'rgba(255, 190, 11, 0.30)',
'--color-warning-light': '#ffbe0b',
'--shadow-amber': '0 0 18px rgba(255, 0, 110, 0.55)',
'--shadow-md': '0 2px 16px rgba(255, 0, 110, 0.18)',
'--shadow-lg': '0 4px 28px rgba(255, 0, 110, 0.25)',
'--gradient-primary': 'linear-gradient(135deg, #ff006e 0%, #8338ec 100%)',
'--gradient-header': 'linear-gradient(135deg, #070011 0%, #160028 100%)',
'--color-loc-fridge': '#00f5ff',
'--color-loc-freezer': '#8338ec',
'--color-loc-pantry': '#ff006e',
'--color-loc-cabinet': '#ffbe0b',
'--color-loc-garage-freezer': '#39ff14',
}
function applyNeon() {
const root = document.documentElement
for (const [prop, val] of Object.entries(NEON_VARS)) {
root.style.setProperty(prop, val)
}
document.body.classList.add('neon-mode')
}
function removeNeon() {
const root = document.documentElement
for (const prop of Object.keys(NEON_VARS)) {
root.style.removeProperty(prop)
}
document.body.classList.remove('neon-mode')
}
function toggleNeon() {
neonMode.value = !neonMode.value
if (neonMode.value) {
applyNeon()
localStorage.setItem('kiwi-neon-mode', '1')
} else {
removeNeon()
localStorage.removeItem('kiwi-neon-mode')
}
}
function spawnKiwi() {
kiwiDirection.value = Math.random() > 0.5 ? 'ltr' : 'rtl'
kiwiVisible.value = true
setTimeout(() => { kiwiVisible.value = false }, 5500)
}
export function useEasterEggs() {
const konamiBuffer: string[] = []
const kiwiBuffer: string[] = []
function onKeyDown(e: KeyboardEvent) {
// Skip when user is typing in a form input
const tag = (e.target as HTMLElement)?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
// Konami code — works even in inputs
konamiBuffer.push(e.key)
if (konamiBuffer.length > KONAMI.length) konamiBuffer.shift()
if (konamiBuffer.join(',') === KONAMI.join(',')) {
toggleNeon()
konamiBuffer.length = 0
}
// KIWI word — only when not in a form input
if (!isInput) {
const key = e.key.toLowerCase()
if ('kiwi'.includes(key) && key.length === 1) {
kiwiBuffer.push(key)
if (kiwiBuffer.length > KIWI_WORD.length) kiwiBuffer.shift()
if (kiwiBuffer.join('') === 'kiwi') {
spawnKiwi()
kiwiBuffer.length = 0
}
} else {
kiwiBuffer.length = 0
}
}
}
onMounted(() => {
if (localStorage.getItem('kiwi-neon-mode')) {
neonMode.value = true
applyNeon()
}
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
return {
neonMode,
kiwiVisible,
kiwiDirection,
toggleNeon,
spawnKiwi,
}
}

View file

@ -415,6 +415,18 @@ export interface SwapCandidate {
compensation_hints: Record<string, string>[]
}
export interface NutritionPanel {
calories: number | null
fat_g: number | null
protein_g: number | null
carbs_g: number | null
fiber_g: number | null
sugar_g: number | null
sodium_mg: number | null
servings: number | null
estimated: boolean
}
export interface RecipeSuggestion {
id: number
title: string
@ -423,9 +435,18 @@ export interface RecipeSuggestion {
swap_candidates: SwapCandidate[]
missing_ingredients: string[]
directions: string[]
prep_notes: string[]
notes: string
level: number
is_wildcard: boolean
nutrition: NutritionPanel | null
}
export interface NutritionFilters {
max_calories: number | null
max_sugar_g: number | null
max_carbs_g: number | null
max_sodium_mg: number | null
}
export interface GroceryLink {
@ -452,7 +473,10 @@ export interface RecipeRequest {
hard_day_mode: boolean
max_missing: number | null
style_id: string | null
category: string | null
wildcard_confirmed: boolean
nutrition_filters: NutritionFilters
excluded_ids: number[]
}
export interface Staple {

View file

@ -2,31 +2,69 @@
* Recipes Store
*
* Manages recipe suggestion state and request parameters using Pinia.
* Dismissed recipe IDs are persisted to localStorage with a 7-day TTL.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeRequest } from '../services/api'
import { ref, computed } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
// [id, dismissedAtMs]
type DismissEntry = [number, number]
function loadDismissed(): Set<number> {
try {
const raw = localStorage.getItem(DISMISSED_KEY)
if (!raw) return new Set()
const entries: DismissEntry[] = JSON.parse(raw)
const cutoff = Date.now() - DISMISS_TTL_MS
return new Set(entries.filter(([, ts]) => ts > cutoff).map(([id]) => id))
} catch {
return new Set()
}
}
function saveDismissed(ids: Set<number>) {
const now = Date.now()
const entries: DismissEntry[] = [...ids].map((id) => [id, now])
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
}
export const useRecipesStore = defineStore('recipes', () => {
// State
// Suggestion result state
const result = ref<RecipeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Request parameters
const level = ref(1)
const constraints = ref<string[]>([])
const allergies = ref<string[]>([])
const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null)
const category = ref<string | null>(null)
const wildcardConfirmed = ref(false)
const nutritionFilters = ref<NutritionFilters>({
max_calories: null,
max_sugar_g: null,
max_carbs_g: null,
max_sodium_mg: null,
})
// Actions
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
// Dismissed IDs: persisted to localStorage, 7-day TTL
const dismissedIds = ref<Set<number>>(loadDismissed())
// Seen IDs: session-only, used by Load More to avoid repeating results
const seenIds = ref<Set<number>>(new Set())
const req: RecipeRequest = {
const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
return {
pantry_items: pantryItems,
level: level.value,
constraints: constraints.value,
@ -35,23 +73,77 @@ export const useRecipesStore = defineStore('recipes', () => {
hard_day_mode: hardDayMode.value,
max_missing: maxMissing.value,
style_id: styleId.value,
category: category.value,
wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
}
}
try {
result.value = await recipesAPI.suggest(req)
} catch (err: unknown) {
if (err instanceof Error) {
error.value = err.message
} else {
error.value = 'Failed to get recipe suggestions'
function _trackSeen(suggestions: RecipeSuggestion[]) {
for (const s of suggestions) {
if (s.id) seenIds.value = new Set([...seenIds.value, s.id])
}
console.error('Error fetching recipe suggestions:', err)
}
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
seenIds.value = new Set()
try {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
_trackSeen(result.value.suggestions)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
} finally {
loading.value = false
}
}
async function loadMore(pantryItems: string[]) {
if (!result.value || loading.value) return
loading.value = true
error.value = null
try {
// Exclude everything already shown (dismissed + all seen this session)
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else {
result.value = {
...result.value,
suggestions: [...result.value.suggestions, ...more.suggestions],
grocery_list: [...new Set([...result.value.grocery_list, ...more.grocery_list])],
grocery_links: [...result.value.grocery_links, ...more.grocery_links],
}
_trackSeen(more.suggestions)
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load more recipes'
} finally {
loading.value = false
}
}
function dismiss(id: number) {
dismissedIds.value = new Set([...dismissedIds.value, id])
saveDismissed(dismissedIds.value)
// Remove from current results immediately
if (result.value) {
result.value = {
...result.value,
suggestions: result.value.suggestions.filter((s) => s.id !== id),
}
}
}
function clearDismissed() {
dismissedIds.value = new Set()
localStorage.removeItem(DISMISSED_KEY)
}
function clearResult() {
result.value = null
error.value = null
@ -59,7 +151,6 @@ export const useRecipesStore = defineStore('recipes', () => {
}
return {
// State
result,
loading,
error,
@ -69,10 +160,15 @@ export const useRecipesStore = defineStore('recipes', () => {
hardDayMode,
maxMissing,
styleId,
category,
wildcardConfirmed,
// Actions
nutritionFilters,
dismissedIds,
dismissedCount,
suggest,
loadMore,
dismiss,
clearDismissed,
clearResult,
}
})

View file

@ -1,9 +1,14 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
/* Typography */
--font-display: 'Fraunces', Georgia, serif;
--font-mono: 'DM Mono', 'Courier New', monospace;
--font-body: 'DM Sans', system-ui, sans-serif;
font-family: var(--font-body);
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -11,66 +16,79 @@
-moz-osx-font-smoothing: grayscale;
/* Theme Colors - Dark Mode (Default) */
--color-text-primary: rgba(255, 255, 255, 0.87);
--color-text-secondary: rgba(255, 255, 255, 0.6);
--color-text-muted: rgba(255, 255, 255, 0.4);
--color-text-primary: rgba(255, 248, 235, 0.92);
--color-text-secondary: rgba(255, 248, 235, 0.60);
--color-text-muted: rgba(255, 248, 235, 0.38);
--color-bg-primary: #242424;
--color-bg-secondary: #1a1a1a;
--color-bg-elevated: #2d2d2d;
--color-bg-card: #2d2d2d;
--color-bg-input: #1a1a1a;
--color-bg-primary: #1e1c1a;
--color-bg-secondary: #161412;
--color-bg-elevated: #2a2724;
--color-bg-card: #2a2724;
--color-bg-input: #161412;
--color-border: rgba(255, 255, 255, 0.1);
--color-border-focus: rgba(255, 255, 255, 0.2);
--color-border: rgba(232, 168, 32, 0.12);
--color-border-focus: rgba(232, 168, 32, 0.35);
/* Brand Colors */
--color-primary: #667eea;
--color-primary-dark: #5568d3;
--color-primary-light: #7d8ff0;
--color-secondary: #764ba2;
/* Brand Colors — Saffron amber + forest green */
--color-primary: #e8a820;
--color-primary-dark: #c88c10;
--color-primary-light: #f0bc48;
--color-secondary: #2d5a27;
--color-secondary-light: #3d7a35;
--color-secondary-dark: #1e3d1a;
/* Status Colors */
--color-success: #4CAF50;
--color-success-dark: #45a049;
--color-success-light: #66bb6a;
--color-success-bg: rgba(76, 175, 80, 0.1);
--color-success-border: rgba(76, 175, 80, 0.3);
--color-success: #4a8c40;
--color-success-dark: #3a7030;
--color-success-light: #6aac60;
--color-success-bg: rgba(74, 140, 64, 0.12);
--color-success-border: rgba(74, 140, 64, 0.30);
--color-warning: #ff9800;
--color-warning-dark: #f57c00;
--color-warning-light: #ffb74d;
--color-warning-bg: rgba(255, 152, 0, 0.1);
--color-warning-border: rgba(255, 152, 0, 0.3);
--color-warning: #e8a820;
--color-warning-dark: #c88c10;
--color-warning-light: #f0bc48;
--color-warning-bg: rgba(232, 168, 32, 0.12);
--color-warning-border: rgba(232, 168, 32, 0.30);
--color-error: #f44336;
--color-error-dark: #d32f2f;
--color-error-light: #ff6b6b;
--color-error-bg: rgba(244, 67, 54, 0.1);
--color-error-border: rgba(244, 67, 54, 0.3);
--color-error: #c0392b;
--color-error-dark: #96281b;
--color-error-light: #e74c3c;
--color-error-bg: rgba(192, 57, 43, 0.12);
--color-error-border: rgba(192, 57, 43, 0.30);
--color-info: #2196F3;
--color-info-dark: #1976D2;
--color-info-light: #64b5f6;
--color-info-bg: rgba(33, 150, 243, 0.1);
--color-info-border: rgba(33, 150, 243, 0.3);
--color-info: #2980b9;
--color-info-dark: #1a5f8a;
--color-info-light: #5dade2;
--color-info-bg: rgba(41, 128, 185, 0.12);
--color-info-border: rgba(41, 128, 185, 0.30);
/* Location dot colors */
--color-loc-fridge: #5dade2;
--color-loc-freezer: #48d1cc;
--color-loc-garage-freezer: #7fb3d3;
--color-loc-pantry: #e8a820;
--color-loc-cabinet: #a0855b;
/* Gradient */
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, #c88c10 100%);
--gradient-secondary: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-light) 100%);
--gradient-header: linear-gradient(160deg, #2a2724 0%, #1e1c1a 100%);
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.5);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.6);
--shadow-amber: 0 4px 16px rgba(232, 168, 32, 0.20);
/* Typography */
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 24px;
--font-size-2xl: 32px;
/* Typography Scale */
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 15px;
--font-size-lg: 17px;
--font-size-xl: 22px;
--font-size-2xl: 30px;
--font-size-display: 28px;
/* Spacing */
--spacing-xs: 4px;
@ -80,176 +98,154 @@
--spacing-xl: 32px;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 999px;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
}
/* Light mode overrides */
@media (prefers-color-scheme: light) {
:root {
--color-text-primary: #2c1a06;
--color-text-secondary: #6b4c1e;
--color-text-muted: #a0845a;
--color-bg-primary: #fdf8f0;
--color-bg-secondary: #ffffff;
--color-bg-elevated: #fff9ed;
--color-bg-card: #ffffff;
--color-bg-input: #fef9ef;
--color-border: rgba(168, 100, 20, 0.15);
--color-border-focus: rgba(168, 100, 20, 0.40);
--color-success-bg: #e8f5e2;
--color-success-border: #c3e0bb;
--color-warning-bg: #fff8e1;
--color-warning-border: #ffe08a;
--color-error-bg: #fdecea;
--color-error-border: #f5c6c2;
--color-info-bg: #e3f2fd;
--color-info-border: #b3d8f5;
--gradient-header: linear-gradient(160deg, #fff9ed 0%, #fdf8f0 100%);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.10);
--shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.16);
--shadow-amber: 0 4px 16px rgba(168, 100, 20, 0.15);
}
}
a {
font-weight: 500;
color: #646cff;
color: var(--color-primary);
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: var(--color-primary-light);
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 600;
line-height: 1.2;
}
button {
border-radius: 8px;
border-radius: var(--radius-md);
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
padding: 0.5em 1.1em;
font-size: var(--font-size-sm);
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
font-family: var(--font-body);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
cursor: pointer;
transition: border-color 0.25s;
transition: all 0.2s ease;
}
button:hover {
border-color: #646cff;
border-color: var(--color-primary);
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.card {
padding: 2em;
padding: var(--spacing-lg);
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
/* Theme Colors - Light Mode */
--color-text-primary: #213547;
--color-text-secondary: #666;
--color-text-muted: #999;
--color-bg-primary: #f5f5f5;
--color-bg-secondary: #ffffff;
--color-bg-elevated: #ffffff;
--color-bg-card: #ffffff;
--color-bg-input: #ffffff;
--color-border: #ddd;
--color-border-focus: #ccc;
/* Status colors stay the same in light mode */
/* But we adjust backgrounds for better contrast */
--color-success-bg: #d4edda;
--color-success-border: #c3e6cb;
--color-warning-bg: #fff3cd;
--color-warning-border: #ffeaa7;
--color-error-bg: #f8d7da;
--color-error-border: #f5c6cb;
--color-info-bg: #d1ecf1;
--color-info-border: #bee5eb;
/* Shadows for light mode (lighter) */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.2);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
text-align: left;
}
/* Mobile Responsive Typography and Spacing */
@media (max-width: 480px) {
:root {
/* Reduce font sizes for mobile */
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
--font-size-xl: 19px;
--font-size-2xl: 24px;
--font-size-display: 22px;
/* Reduce spacing for mobile */
--spacing-xs: 4px;
--spacing-sm: 6px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
/* Reduce shadows for mobile */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.4);
}
h1 {
font-size: 2em;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 6px 12px rgba(0, 0, 0, 0.40);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.50);
}
.card {
padding: 1em;
padding: var(--spacing-md);
}
#app {
padding: 1rem;
padding: 0;
}
}
@media (min-width: 481px) and (max-width: 768px) {
:root {
/* Slightly reduced sizes for tablets */
--font-size-base: 15px;
--font-size-lg: 17px;
--font-size-xl: 22px;
--font-size-2xl: 28px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
--font-size-2xl: 26px;
--spacing-md: 14px;
--spacing-lg: 20px;
--spacing-xl: 28px;
}
h1 {
font-size: 2.5em;
}
.card {
padding: 1.5em;
padding: var(--spacing-md) var(--spacing-lg);
}
#app {
padding: 1.5rem;
padding: 0;
}
}

View file

@ -1,5 +1,5 @@
/**
* Central Theme System for Project Thoth
* Central Theme System for Kiwi
*
* This file contains all reusable, theme-aware, responsive CSS classes.
* Components should use these classes instead of custom styles where possible.
@ -9,24 +9,42 @@
LAYOUT UTILITIES - RESPONSIVE GRIDS
============================================ */
/* Responsive Grid - Automatically adjusts columns based on screen size */
.grid-responsive {
display: grid;
gap: var(--spacing-md);
}
/* Mobile: 1 column, Tablet: 2 columns, Desktop: 3+ columns */
.grid-auto {
display: grid;
gap: var(--spacing-md);
grid-template-columns: 1fr; /* Default to single column */
grid-template-columns: 1fr;
}
/* Stats grid - always fills available space */
/* Stats grid — horizontal strip of compact stats */
.grid-stats {
display: grid;
gap: var(--spacing-md);
grid-template-columns: 1fr; /* Default to single column */
grid-template-columns: 1fr;
}
.grid-stats-strip {
display: flex;
gap: 0;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.grid-stats-strip .stat-strip-item {
flex: 1;
text-align: center;
padding: var(--spacing-sm) var(--spacing-xs);
border-right: 1px solid var(--color-border);
}
.grid-stats-strip .stat-strip-item:last-child {
border-right: none;
}
/* Force specific column counts */
@ -36,7 +54,7 @@
.grid-4 { grid-template-columns: repeat(4, 1fr); }
/* ============================================
FLEXBOX UTILITIES - RESPONSIVE
FLEXBOX UTILITIES
============================================ */
.flex { display: flex; }
@ -63,7 +81,6 @@
align-items: center;
}
/* Stack on mobile, horizontal on desktop */
.flex-responsive {
display: flex;
gap: var(--spacing-md);
@ -74,14 +91,12 @@
SPACING UTILITIES
============================================ */
/* Gaps */
.gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); }
/* Padding */
.p-0 { padding: 0; }
.p-xs { padding: var(--spacing-xs); }
.p-sm { padding: var(--spacing-sm); }
@ -89,7 +104,6 @@
.p-lg { padding: var(--spacing-lg); }
.p-xl { padding: var(--spacing-xl); }
/* Margin */
.m-0 { margin: 0; }
.m-xs { margin: var(--spacing-xs); }
.m-sm { margin: var(--spacing-sm); }
@ -97,9 +111,14 @@
.m-lg { margin: var(--spacing-lg); }
.m-xl { margin: var(--spacing-xl); }
/* Margin/Padding specific sides */
.mt-xs { margin-top: var(--spacing-xs); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mb-xs { margin-bottom: var(--spacing-xs); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.ml-xs { margin-left: var(--spacing-xs); }
.ml-md { margin-left: var(--spacing-md); }
.mr-md { margin-right: var(--spacing-md); }
@ -115,8 +134,9 @@
.card {
background: var(--color-bg-card);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
padding: var(--spacing-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--color-border);
transition: box-shadow 0.2s ease;
}
@ -129,20 +149,22 @@
border-radius: var(--radius-lg);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.card-secondary {
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
/* Status border variants */
.card-success { border-left: 4px solid var(--color-success); }
.card-warning { border-left: 4px solid var(--color-warning); }
.card-error { border-left: 4px solid var(--color-error); }
.card-info { border-left: 4px solid var(--color-info); }
.card-success { border-left: 3px solid var(--color-success); }
.card-warning { border-left: 3px solid var(--color-warning); }
.card-error { border-left: 3px solid var(--color-error); }
.card-info { border-left: 3px solid var(--color-info); }
/* ============================================
BUTTON COMPONENTS - THEME AWARE
@ -150,13 +172,18 @@
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border: 1px solid transparent;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 600;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.18s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
}
.btn:hover {
@ -168,7 +195,7 @@
}
.btn:disabled {
opacity: 0.5;
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
@ -176,8 +203,14 @@
/* Button variants */
.btn-primary {
background: var(--gradient-primary);
color: white;
color: #1e1c1a;
border: none;
font-weight: 700;
box-shadow: var(--shadow-amber);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(232, 168, 32, 0.35);
}
.btn-success {
@ -208,20 +241,49 @@
}
.btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 2px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-bg-primary);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-secondary.active {
background: var(--gradient-primary);
color: white;
background: var(--color-primary);
color: #1e1c1a;
border-color: var(--color-primary);
font-weight: 700;
}
/* Pill chip button — for filter chips */
.btn-chip {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 500;
font-family: var(--font-body);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
}
.btn-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-chip.active {
background: var(--color-primary);
color: #1e1c1a;
border-color: var(--color-primary);
font-weight: 700;
}
/* Button sizes */
@ -232,7 +294,38 @@
.btn-lg {
padding: var(--spacing-md) var(--spacing-xl);
font-size: var(--font-size-lg);
font-size: var(--font-size-base);
}
/* Icon-only action button */
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-icon:hover {
background: var(--color-bg-primary);
color: var(--color-text-primary);
transform: none;
}
.btn-icon.btn-icon-danger:hover {
color: var(--color-error);
}
.btn-icon.btn-icon-success:hover {
color: var(--color-success);
}
/* ============================================
@ -245,10 +338,13 @@
.form-label {
display: block;
margin-bottom: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
font-weight: 600;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: var(--font-body);
}
.form-input,
@ -261,7 +357,9 @@
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
font-family: var(--font-body);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
box-sizing: border-box;
}
.form-input:focus,
@ -269,22 +367,34 @@
.form-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
box-shadow: 0 0 0 3px var(--color-warning-bg);
}
.form-textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
font-family: var(--font-body);
}
/* Form layouts */
.form-row {
display: grid;
gap: var(--spacing-md);
grid-template-columns: 1fr;
}
/* Chip row filter bar — horizontal scroll */
.filter-chip-row {
display: flex;
gap: var(--spacing-xs);
overflow-x: auto;
padding-bottom: var(--spacing-xs);
scrollbar-width: none;
}
.filter-chip-row::-webkit-scrollbar {
display: none;
}
/* ============================================
TEXT UTILITIES
============================================ */
@ -296,6 +406,17 @@
.text-xl { font-size: var(--font-size-xl); }
.text-2xl { font-size: var(--font-size-2xl); }
/* Display font */
.text-display {
font-family: var(--font-display);
font-style: italic;
}
/* Mono font */
.text-mono {
font-family: var(--font-mono);
}
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); }
@ -304,6 +425,7 @@
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-info { color: var(--color-info); }
.text-amber { color: var(--color-primary); }
.text-center { text-align: center; }
.text-left { text-align: left; }
@ -313,59 +435,76 @@
.font-semibold { font-weight: 600; }
.font-normal { font-weight: 400; }
/* ============================================
LOCATION DOT INDICATORS
============================================ */
.loc-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.loc-dot-fridge { background: var(--color-loc-fridge); }
.loc-dot-freezer { background: var(--color-loc-freezer); }
.loc-dot-garage_freezer { background: var(--color-loc-garage-freezer); }
.loc-dot-pantry { background: var(--color-loc-pantry); }
.loc-dot-cabinet { background: var(--color-loc-cabinet); }
/* Location left-border strip on inventory rows */
.inv-row-fridge { border-left-color: var(--color-loc-fridge) !important; }
.inv-row-freezer { border-left-color: var(--color-loc-freezer) !important; }
.inv-row-garage_freezer { border-left-color: var(--color-loc-garage-freezer) !important; }
.inv-row-pantry { border-left-color: var(--color-loc-pantry) !important; }
.inv-row-cabinet { border-left-color: var(--color-loc-cabinet) !important; }
/* ============================================
RESPONSIVE UTILITIES
============================================ */
/* Show/Hide based on screen size */
.mobile-only { display: none; }
.desktop-only { display: block; }
/* Width utilities */
.w-full { width: 100%; }
.w-auto { width: auto; }
/* Height utilities */
.h-full { height: 100%; }
.h-auto { height: auto; }
/* ============================================
MOBILE BREAKPOINTS (480px)
MOBILE BREAKPOINTS (<=480px)
============================================ */
@media (max-width: 480px) {
/* Show/Hide */
.mobile-only { display: block; }
.desktop-only { display: none; }
/* Grids already default to 1fr, just ensure it stays that way */
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr !important;
}
/* Stack flex items vertically */
.flex-responsive {
flex-direction: column;
}
/* Buttons take full width */
.btn-mobile-full {
width: 100%;
min-width: 100%;
}
/* Reduce card padding on mobile */
.card {
padding: var(--spacing-md);
border-radius: var(--radius-lg);
}
.card-sm {
padding: var(--spacing-sm);
}
/* Allow text wrapping on mobile */
.btn {
white-space: normal;
text-align: center;
@ -377,7 +516,6 @@
============================================ */
@media (min-width: 481px) and (max-width: 768px) {
/* 2-column layouts on tablets */
.grid-3,
.grid-4 {
grid-template-columns: repeat(2, 1fr);
@ -402,11 +540,11 @@
@media (min-width: 769px) and (max-width: 1024px) {
.grid-auto {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
}
.grid-stats {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
}
.grid-4 {
@ -415,16 +553,16 @@
}
/* ============================================
LARGE DESKTOP (1025px)
LARGE DESKTOP (>=1025px)
============================================ */
@media (min-width: 1025px) {
.grid-auto {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.grid-stats {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.form-row {
@ -437,34 +575,37 @@
============================================ */
.status-badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
padding: 3px var(--spacing-sm);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
.status-success {
background: var(--color-success-bg);
color: var(--color-success-dark);
color: var(--color-success-light);
border: 1px solid var(--color-success-border);
}
.status-warning {
background: var(--color-warning-bg);
color: var(--color-warning-dark);
color: var(--color-warning-light);
border: 1px solid var(--color-warning-border);
}
.status-error {
background: var(--color-error-bg);
color: var(--color-error-dark);
color: var(--color-error-light);
border: 1px solid var(--color-error-border);
}
.status-info {
background: var(--color-info-bg);
color: var(--color-info-dark);
color: var(--color-info-light);
border: 1px solid var(--color-info-border);
}
@ -488,7 +629,7 @@
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
transform: translateY(16px);
}
to {
opacity: 1;
@ -496,23 +637,33 @@
}
}
/* Urgency pulse — for items expiring very soon */
@keyframes urgencyPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.pulse-urgent {
animation: urgencyPulse 1.8s ease-in-out infinite;
}
/* ============================================
LOADING UTILITIES
============================================ */
.spinner {
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-primary);
border: 2px solid var(--color-border);
border-top: 2px solid var(--color-primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
width: 36px;
height: 36px;
animation: spin 0.9s linear infinite;
margin: 0 auto;
}
.spinner-sm {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
border-width: 2px;
}
@ -534,3 +685,160 @@
.divider-md {
margin: var(--spacing-md) 0;
}
/* ============================================
SECTION HEADERS (display font)
============================================ */
.section-title {
font-family: var(--font-display);
font-style: italic;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
/* ============================================
EASTER EGG GRID KITCHEN NEON MODE
Activated via Konami code
============================================ */
body.neon-mode .card,
body.neon-mode .card-sm,
body.neon-mode .card-secondary {
box-shadow:
0 0 0 1px rgba(255, 0, 110, 0.35),
0 0 12px rgba(255, 0, 110, 0.18),
0 2px 20px rgba(131, 56, 236, 0.15);
}
body.neon-mode .btn-primary {
box-shadow: 0 0 18px rgba(255, 0, 110, 0.55), 0 0 36px rgba(131, 56, 236, 0.25);
color: #fff;
}
body.neon-mode .wordmark-kiwi {
text-shadow: 0 0 10px rgba(255, 0, 110, 0.7), 0 0 24px rgba(131, 56, 236, 0.5);
}
body.neon-mode .sidebar,
body.neon-mode .bottom-nav {
border-color: rgba(255, 0, 110, 0.3);
box-shadow: 4px 0 20px rgba(255, 0, 110, 0.12);
}
body.neon-mode .sidebar-item.active,
body.neon-mode .nav-item.active {
text-shadow: 0 0 8px currentColor;
}
/* Scanline overlay */
body.neon-mode::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(0, 0, 0, 0.08) 3px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 9998;
animation: scanlineScroll 8s linear infinite;
}
@keyframes scanlineScroll {
0% { background-position: 0 0; }
100% { background-position: 0 80px; }
}
/* CRT flicker on wordmark */
body.neon-mode .wordmark-kiwi {
animation: crtFlicker 6s ease-in-out infinite;
}
@keyframes crtFlicker {
0%, 94%, 100% { opacity: 1; }
95% { opacity: 0.88; }
97% { opacity: 0.95; }
98% { opacity: 0.82; }
}
/* ============================================
EASTER EGG KIWI BIRD SPRITE
============================================ */
.kiwi-bird-stage {
position: fixed;
bottom: 72px; /* above bottom nav */
left: 0;
right: 0;
height: 72px;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
@media (min-width: 769px) {
.kiwi-bird-stage {
bottom: 0;
left: 200px; /* clear the sidebar */
}
}
.kiwi-bird {
position: absolute;
bottom: 8px;
width: 64px;
height: 64px;
will-change: transform;
}
/* Enters from right, walks left */
.kiwi-bird.rtl {
animation: kiwiWalkRtl 5.5s ease-in-out forwards;
}
.kiwi-bird.rtl .kiwi-svg {
transform: scaleX(1); /* faces left */
}
/* Enters from left, walks right */
.kiwi-bird.ltr {
animation: kiwiWalkLtr 5.5s ease-in-out forwards;
}
.kiwi-bird.ltr .kiwi-svg {
transform: scaleX(-1); /* faces right */
}
/* Bob on each step */
.kiwi-svg {
display: block;
animation: kiwiBob 0.38s steps(1) infinite;
}
@keyframes kiwiWalkRtl {
0% { right: -80px; }
15% { right: 35%; } /* enter and slow */
40% { right: 35%; } /* pause — sniffing */
55% { right: 38%; } /* tiny shuffle */
60% { right: 35%; }
85% { right: 35%; }
100% { right: calc(100% + 80px); } /* exit left */
}
@keyframes kiwiWalkLtr {
0% { left: -80px; }
15% { left: 35%; }
40% { left: 35%; }
55% { left: 38%; }
60% { left: 35%; }
85% { left: 35%; }
100% { left: calc(100% + 80px); }
}
@keyframes kiwiBob {
0% { transform: translateY(0) scaleX(var(--bird-flip, 1)); }
50% { transform: translateY(-4px) scaleX(var(--bird-flip, 1)); }
}