feat(ui): implement InstaChef design system
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s

- Replace Tailwind with IC CSS design tokens (purple/pink/orange brand gradient,
  Lilita One / DM Sans / JetBrains Mono fonts, light+dark theme via data-theme)
- Add all SVG icon components (ic/Bell, BellOff, Check, Chevron, Clipboard,
  Close, Download, External, Filter, Link, Plus, Retry, Search, Settings,
  Share, Spark, Trash, PhasePrepping, PhaseSimmering, PhasePlating)
- Add shared primitives: Chip, RecipeThumb (deterministic gradient swatch),
  CookingPot (animated SVG), PhaseTrack, SectionHead
- Add TopBar with LIVE indicator and notification bell
- Add CookingHero: animated hero card for in-progress items
- Add TimelineRow: queue list row with status badges
- Add EmptyState: gradient hero + dismissible How it works card
- Add RecipeSheet: bottom-sheet detail overlay with phase progress
- Add AddUrlScreen: full-page URL input with clipboard paste
- Add NotificationsScreen: push toggle + SSE status
- Rewrite +page.svelte: screen router (home/addurl/notifs) + RecipeSheet;
  preserves all SSE, retry, remove, filter, auto-subscribe logic
- Rewrite share/+page.svelte: uses AddUrlScreen shell, preserves Share Target
  logic and auto-process on URL param
- Rewrite InstallPrompt.svelte: InstallSheet bottom-sheet design, all PWA logic intact
- Update manifest.json theme_color to #FFF8F5
- 282 unit tests passing (unchanged)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giancarmine Salucci
2026-05-12 22:02:47 +02:00
parent 0b9f598c7d
commit 573cf49ac5
39 changed files with 2982 additions and 569 deletions

View File

@@ -3,42 +3,82 @@
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
import { replaceState } from '$app/navigation';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
import type { NotificationState } from '$lib/client/PushNotificationManager';
import TopBar from './components/TopBar.svelte';
import CookingHero from './components/CookingHero.svelte';
import TimelineRow from './components/TimelineRow.svelte';
import EmptyState from './components/EmptyState.svelte';
import RecipeSheet from './components/RecipeSheet.svelte';
import SectionHead from './components/SectionHead.svelte';
import AddUrlScreen from './components/AddUrlScreen.svelte';
import NotificationsScreen from './components/NotificationsScreen.svelte';
// ── State ──────────────────────────────────────────────────
let items = $state<QueueItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state<string>('all');
let loadError = $state<string | null>(null);
let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all');
let eventSource = $state<EventSource | null>(null);
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
let lastPing = $state<string | null>(null);
let hasAttemptedAutoSubscribe = $state(false);
let notificationViewModel = $state<NotificationState | null>(null);
// Screen router
let screen = $state<'home' | 'addurl' | 'notifications'>('home');
// Recipe detail sheet
let selectedItem = $state<QueueItem | null>(null);
// "How it works" dismissible card
let showHowTo = $state(true);
// Get highlighted item ID from URL params (when redirected from Share page)
let highlightId = $derived($page.url.searchParams.get('highlight'));
const highlightId = $derived($page.url.searchParams.get('highlight'));
// Available filters - derived to be reactive
let filters = $derived([
{ id: 'all', name: 'All Items', count: items.length },
{ id: 'pending', name: 'Pending', count: items.filter(item => item.status === 'pending').length },
{ id: 'in_progress', name: 'Processing', count: items.filter(item => item.status === 'in_progress').length },
{ id: 'success', name: 'Complete', count: items.filter(item => item.status === 'success').length },
{ id: 'error', name: 'Failed', count: items.filter(item => item.status === 'error' || item.status === 'unhealthy').length }
]);
// Filter items based on selected filter
// Using $derived.by to execute the function and derive the result array
let filteredItems = $derived.by(() => {
// ── Derived ────────────────────────────────────────────────
const filteredItems = $derived.by(() => {
if (filter === 'all') return items;
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
return items.filter(item => item.status === filter);
if (filter === 'error') return items.filter((i) => i.status === 'error' || i.status === 'unhealthy');
return items.filter((i) => i.status === filter);
});
const sseLastPing = $derived(
lastPing ? relTime(lastPing) + ' ago' : ''
);
function relTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return Math.round(diff) + 's';
if (diff < 3600) return Math.floor(diff / 60) + 'm';
return Math.floor(diff / 3600) + 'h';
}
// ── groupByDate ────────────────────────────────────────────
type Group = 'Cooking now' | 'In line' | 'Today' | 'Yesterday' | 'Earlier';
function groupByDate(list: QueueItem[]): Record<Group, QueueItem[]> {
const g: Record<Group, QueueItem[]> = {
'Cooking now': [],
'In line': [],
Today: [],
Yesterday: [],
Earlier: []
};
const now = Date.now();
for (const it of list) {
if (it.status === 'in_progress') { g['Cooking now'].push(it); continue; }
if (it.status === 'pending') { g['In line'].push(it); continue; }
const age = (now - new Date(it.createdAt).getTime()) / 1000;
if (age < 86400) g['Today'].push(it);
else if (age < 172800) g['Yesterday'].push(it);
else g['Earlier'].push(it);
}
return g;
}
const groups = $derived(groupByDate(filteredItems));
const cooking = $derived(groups['Cooking now'][0] ?? null);
// ── Lifecycle ──────────────────────────────────────────────
let unsubscribeNotifications: (() => void) | undefined;
onMount(async () => {
@@ -46,384 +86,374 @@
if (browser) {
startSSEConnection();
setupAutoSubscribe();
unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
notificationViewModel = newState;
});
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
// Open RecipeSheet for highlighted item
if (highlightId) {
const found = items.find((i) => i.id === highlightId);
if (found) { selectedItem = found; clearHighlight(); }
}
}
});
onDestroy(() => {
if (eventSource) {
console.log('[SSE] Closing connection on component destroy');
eventSource.close();
connectionStatus = 'disconnected';
}
// Add notification state cleanup
eventSource?.close();
connectionStatus = 'disconnected';
unsubscribeNotifications?.();
});
// ── Data fetching ──────────────────────────────────────────
async function loadQueueItems() {
try {
loading = true;
error = null;
loadError = null;
const response = await fetch('/api/queue');
if (!response.ok) {
throw new Error('Failed to load queue items');
}
if (!response.ok) throw new Error('Failed to load queue items');
const data = await response.json();
items = data.items || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
loadError = e instanceof Error ? e.message : 'Unknown error';
console.error('Failed to load queue items:', e);
} finally {
loading = false;
}
}
function startSSEConnection() {
if (!browser) {
console.error('Cannot start SSE connection on server side');
return; // Guard: EventSource is browser-only API
async function submitUrl(url: string) {
try {
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Failed to enqueue URL');
}
const queueItem = await response.json();
// Item will arrive via SSE, but add immediately for UX
items = [queueItem, ...items];
screen = 'home';
// Show the new item in RecipeSheet
selectedItem = queueItem;
} catch (e) {
console.error('Failed to submit URL:', e);
}
}
// ── SSE ────────────────────────────────────────────────────
function startSSEConnection() {
if (!browser) return;
connectionStatus = 'connecting';
console.log('[SSE] Connecting to queue stream...');
try {
eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('open', () => {
console.log('[SSE] Connection opened');
connectionStatus = 'connected';
});
eventSource.addEventListener('connection', (event) => {
const data = JSON.parse(event.data);
console.log('[SSE] Connection confirmed:', data.message);
connectionStatus = 'connected';
});
eventSource.addEventListener('open', () => { connectionStatus = 'connected'; });
eventSource.addEventListener('connection', () => { connectionStatus = 'connected'; });
eventSource.addEventListener('queue-update', (event) => {
const update: QueueStatusUpdate = JSON.parse(event.data);
updateQueueItem(update);
updateQueueItem(JSON.parse(event.data) as QueueStatusUpdate);
});
eventSource.addEventListener('error', (event) => {
console.error('[SSE] Connection error:', event);
eventSource.addEventListener('error', () => {
connectionStatus = 'disconnected';
// Attempt to reconnect after 5 seconds
setTimeout(() => {
// EventSource.CLOSED = 2 (use numeric constant for SSR safety)
if (eventSource?.readyState === 2) {
console.log('[SSE] Attempting reconnection...');
startSSEConnection();
}
if (eventSource?.readyState === 2) startSSEConnection();
}, 5000);
});
eventSource.addEventListener('ping', (event) => {
// Keep-alive ping, update last ping timestamp
const data = JSON.parse(event.data);
lastPing = data.timestamp;
console.log('[SSE] Keep-alive ping received at:', data.timestamp);
lastPing = JSON.parse(event.data).timestamp;
});
} catch (e) {
console.error('[SSE] Failed to start SSE connection:', e);
console.error('[SSE] Failed to start:', e);
connectionStatus = 'disconnected';
}
}
/**
* Setup automatic notification subscription on first user interaction
*
* Follows Web Push API best practices: subscription requires user gesture.
* Listens for first click/touch anywhere on page, checks if notifications
* are supported but not subscribed, then auto-subscribes.
*/
function setupAutoSubscribe() {
if (hasAttemptedAutoSubscribe) return;
const attemptSubscribe = async () => {
const attempt = async () => {
if (hasAttemptedAutoSubscribe) return;
hasAttemptedAutoSubscribe = true;
const state = pushNotificationManager.getState();
// Only auto-subscribe if:
// - Browser supports notifications
// - Permission is not denied
// - Not already subscribed
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
console.log('[HomePage] Auto-subscribing to notifications on first interaction');
await pushNotificationManager.subscribe();
}
// Remove listener after first attempt
document.removeEventListener('click', attemptSubscribe);
document.removeEventListener('touchstart', attemptSubscribe);
};
// Listen for first user interaction
document.addEventListener('click', attemptSubscribe, { once: true });
document.addEventListener('touchstart', attemptSubscribe, { once: true });
document.addEventListener('click', attempt, { once: true });
document.addEventListener('touchstart', attempt, { once: true });
}
function updateQueueItem(update: QueueStatusUpdate) {
// Find and update the item in the list
const itemIndex = items.findIndex(item => item.id === update.itemId);
if (itemIndex >= 0) {
// Update existing item
items[itemIndex] = {
...items[itemIndex],
const idx = items.findIndex((i) => i.id === update.itemId);
if (idx >= 0) {
items[idx] = {
...items[idx],
status: update.status,
phases: update.progress || items[itemIndex].phases,
results: update.results || items[itemIndex].results,
error: update.error || items[itemIndex].error,
phases: update.progress || items[idx].phases,
results: update.results || items[idx].results,
error: update.error || items[idx].error,
updatedAt: update.timestamp
};
// Keep selectedItem in sync
if (selectedItem?.id === update.itemId) selectedItem = items[idx];
} else {
// New item - fetch full details from API
fetchQueueItem(update.itemId);
}
// Trigger reactivity
items = [...items];
}
async function fetchQueueItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`);
if (response.ok) {
const item = await response.json();
items = [item, ...items]; // Add to top of list
}
if (response.ok) items = [await response.json(), ...items];
} catch (e) {
console.error('Failed to fetch queue item:', e);
}
}
// ── Actions ────────────────────────────────────────────────
async function retryItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}/retry`, {
method: 'POST'
});
const response = await fetch(`/api/queue/${id}/retry`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to retry item');
const err = await response.json();
throw new Error(err.message || 'Failed to retry');
}
// Item will be updated via SSE
console.log('Retry initiated for item:', id);
} catch (e) {
console.error('Failed to retry item:', e);
// Could show a toast notification here
}
}
async function removeItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to remove item');
}
// Item will be removed from local state via SSE update
// but remove immediately for better UX
items = items.filter(item => item.id !== id);
console.log('Item removed successfully:', id);
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
} catch (e) {
console.error('Failed to remove item:', e);
// Fallback: remove from local state anyway
items = items.filter(item => item.id !== id);
} finally {
items = items.filter((i) => i.id !== id);
if (selectedItem?.id === id) selectedItem = null;
}
}
function clearHighlight() {
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
replaceState(url, {});
}
// Queue positions for pending items
function queuePos(item: QueueItem): number {
return items.filter((i) => i.status === 'pending').indexOf(item) + 1;
}
</script>
<svelte:head>
<title>InstaRecipe Queue Dashboard</title>
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
<title>InstaChef</title>
<meta name="description" content="Cook anything from an Instagram link." />
</svelte:head>
<div class="mx-auto p-6 max-w-6xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
</div>
<div class="app-root">
<!-- ── Home screen ────────────────────────────────────────── -->
{#if screen === 'home'}
<div class="ic-scroll home-scroll">
<TopBar
count={items.length}
notifCount={0}
onNotifications={() => (screen = 'notifications')}
/>
<!-- Action Bar -->
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<div class="flex items-center gap-4 w-full sm:w-auto">
<!-- Filter Dropdown -->
<div class="flex items-center gap-2">
<label for="filter-select" class="text-sm font-medium text-gray-700">Filter:</label>
<select
id="filter-select"
bind:value={filter}
class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
{#each filters as filterOption}
<option value={filterOption.id}>
{filterOption.name} ({filterOption.count})
</option>
{/each}
</select>
</div>
<!-- Refresh Button (moved to same row) -->
<button
onclick={loadQueueItems}
disabled={loading}
title="Refresh queue"
aria-label="Refresh queue"
class="flex items-center p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<svg class="w-5 h-5 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
<!-- Add Recipe Button (icon-only, visible when items exist) -->
{#if items.length > 0}
<a
href="/share"
title="Add recipe URL"
aria-label="Add recipe URL"
class="inline-flex items-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
{/if}
</div>
<!-- Loading State -->
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading queue items...</span>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<span class="text-red-800">Error loading queue: {error}</span>
</div>
</div>
{/if}
<!-- Queue Items -->
{#if !loading && filteredItems.length === 0}
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No queue items</h3>
<p class="text-gray-600 mb-6">
{#if filter === 'all'}
Start by sharing an Instagram recipe or adding a URL manually
{:else}
No items match the selected filter
{/if}
</p>
<a
href="/share"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Recipe URL
</a>
</div>
{:else}
<div class="space-y-4">
{#each filteredItems as item (item.id)}
<QueueItemCard
{item}
highlighted={item.id === highlightId}
onRetry={() => retryItem(item.id)}
onRemove={() => removeItem(item.id)}
onClearHighlight={clearHighlight}
{#if loading}
<div class="loading-wrap">
<div class="spinner"></div>
</div>
{:else if loadError}
<div class="err-banner">Failed to load queue: {loadError}</div>
{:else if items.length === 0}
<EmptyState
onAdd={() => (screen = 'addurl')}
{showHowTo}
onDismissHowTo={() => (showHowTo = false)}
/>
{/each}
{:else}
<!-- Cooking hero -->
{#if cooking && filter !== 'success' && filter !== 'error'}
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
{/if}
<!-- Filter chips -->
<div class="ic-scroll filter-row">
{#each [
{ id: 'all', label: 'All', count: items.length },
{ id: 'in_progress', label: 'Cooking', count: items.filter((i) => i.status === 'in_progress').length },
{ id: 'success', label: 'Saved', count: items.filter((i) => i.status === 'success').length },
{ id: 'error', label: 'Failed', count: items.filter((i) => i.status === 'error' || i.status === 'unhealthy').length }
] as f}
<button
class="ic-btn-reset filter-chip"
class:active={filter === f.id}
onclick={() => (filter = f.id as typeof filter)}
>
{f.label}
<span class="chip-count">{f.count}</span>
</button>
{/each}
</div>
<!-- Timeline groups -->
<div class="timeline">
{#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g}
{#if groups[g]?.length}
<SectionHead>{g}</SectionHead>
{#each groups[g] as it (it.id)}
<TimelineRow
item={it}
queuePosition={queuePos(it)}
onTap={() => (selectedItem = it)}
onRetry={retryItem}
/>
{/each}
{/if}
{/each}
</div>
{/if}
<!-- Sticky add button -->
<div class="add-fab-wrap">
<button class="ic-btn-reset add-fab" onclick={() => (screen = 'addurl')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
<path d="M9 4v9a4 4 0 004 4h8M9 8h4M5 20h8" />
</svg>
Paste Instagram link
</button>
</div>
</div>
<!-- ── Add URL screen ────────────────────────────────────── -->
{:else if screen === 'addurl'}
<div class="ic-scroll screen-scroll">
<AddUrlScreen
onBack={() => (screen = 'home')}
onSubmit={submitUrl}
/>
</div>
<!-- ── Notifications screen ──────────────────────────────── -->
{:else if screen === 'notifications'}
<div class="ic-scroll screen-scroll">
<NotificationsScreen
onBack={() => (screen = 'home')}
sseConnected={connectionStatus === 'connected'}
{sseLastPing}
/>
</div>
{/if}
<!-- Notification Settings - Always visible -->
<div class="mt-8" data-notification-settings>
<NotificationSettings />
</div>
<!-- Footer Status Bar (icons only) -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
<div class="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
<!-- Notification Status Icon (left) -->
<button
onclick={() => {
// Scroll to NotificationSettings component
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
}}
title={notificationViewModel?.subscribed ? 'Notifications enabled' : notificationViewModel?.supported ? 'Notifications disabled' : 'Notifications not supported'}
aria-label="Notification status"
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
{#if !notificationViewModel?.supported || notificationViewModel?.permission === 'denied'}
<!-- Not supported / denied - bell with slash -->
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
</svg>
{:else if notificationViewModel?.subscribed}
<!-- Enabled - bell icon (green) -->
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
</svg>
{:else}
<!-- Disabled - bell icon (gray) -->
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
</svg>
{/if}
</button>
<!-- Live Update Indicator (right) -->
<div
title={connectionStatus === 'connected' ? 'Live updates active' : connectionStatus === 'connecting' ? 'Connecting to live updates...' : 'Live updates disconnected'}
aria-label="Live update status"
class="flex items-center space-x-2"
>
<div class="w-2 h-2 rounded-full {
connectionStatus === 'connected' ? 'bg-green-600' :
connectionStatus === 'connecting' ? 'bg-yellow-600' :
'bg-red-600'
}"></div>
</div>
</div>
</div>
<!-- ── Recipe sheet overlay ─────────────────────────────── -->
{#if selectedItem}
<RecipeSheet
item={selectedItem}
onClose={() => (selectedItem = null)}
onRetry={(id) => { retryItem(id); selectedItem = null; }}
/>
{/if}
</div>
<style>
.app-root {
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
}
.home-scroll,
.screen-scroll {
height: 100vh;
height: 100dvh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.loading-wrap {
display: flex;
justify-content: center;
padding: 60px 0;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: var(--pink);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.err-banner {
margin: 20px 16px;
padding: 14px 16px;
border-radius: 16px;
background: #ffe9e9;
border: 1px solid #f8c2c2;
color: #7e1717;
font-size: 14px;
}
.filter-row {
display: flex;
gap: 8px;
padding: 20px 20px 6px;
overflow-x: auto;
}
.filter-chip {
padding: 8px 14px;
border-radius: 99px;
background: var(--surface-2);
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
transition: background 0.15s, color 0.15s;
}
.filter-chip.active {
background: var(--ink);
color: var(--bg);
border-color: var(--ink);
}
.chip-count {
font-size: 11px;
font-family: var(--font-mono);
opacity: 0.6;
}
.filter-chip.active .chip-count {
opacity: 0.7;
}
.timeline {
padding-bottom: 100px;
}
.add-fab-wrap {
position: sticky;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px 34px;
background: linear-gradient(to top, var(--bg) 55%, color-mix(in srgb, var(--bg) 80%, transparent));
pointer-events: none;
z-index: 40;
}
.add-fab {
width: 100%;
pointer-events: auto;
background: var(--brand-gradient);
color: #fff;
padding: 16px 20px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
box-shadow: var(--shadow-lg);
}
</style>