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

View file

@ -1,5 +1,39 @@
<template>
<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 -->
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
@ -39,7 +73,7 @@
class="tag-chip status-badge status-info"
>
{{ 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>
</div>
<input
@ -61,7 +95,7 @@
class="tag-chip status-badge status-error"
>
{{ 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>
</div>
<input
@ -84,8 +118,19 @@
</p>
</div>
<!-- Max Missing -->
<!-- Shopping Mode -->
<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>
<input
type="number"
@ -210,6 +255,13 @@
{{ recipesStore.error }}
</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 -->
<div v-if="recipesStore.result" class="results-section fade-in">
<!-- Rate limit warning -->
@ -233,18 +285,55 @@
</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 -->
<div
v-if="recipesStore.result.suggestions.length === 0"
v-if="filteredSuggestions.length === 0"
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>
<!-- Recipe Cards -->
<div class="grid-auto mb-md">
<div
v-for="recipe in recipesStore.result.suggestions"
v-for="recipe in filteredSuggestions"
:key="recipe.id"
class="card slide-up"
>
@ -255,12 +344,17 @@
<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-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
v-if="recipe.id"
class="btn-dismiss"
@click="recipesStore.dismiss(recipe.id)"
title="Hide this recipe"
aria-label="Dismiss recipe"
:aria-label="'Hide recipe: ' + recipe.title"
></button>
</div>
</div>
@ -268,6 +362,18 @@
<!-- Notes -->
<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 -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
@ -358,17 +464,22 @@
</ul>
</div>
<!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary">
Directions ({{ recipe.directions.length }} steps)
</summary>
<!-- Directions always visible; this is the content people came for -->
<div v-if="recipe.directions.length > 0" class="directions-section">
<p class="text-sm font-semibold directions-label">Steps</p>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</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>
@ -386,21 +497,17 @@
</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>
<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>
<!-- 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 -->
<div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
@ -413,6 +520,17 @@
</svg>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</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>
</template>
@ -420,17 +538,111 @@
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
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 { recipesAPI } from '../services/api'
const recipesStore = useRecipesStore()
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
const constraintInput = ref('')
const allergyInput = ref('')
const categoryInput = ref('')
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 = [
{ value: 1, label: '1 — From Pantry' },
{ value: 2, label: '2 — Creative Swaps' },
@ -554,6 +766,16 @@ onMounted(async () => {
</script>
<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 {
margin-bottom: var(--spacing-md);
}
@ -585,6 +807,11 @@ onMounted(async () => {
user-select: none;
}
.shopping-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
@ -643,6 +870,116 @@ onMounted(async () => {
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 {
display: flex;
align-items: center;
@ -721,13 +1058,54 @@ details[open] .collapsible-summary::before {
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 {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
margin-bottom: var(--spacing-sm);
line-height: 1.6;
}
.grocery-link {
@ -740,12 +1118,17 @@ details[open] .collapsible-summary::before {
opacity: 0.8;
}
.grocery-list {
padding-left: var(--spacing-lg);
.card-actions {
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 {
margin-bottom: var(--spacing-xs);
.btn-make {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
}
.results-section {

View file

@ -5,6 +5,37 @@
* 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
============================================ */
@ -639,14 +670,20 @@
}
}
/* 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;
/* ============================================
REDUCED MOTION global guard (WCAG 2.3.3)
All animations/transitions are suppressed when
the user has requested reduced motion. This
single rule covers every animation in this file.
Do NOT add urgency/pulse animations to Kiwi
see design policy in circuitforge-plans.
============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ============================================