diff --git a/frontend/index.html b/frontend/index.html index 2b36112..f95f61d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,13 @@ - frontend + Kiwi — Pantry Tracker + + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8cea527..b27d978 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bc62189..406471c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,66 +1,149 @@ @@ -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('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 | 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; } diff --git a/frontend/src/components/InventoryList.vue b/frontend/src/components/InventoryList.vue index abb3381..ee04c65 100644 --- a/frontend/src/components/InventoryList.vue +++ b/frontend/src/components/InventoryList.vue @@ -281,15 +281,25 @@

Loading pantry…

- -
+ +
-

No items found.

-

Scan a barcode or add manually above.

+

Clean slate.

+

Your pantry is ready for anything — scan a barcode or add an item above.

+
+ + +
+ + + + +

Nothing matches that filter.

+

Try a different location or status.

diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index 68914b4..b3f5676 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -154,17 +154,50 @@

+ +
+ +
+ +
+
+ + +
+ + +
+ - +
+ + +

@@ -218,10 +251,17 @@

{{ recipe.title }}

-
+
{{ recipe.match_count }} matched Level {{ recipe.level }} Wildcard +
@@ -308,6 +348,16 @@
+ +
+

Before you start:

+
    +
  • + {{ note }} +
  • +
+
+
@@ -322,6 +372,20 @@
+ +
+ +
+

Shopping List

@@ -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); } diff --git a/frontend/src/composables/useEasterEggs.ts b/frontend/src/composables/useEasterEggs.ts new file mode 100644 index 0000000..61d4908 --- /dev/null +++ b/frontend/src/composables/useEasterEggs.ts @@ -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 = { + '--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, + } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7713d06..3834695 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -415,6 +415,18 @@ export interface SwapCandidate { compensation_hints: Record[] } +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 { diff --git a/frontend/src/stores/recipes.ts b/frontend/src/stores/recipes.ts index 56ff397..09d1122 100644 --- a/frontend/src/stores/recipes.ts +++ b/frontend/src/stores/recipes.ts @@ -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 { + 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) { + 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(null) const loading = ref(false) const error = ref(null) + + // Request parameters const level = ref(1) const constraints = ref([]) const allergies = ref([]) const hardDayMode = ref(false) const maxMissing = ref(null) const styleId = ref(null) + const category = ref(null) const wildcardConfirmed = ref(false) + const nutritionFilters = ref({ + 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>(loadDismissed()) + // Seen IDs: session-only, used by Load More to avoid repeating results + const seenIds = ref>(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, } }) diff --git a/frontend/src/style.css b/frontend/src/style.css index a5d7160..720a999 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } } diff --git a/frontend/src/theme.css b/frontend/src/theme.css index 5992156..b03d026 100644 --- a/frontend/src/theme.css +++ b/frontend/src/theme.css @@ -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)); } +}