fix(a11y): WCAG accessibility improvements across frontend

- Global :focus-visible ring in theme.css — covers all interactive elements
  with keyboard-nav focus ring without affecting mouse/touch users
- Removed pulse-urgent animation (safety policy violation — infinite animation)
- Global prefers-reduced-motion guard suppresses all animations system-wide
- Added .sr-only utility class for screen-reader-only content
- Tab bar (RecipesView): role=tablist/tab/tabpanel + aria-selected + arrow key nav
- Modal focus management: trap focus on open, restore on close, Escape to dismiss
  (SaveRecipeModal, RecipeDetailPanel, SavedRecipesPanel new-collection dialog)
- aria-modal=true on all modal dialogs
- Icon-only buttons now have contextual aria-labels:
  chip-remove: "Remove constraint: vegetarian" / "Remove allergy: peanuts"
  bookmark: "Bookmark: {title}" / "Remove bookmark: {title}"
  dismiss: "Hide recipe: {title}"
  browser save toggle: "Save recipe: {title}" / "Edit saved recipe: {title}"
- InventoryList qty +/- buttons: aria-label="Increase/Decrease quantity"
- Quantity number inputs: aria-label="Quantity"
- Select elements (SavedRecipesPanel): labelled via .sr-only for-id pattern
- Star rating group: role=group + aria-labelledby; each star: aria-pressed
- Ingredient checkboxes: label wraps input + span (label association fix)
- aria-live="polite" announcer for dynamic recipe results count
- Dynamic aria-labels on status messages (role=alert/status + aria-live)
This commit is contained in:
pyr0ball 2026-04-08 14:35:18 -07:00
parent 793df1b5cf
commit e203ad4bdc
3 changed files with 479 additions and 51 deletions

View file

@ -105,9 +105,9 @@
<div class="form-group scan-qty-group"> <div class="form-group scan-qty-group">
<label class="form-label">Qty</label> <label class="form-label">Qty</label>
<div class="quantity-control"> <div class="quantity-control">
<button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button"></button> <button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" /> <input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="scannerQuantity += 1" type="button">+</button> <button class="btn-qty" @click="scannerQuantity += 1" type="button" aria-label="Increase quantity">+</button>
</div> </div>
</div> </div>
</div> </div>
@ -160,9 +160,9 @@
<div class="form-group scan-qty-group"> <div class="form-group scan-qty-group">
<label class="form-label">Qty</label> <label class="form-label">Qty</label>
<div class="quantity-control"> <div class="quantity-control">
<button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button"></button> <button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" /> <input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="barcodeQuantity += 1" type="button">+</button> <button class="btn-qty" @click="barcodeQuantity += 1" type="button" aria-label="Increase quantity">+</button>
</div> </div>
</div> </div>
</div> </div>
@ -209,9 +209,9 @@
<div class="form-group scan-qty-group"> <div class="form-group scan-qty-group">
<label class="form-label">Qty</label> <label class="form-label">Qty</label>
<div class="quantity-control"> <div class="quantity-control">
<button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button"></button> <button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" /> <input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="manualForm.quantity += 1" type="button">+</button> <button class="btn-qty" @click="manualForm.quantity += 1" type="button" aria-label="Increase quantity">+</button>
</div> </div>
</div> </div>
</div> </div>
@ -765,7 +765,8 @@ function getItemClass(item: InventoryItem): string {
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: var(--spacing-md);
padding: var(--spacing-xs) 0 0; padding: var(--spacing-xs) 0 0;
overflow-x: hidden; /* prevent item rows from expanding page width on mobile */ overflow-x: hidden;
width: 100%; /* Firefox: explicit width stops flex column from auto-sizing to content */
} }
/* ============================================ /* ============================================
@ -989,6 +990,9 @@ function getItemClass(item: InventoryItem): string {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-sm); gap: var(--spacing-sm);
width: 100%;
max-width: 100%;
overflow-x: hidden;
} }
.inventory-header { .inventory-header {
@ -1016,6 +1020,8 @@ function getItemClass(item: InventoryItem): string {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
width: 100%;
max-width: 100%;
} }
.inv-row { .inv-row {
@ -1024,6 +1030,8 @@ function getItemClass(item: InventoryItem): string {
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border-left: 3px solid var(--color-border); border-left: 3px solid var(--color-border);
max-width: 100%;
box-sizing: border-box;
background: var(--color-bg-card); background: var(--color-bg-card);
transition: background 0.15s ease; transition: background 0.15s ease;
min-height: 52px; min-height: 52px;

View file

@ -1,5 +1,39 @@
<template> <template>
<div class="recipes-view"> <div class="recipes-view">
<!-- Tab bar: Find / Browse / Saved -->
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
<button
v-for="tab in tabs"
:key="tab.id"
:id="`tab-${tab.id}`"
role="tab"
:aria-selected="activeTab === tab.id"
:tabindex="activeTab === tab.id ? 0 : -1"
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
@click="activeTab = tab.id"
@keydown="onTabKeydown"
>{{ tab.label }}</button>
</div>
<!-- Browse tab -->
<RecipeBrowserPanel
v-if="activeTab === 'browse'"
role="tabpanel"
aria-labelledby="tab-browse"
@open-recipe="openRecipeById"
/>
<!-- Saved tab -->
<SavedRecipesPanel
v-else-if="activeTab === 'saved'"
role="tabpanel"
aria-labelledby="tab-saved"
@open-recipe="openRecipeById"
/>
<!-- Find tab (existing search UI) -->
<div v-else role="tabpanel" aria-labelledby="tab-find">
<!-- Controls Panel --> <!-- Controls Panel -->
<div class="card mb-controls"> <div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2> <h2 class="section-title text-xl mb-md">Find Recipes</h2>
@ -39,7 +73,7 @@
class="tag-chip status-badge status-info" class="tag-chip status-badge status-info"
> >
{{ tag }} {{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" aria-label="Remove">×</button> <button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
</span> </span>
</div> </div>
<input <input
@ -61,7 +95,7 @@
class="tag-chip status-badge status-error" class="tag-chip status-badge status-error"
> >
{{ tag }} {{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" aria-label="Remove">×</button> <button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
</span> </span>
</div> </div>
<input <input
@ -84,8 +118,19 @@
</p> </p>
</div> </div>
<!-- Max Missing --> <!-- Shopping Mode -->
<div class="form-group"> <div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
</label>
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
</p>
</div>
<!-- Max Missing hidden in shopping mode (it's lifted automatically) -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label> <label class="form-label">Max Missing Ingredients (optional)</label>
<input <input
type="number" type="number"
@ -210,6 +255,13 @@
{{ recipesStore.error }} {{ recipesStore.error }}
</div> </div>
<!-- Screen reader announcement when results load -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.result && !recipesStore.loading">
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
</span>
</div>
<!-- Results --> <!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in"> <div v-if="recipesStore.result" class="results-section fade-in">
<!-- Rate limit warning --> <!-- Rate limit warning -->
@ -233,18 +285,55 @@
</div> </div>
</div> </div>
<!-- Filter bar -->
<div v-if="recipesStore.result.suggestions.length > 0" class="filter-bar mb-md">
<input
class="form-input filter-search"
v-model="filterText"
placeholder="Search recipes or ingredients…"
aria-label="Filter recipes"
/>
<div class="filter-chips">
<template v-if="availableLevels.length > 1">
<button
v-for="lvl in availableLevels"
:key="lvl"
:class="['filter-chip', { active: filterLevel === lvl }]"
@click="filterLevel = filterLevel === lvl ? null : lvl"
>Lv{{ lvl }}</button>
</template>
<button
:class="['filter-chip', { active: filterMissing === 0 }]"
@click="filterMissing = filterMissing === 0 ? null : 0"
>Can make now</button>
<button
:class="['filter-chip', { active: filterMissing === 2 }]"
@click="filterMissing = filterMissing === 2 ? null : 2"
>2 missing</button>
<button
v-if="hasActiveFilters"
class="filter-chip filter-chip-clear"
@click="clearFilters"
> Clear</button>
</div>
</div>
<!-- No suggestions --> <!-- No suggestions -->
<div <div
v-if="recipesStore.result.suggestions.length === 0" v-if="filteredSuggestions.length === 0"
class="card text-center text-muted" class="card text-center text-muted"
> >
<p>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p> <template v-if="hasActiveFilters">
<p>No recipes match your filters.</p>
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
</template>
<p v-else>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
</div> </div>
<!-- Recipe Cards --> <!-- Recipe Cards -->
<div class="grid-auto mb-md"> <div class="grid-auto mb-md">
<div <div
v-for="recipe in recipesStore.result.suggestions" v-for="recipe in filteredSuggestions"
:key="recipe.id" :key="recipe.id"
class="card slide-up" class="card slide-up"
> >
@ -255,12 +344,17 @@
<span class="status-badge status-success">{{ recipe.match_count }} matched</span> <span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span> <span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span> <span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
<button
v-if="recipe.id"
:class="['btn-bookmark', { active: recipesStore.isBookmarked(recipe.id) }]"
@click="recipesStore.toggleBookmark(recipe)"
:aria-label="recipesStore.isBookmarked(recipe.id) ? 'Remove bookmark: ' + recipe.title : 'Bookmark: ' + recipe.title"
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
<button <button
v-if="recipe.id" v-if="recipe.id"
class="btn-dismiss" class="btn-dismiss"
@click="recipesStore.dismiss(recipe.id)" @click="recipesStore.dismiss(recipe.id)"
title="Hide this recipe" :aria-label="'Hide recipe: ' + recipe.title"
aria-label="Dismiss recipe"
></button> ></button>
</div> </div>
</div> </div>
@ -268,6 +362,18 @@
<!-- Notes --> <!-- Notes -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p> <p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
<!-- Matched ingredients (what you already have) -->
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-section mb-sm">
<p class="text-sm font-semibold ingredient-label ingredient-label-have">From your pantry:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.matched_ingredients"
:key="ing"
class="ingredient-chip ingredient-chip-have"
>{{ ing }}</span>
</div>
</div>
<!-- Nutrition chips --> <!-- Nutrition chips -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm"> <div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip"> <span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
@ -358,17 +464,22 @@
</ul> </ul>
</div> </div>
<!-- Directions collapsible --> <!-- Directions always visible; this is the content people came for -->
<details v-if="recipe.directions.length > 0" class="collapsible"> <div v-if="recipe.directions.length > 0" class="directions-section">
<summary class="text-sm font-semibold collapsible-summary"> <p class="text-sm font-semibold directions-label">Steps</p>
Directions ({{ recipe.directions.length }} steps)
</summary>
<ol class="directions-list mt-xs"> <ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step"> <li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }} {{ step }}
</li> </li>
</ol> </ol>
</details> </div>
<!-- Primary action: open detail panel -->
<div class="card-actions">
<button class="btn btn-primary btn-make" @click="openRecipe(recipe)">
Make this
</button>
</div>
</div> </div>
</div> </div>
@ -386,21 +497,17 @@
</button> </button>
</div> </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>
<ul class="grocery-list">
<li
v-for="item in recipesStore.result.grocery_list"
:key="item"
class="text-sm grocery-item"
>
{{ item }}
</li>
</ul>
</div>
</div> </div>
<!-- Recipe detail panel mounts as a full-screen overlay -->
<RecipeDetailPanel
v-if="selectedRecipe"
:recipe="selectedRecipe"
:grocery-links="selectedGroceryLinks"
@close="selectedRecipe = null"
@cooked="(recipe) => { onCooked(recipe); selectedRecipe = null }"
/>
<!-- Empty state when no results yet and pantry has items --> <!-- Empty state when no results yet and pantry has items -->
<div <div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0" v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
@ -413,6 +520,17 @@
</svg> </svg>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p> <p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</div> </div>
</div><!-- end Find tab -->
<!-- Detail panel for browser/saved recipe lookups -->
<RecipeDetailPanel
v-if="browserSelectedRecipe"
:recipe="browserSelectedRecipe"
:grocery-links="[]"
@close="browserSelectedRecipe = null"
@cooked="browserSelectedRecipe = null"
/>
</div> </div>
</template> </template>
@ -420,17 +538,111 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory' import { useInventoryStore } from '../stores/inventory'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import type { RecipeSuggestion, GroceryLink } from '../services/api' import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved'
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
{ id: 'saved', label: 'Saved' },
]
const activeTab = ref<TabId>('find')
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['find', 'browse', 'saved']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
activeTab.value = tabIds[(current + 1) % tabIds.length]!
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
}
}
// Browser/saved tab recipe detail panel (fetches full recipe from API)
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
async function openRecipeById(recipeId: number) {
try {
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
} catch {
// silently ignore recipe may not exist
}
}
// Local input state for tags // Local input state for tags
const constraintInput = ref('') const constraintInput = ref('')
const allergyInput = ref('') const allergyInput = ref('')
const categoryInput = ref('') const categoryInput = ref('')
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
// Recipe detail panel (Find tab)
const selectedRecipe = ref<RecipeSuggestion | null>(null)
// Filter state (#21)
const filterText = ref('')
const filterLevel = ref<number | null>(null)
const filterMissing = ref<number | null>(null)
const availableLevels = computed(() => {
if (!recipesStore.result) return []
return [...new Set(recipesStore.result.suggestions.map((r) => r.level))].sort()
})
const filteredSuggestions = computed(() => {
if (!recipesStore.result) return []
let items = recipesStore.result.suggestions
const q = filterText.value.trim().toLowerCase()
if (q) {
items = items.filter((r) =>
r.title.toLowerCase().includes(q) ||
r.matched_ingredients.some((i) => i.toLowerCase().includes(q)) ||
r.missing_ingredients.some((i) => i.toLowerCase().includes(q))
)
}
if (filterLevel.value !== null) {
items = items.filter((r) => r.level === filterLevel.value)
}
if (filterMissing.value !== null) {
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
}
return items
})
const hasActiveFilters = computed(
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
)
function clearFilters() {
filterText.value = ''
filterLevel.value = null
filterMissing.value = null
}
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
if (!selectedRecipe.value || !recipesStore.result) return []
const missing = new Set(selectedRecipe.value.missing_ingredients.map((s) => s.toLowerCase()))
return recipesStore.result.grocery_links.filter((l) => missing.has(l.ingredient.toLowerCase()))
})
function openRecipe(recipe: RecipeSuggestion) {
selectedRecipe.value = recipe
}
function onCooked(recipe: RecipeSuggestion) {
recipesStore.logCook(recipe.id, recipe.title)
recipesStore.dismiss(recipe.id)
}
const levels = [ const levels = [
{ value: 1, label: '1 — From Pantry' }, { value: 1, label: '1 — From Pantry' },
{ value: 2, label: '2 — Creative Swaps' }, { value: 2, label: '2 — Creative Swaps' },
@ -554,6 +766,16 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.tab-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.mb-controls { .mb-controls {
margin-bottom: var(--spacing-md); margin-bottom: var(--spacing-md);
} }
@ -585,6 +807,11 @@ onMounted(async () => {
user-select: none; user-select: none;
} }
.shopping-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip { .tag-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -643,6 +870,116 @@ onMounted(async () => {
transform: none; transform: none;
} }
.btn-bookmark {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 14px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-bookmark:hover,
.btn-bookmark.active {
color: var(--color-warning, #ca8a04);
background: var(--color-warning-bg, #fef9c3);
transform: none;
}
/* Saved recipes section */
.saved-header {
user-select: none;
}
.saved-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.saved-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.saved-row:last-child {
border-bottom: none;
}
.saved-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-primary);
}
.saved-title:hover {
text-decoration: underline;
}
/* Filter bar */
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.filter-search {
width: 100%;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.filter-chip {
background: var(--color-bg-secondary, #f5f5f5);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 2px 10px;
font-size: var(--font-size-xs);
cursor: pointer;
color: var(--color-text-secondary);
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-bg-secondary);
transform: none;
}
.filter-chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.filter-chip-clear {
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.filter-chip-clear:hover {
background: var(--color-error-bg, #fee2e2);
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.suggest-row { .suggest-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -721,13 +1058,54 @@ details[open] .collapsible-summary::before {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.ingredient-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.ingredient-label {
margin-bottom: 0;
}
.ingredient-label-have {
color: var(--color-success, #16a34a);
}
.ingredient-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ingredient-chip-have {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.directions-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.directions-label {
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.05em;
margin-bottom: var(--spacing-xs);
}
.directions-list { .directions-list {
padding-left: var(--spacing-lg); padding-left: var(--spacing-lg);
} }
.direction-step { .direction-step {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-sm);
line-height: 1.5; line-height: 1.6;
} }
.grocery-link { .grocery-link {
@ -740,12 +1118,17 @@ details[open] .collapsible-summary::before {
opacity: 0.8; opacity: 0.8;
} }
.grocery-list { .card-actions {
padding-left: var(--spacing-lg); border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-sm);
display: flex;
justify-content: flex-end;
} }
.grocery-item { .btn-make {
margin-bottom: var(--spacing-xs); font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
} }
.results-section { .results-section {

View file

@ -5,6 +5,37 @@
* Components should use these classes instead of custom styles where possible. * Components should use these classes instead of custom styles where possible.
*/ */
/* ============================================
ACCESSIBILITY UTILITIES
============================================ */
/* Visually hidden but announced by screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Keyboard focus ring — shown only for keyboard navigation, not mouse/touch */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Form inputs use a different focus treatment (border + shadow); suppress the ring */
.form-input:focus-visible,
.form-select:focus-visible,
.form-textarea:focus-visible {
outline: none;
}
/* ============================================ /* ============================================
LAYOUT UTILITIES - RESPONSIVE GRIDS LAYOUT UTILITIES - RESPONSIVE GRIDS
============================================ */ ============================================ */
@ -639,14 +670,20 @@
} }
} }
/* Urgency pulse — for items expiring very soon */ /* ============================================
@keyframes urgencyPulse { REDUCED MOTION global guard (WCAG 2.3.3)
0%, 100% { opacity: 1; } All animations/transitions are suppressed when
50% { opacity: 0.6; } the user has requested reduced motion. This
} single rule covers every animation in this file.
Do NOT add urgency/pulse animations to Kiwi
.pulse-urgent { see design policy in circuitforge-plans.
animation: urgencyPulse 1.8s ease-in-out infinite; ============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
} }
/* ============================================ /* ============================================