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:
parent
1a493e0ad9
commit
1f819c4ee0
10 changed files with 1379 additions and 381 deletions
|
|
@ -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>
|
||||
|
|
|
|||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,66 +1,149 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<header class="app-header">
|
||||
<div class="container">
|
||||
<h1>🥝 Kiwi</h1>
|
||||
<p class="tagline">Smart Pantry Tracking & Recipe Suggestions</p>
|
||||
<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>
|
||||
</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>
|
||||
<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="header-inner">
|
||||
<span class="wordmark-kiwi">Kiwi</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div v-show="currentTab === 'inventory'" class="tab-content">
|
||||
<InventoryList />
|
||||
<main class="app-main">
|
||||
<div class="container">
|
||||
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
||||
<InventoryList />
|
||||
</div>
|
||||
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
||||
<ReceiptsView />
|
||||
</div>
|
||||
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
||||
<RecipesView />
|
||||
</div>
|
||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||
<SettingsView />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === 'receipts'" class="tab-content">
|
||||
<ReceiptsView />
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<div v-show="currentTab === 'recipes'" class="tab-content">
|
||||
<RecipesView />
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === 'settings'" class="tab-content">
|
||||
<SettingsView />
|
||||
<!-- 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>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container">
|
||||
<p>© 2026 CircuitForge LLC</p>
|
||||
</div>
|
||||
</footer>
|
||||
</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;
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 4px 10px;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: color 0.18s ease, background 0.18s ease;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
.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;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-primary);
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
.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;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 24px;
|
||||
/* ---- 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;
|
||||
}
|
||||
|
||||
.app-header .tagline {
|
||||
font-size: 14px;
|
||||
.sidebar-collapsed .sidebar {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
gap: 8px;
|
||||
.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);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -154,17 +154,50 @@
|
|||
</p>
|
||||
</details>
|
||||
|
||||
<!-- 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
|
||||
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 1–2 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, < 30 Mins"
|
||||
@blur="recipesStore.category = categoryInput.trim() || null"
|
||||
@keydown.enter="recipesStore.category = categoryInput.trim() || null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Suggest Button -->
|
||||
<button
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
|
||||
@click="handleSuggest"
|
||||
>
|
||||
<span v-if="recipesStore.loading">
|
||||
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes…
|
||||
</span>
|
||||
<span v-else>Suggest Recipes</span>
|
||||
</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 && !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);
|
||||
}
|
||||
|
|
|
|||
132
frontend/src/composables/useEasterEggs.ts
Normal file
132
frontend/src/composables/useEasterEggs.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
function _trackSeen(suggestions: RecipeSuggestion[]) {
|
||||
for (const s of suggestions) {
|
||||
if (s.id) seenIds.value = new Set([...seenIds.value, s.id])
|
||||
}
|
||||
}
|
||||
|
||||
async function suggest(pantryItems: string[]) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
seenIds.value = new Set()
|
||||
|
||||
try {
|
||||
result.value = await recipesAPI.suggest(req)
|
||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
||||
_trackSeen(result.value.suggestions)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = 'Failed to get recipe suggestions'
|
||||
}
|
||||
console.error('Error fetching recipe suggestions:', err)
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue