feat: brain frontend — Atelier-style page with capture, search, feed, detail sheet

- AtelierBrainPage.svelte with full CRUD UI
- Capture bar: paste URL or type note, saves instantly
- Folder signal cards with counts
- Hybrid search (keyword + semantic)
- Item feed with metadata, tags, confidence indicators
- Detail slide-over sheet with summary, metadata, actions
- Added to AppShell nav, legacy Navbar, and layout allApps
- Route at /brain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 17:18:36 -05:00
parent 2072c359aa
commit 477188f542
6 changed files with 1079 additions and 333 deletions

View File

@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
BookOpen, BookOpen,
Brain,
CalendarDays, CalendarDays,
CircleDot, CircleDot,
Compass, Compass,
@@ -39,6 +40,7 @@
{ id: 'inventory', href: '/inventory', label: 'Inventory', icon: Package2 }, { id: 'inventory', href: '/inventory', label: 'Inventory', icon: Package2 },
{ id: 'reader', href: '/reader', label: 'Reader', icon: BookOpen }, { id: 'reader', href: '/reader', label: 'Reader', icon: BookOpen },
{ id: 'media', href: '/media', label: 'Media', icon: LibraryBig }, { id: 'media', href: '/media', label: 'Media', icon: LibraryBig },
{ id: 'brain', href: '/brain', label: 'Brain', icon: Brain },
{ id: 'settings', href: '/settings', label: 'Settings', icon: Settings2 } { id: 'settings', href: '/settings', label: 'Settings', icon: Settings2 }
]; ];

View File

@@ -63,6 +63,9 @@
{#if showApp('media')} {#if showApp('media')}
<a href="/media" class="navbar-link" class:active={isActive('/media')}>Media</a> <a href="/media" class="navbar-link" class:active={isActive('/media')}>Media</a>
{/if} {/if}
{#if showApp('brain')}
<a href="/brain" class="navbar-link" class:active={isActive('/brain')}>Brain</a>
{/if}
</div> </div>
<button class="search-trigger" onclick={onOpenCommand}> <button class="search-trigger" onclick={onOpenCommand}>

View File

@@ -0,0 +1,601 @@
<script lang="ts">
import { onMount } from 'svelte';
interface BrainItem {
id: string;
type: string;
title: string | null;
url: string | null;
folder: string | null;
tags: string[] | null;
summary: string | null;
confidence: number | null;
processing_status: string;
processing_error: string | null;
metadata_json: any;
created_at: string;
updated_at: string;
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
}
let loading = $state(true);
let items = $state<BrainItem[]>([]);
let total = $state(0);
let activeFolder = $state<string | null>(null);
let searchQuery = $state('');
let searching = $state(false);
let folders = $state<string[]>([]);
let tags = $state<string[]>([]);
// Capture
let captureInput = $state('');
let capturing = $state(false);
// Detail
let selectedItem = $state<BrainItem | null>(null);
// Folder counts
let folderCounts = $state<Record<string, number>>({});
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function loadConfig() {
try {
const data = await api('/config');
folders = data.folders || [];
tags = data.tags || [];
} catch { /* silent */ }
}
async function loadItems() {
loading = true;
try {
const params = new URLSearchParams({ limit: '50' });
if (activeFolder) params.set('folder', activeFolder);
const data = await api(`/items?${params}`);
items = data.items || [];
total = data.total || 0;
// Count items per folder
const counts: Record<string, number> = {};
for (const item of items) {
const f = item.folder || 'Uncategorized';
counts[f] = (counts[f] || 0) + 1;
}
folderCounts = counts;
} catch { /* silent */ }
loading = false;
}
async function capture() {
if (!captureInput.trim()) return;
capturing = true;
try {
const isUrl = captureInput.trim().match(/^https?:\/\//);
const body: any = isUrl
? { type: 'link', url: captureInput.trim() }
: { type: 'note', raw_content: captureInput.trim() };
await api('/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
captureInput = '';
await loadItems();
} catch { /* silent */ }
capturing = false;
}
async function search() {
if (!searchQuery.trim()) {
await loadItems();
return;
}
searching = true;
try {
const data = await api('/search/hybrid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: searchQuery, folder: activeFolder, limit: 30 }),
});
items = data.items || [];
total = data.total || 0;
} catch { /* silent */ }
searching = false;
}
async function deleteItem(id: string) {
try {
await api(`/items/${id}`, { method: 'DELETE' });
selectedItem = null;
await loadItems();
} catch { /* silent */ }
}
async function reprocessItem(id: string) {
try {
await api(`/items/${id}/reprocess`, { method: 'POST' });
await loadItems();
} catch { /* silent */ }
}
function formatDate(d: string): string {
try {
const date = new Date(d);
const now = new Date();
const diff = Math.round((now.getTime() - date.getTime()) / 86400000);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yesterday';
if (diff < 7) return `${diff}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch { return ''; }
}
function getScreenshot(item: BrainItem): string | null {
const asset = item.assets?.find(a => a.asset_type === 'screenshot');
return asset ? `/api/brain/storage/${item.id}/screenshot/${asset.filename}` : null;
}
function typeIcon(type: string): string {
switch (type) {
case 'link': return '🔗';
case 'note': return '📝';
case 'pdf': return '📄';
case 'image': return '🖼';
default: return '📎';
}
}
function handleCaptureKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); capture(); }
}
function handleSearchKey(e: KeyboardEvent) {
if (e.key === 'Enter') search();
}
onMount(async () => {
await loadConfig();
await loadItems();
});
</script>
<div class="page">
<div class="app-surface">
<!-- ═══ Hero ═══ -->
<section class="brain-command reveal">
<div class="command-copy">
<div class="command-label">Second brain</div>
<h1>Brain</h1>
<p>Save anything. Links, notes, files. AI classifies everything automatically so you can find it later without thinking about where to put it.</p>
</div>
<div class="command-actions">
<div class="command-kicker">Collection</div>
<div class="command-stats">{total} saved · {Object.keys(folderCounts).length} folders</div>
</div>
</section>
<!-- ═══ Capture bar ═══ -->
<section class="capture-card reveal">
<div class="capture-wrap">
<svg class="capture-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<input
class="capture-input"
placeholder="Paste a URL or type a note to save..."
bind:value={captureInput}
onkeydown={handleCaptureKey}
disabled={capturing}
/>
{#if captureInput.trim()}
<button class="capture-btn" onclick={capture} disabled={capturing}>
{capturing ? 'Saving...' : 'Save'}
</button>
{/if}
</div>
</section>
<!-- ═══ Folder signal strip ═══ -->
<section class="signal-strip stagger">
<button class="signal-card" class:active={activeFolder === null} onclick={() => { activeFolder = null; loadItems(); }}>
<div class="signal-topline">
<div class="signal-label">All</div>
<div class="signal-value">{total}</div>
</div>
<div class="signal-note">Everything saved across all folders.</div>
</button>
{#each folders.slice(0, 5) as folder}
<button class="signal-card" class:active={activeFolder === folder} onclick={() => { activeFolder = folder; loadItems(); }}>
<div class="signal-topline">
<div class="signal-label">{folder}</div>
<div class="signal-value">{folderCounts[folder] || 0}</div>
</div>
<div class="signal-note">Items classified under {folder.toLowerCase()}.</div>
</button>
{/each}
</section>
<!-- ═══ Search + Item feed ═══ -->
<section class="brain-layout">
<div class="brain-main">
<div class="toolbar-card">
<div class="toolbar-head">
<div>
<div class="toolbar-label">Search</div>
<div class="toolbar-title">Find saved items</div>
</div>
<div class="toolbar-meta">{items.length} item{items.length !== 1 ? 's' : ''}</div>
</div>
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input
type="text"
class="search-input"
placeholder="Search by title, content, tags..."
bind:value={searchQuery}
onkeydown={handleSearchKey}
/>
{#if searchQuery}
<button class="search-clear" onclick={() => { searchQuery = ''; loadItems(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
</div>
<div class="queue-header">
<div>
<div class="queue-label">Feed</div>
<div class="queue-title">{activeFolder || 'All items'}</div>
<div class="queue-summary">
{#if activeFolder}
Items the AI classified under {activeFolder.toLowerCase()}.
{:else}
Your complete saved collection, newest first.
{/if}
</div>
</div>
</div>
{#if loading}
<div class="items-card">
{#each [1, 2, 3, 4] as _}
<div class="item-row skeleton-row" style="height: 80px"></div>
{/each}
</div>
{:else if items.length === 0}
<div class="items-card">
<div class="empty">No items yet. Paste a URL or note above to get started.</div>
</div>
{:else}
<div class="items-card">
{#each items as item (item.id)}
<button class="item-row" onclick={() => selectedItem = item}>
<div class="row-accent" class:processing={item.processing_status === 'processing'} class:failed={item.processing_status === 'failed'}></div>
<div class="item-info">
<div class="item-name">{item.title || 'Processing...'}</div>
<div class="item-meta">
<span>{formatDate(item.created_at)}</span>
{#if item.folder}<span class="meta-folder">{item.folder}</span>{/if}
{#if item.url}<span class="meta-url">{new URL(item.url).hostname}</span>{/if}
{#each (item.tags || []).slice(0, 2) as tag}
<span class="meta-tag">{tag}</span>
{/each}
</div>
</div>
<div class="item-tail">
{#if item.processing_status === 'pending' || item.processing_status === 'processing'}
<span class="status-badge processing-badge">Processing</span>
{:else if item.processing_status === 'failed'}
<span class="status-badge error">Failed</span>
{:else if item.confidence && item.confidence > 0.8}
<span class="conf-dot high"></span>
{:else if item.confidence}
<span class="conf-dot med"></span>
{/if}
<svg class="row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
</button>
{/each}
</div>
{/if}
</div>
</section>
</div>
</div>
<!-- ═══ Detail sheet ═══ -->
{#if selectedItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
<div class="detail-sheet">
<div class="detail-header">
<div>
<div class="detail-type">{selectedItem.type}</div>
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
</div>
<button class="close-btn" onclick={() => selectedItem = null}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{#if selectedItem.url}
<a class="detail-url" href={selectedItem.url} target="_blank" rel="noopener">{selectedItem.url}</a>
{/if}
{#if selectedItem.summary}
<div class="detail-summary">{selectedItem.summary}</div>
{/if}
<div class="detail-meta-grid">
<div class="meta-block">
<div class="meta-label">Folder</div>
<div class="meta-value">{selectedItem.folder || '—'}</div>
</div>
<div class="meta-block">
<div class="meta-label">Confidence</div>
<div class="meta-value">{selectedItem.confidence ? (selectedItem.confidence * 100).toFixed(0) + '%' : '—'}</div>
</div>
<div class="meta-block">
<div class="meta-label">Status</div>
<div class="meta-value">{selectedItem.processing_status}</div>
</div>
<div class="meta-block">
<div class="meta-label">Saved</div>
<div class="meta-value">{new Date(selectedItem.created_at).toLocaleDateString()}</div>
</div>
</div>
{#if selectedItem.tags && selectedItem.tags.length > 0}
<div class="detail-tags">
{#each selectedItem.tags as tag}
<span class="detail-tag">{tag}</span>
{/each}
</div>
{/if}
<div class="detail-actions">
<button class="action-btn" onclick={() => reprocessItem(selectedItem.id)}>Reprocess</button>
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
{#if selectedItem.url}
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
{/if}
</div>
</div>
</div>
{/if}
<style>
.page { padding-top: 0; }
.app-surface { max-width: 1360px; }
/* ═══ Command hero ═══ */
.brain-command {
display: flex;
justify-content: space-between;
align-items: end;
gap: 24px;
padding: 8px 0 18px;
}
.command-copy { max-width: 640px; }
.command-label {
font-size: 11px; text-transform: uppercase;
letter-spacing: 0.14em; color: #8c7b69; margin-bottom: 8px;
}
.command-copy h1 {
margin: 0; font-size: clamp(2.1rem, 4.1vw, 3.5rem);
line-height: 0.94; letter-spacing: -0.065em; color: #17120d;
}
.command-copy p {
margin: 12px 0 0; max-width: 46ch;
color: #5c5046; font-size: 1rem; line-height: 1.55;
}
.command-actions { min-width: 220px; display: grid; justify-items: end; gap: 8px; }
.command-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #8c7b69; }
.command-stats { font-size: 1.15rem; letter-spacing: -0.04em; color: #221a13; }
/* ═══ Capture bar ═══ */
.capture-card {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
padding: 14px 16px; margin-bottom: 18px;
}
.capture-wrap { display: flex; align-items: center; gap: 10px; }
.capture-icon { width: 18px; height: 18px; color: #7f7365; flex-shrink: 0; }
.capture-input {
flex: 1; padding: 10px 0; border: none; background: none;
color: #1e1812; font-size: 1rem; font-family: var(--font); outline: none;
}
.capture-input::placeholder { color: #8b7b6a; }
.capture-btn {
padding: 8px 18px; border-radius: 999px;
background: #1e1812; color: white; border: none;
font-size: 0.88rem; font-weight: 600; font-family: var(--font);
transition: opacity 160ms;
}
.capture-btn:hover { opacity: 0.9; }
/* ═══ Signal strip ═══ */
.signal-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 18px; }
.signal-card {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
padding: 18px; text-align: left;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.signal-card:hover { transform: translateY(-2px); background: rgba(255,255,255,0.82); }
.signal-card.active {
border-color: rgba(179,92,50,0.2);
background: linear-gradient(145deg, rgba(255,248,242,0.94), rgba(246,237,227,0.72));
}
.signal-topline { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.signal-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; }
.signal-value { font-size: clamp(1.6rem, 3vw, 2.2rem); line-height: 0.95; letter-spacing: -0.05em; color: #1e1812; }
.signal-note { color: #4f463d; line-height: 1.6; font-size: 0.85rem; }
/* ═══ Layout ═══ */
.brain-layout { display: grid; gap: 18px; }
.brain-main {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: linear-gradient(180deg, rgba(255,252,248,0.84), rgba(244,237,229,0.74));
padding: 16px;
}
/* ═══ Toolbar ═══ */
.toolbar-card {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,255,255,0.42); padding: 16px 16px 12px; margin-bottom: 8px;
}
.toolbar-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.toolbar-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; }
.toolbar-title { margin-top: 4px; font-size: 1.1rem; letter-spacing: -0.035em; color: #1e1812; }
.toolbar-meta { color: #4f463d; font-size: 0.88rem; }
.search-wrap { position: relative; }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
.search-input {
width: 100%; padding: 12px 40px 12px 42px;
border-radius: var(--radius); border: 1.5px solid rgba(35,26,17,0.12);
background: rgba(255,255,255,0.92); color: #1e1812;
font-size: var(--text-md); font-family: var(--font);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5);
}
.search-input::placeholder { color: #8b7b6a; }
.search-input:focus { outline: none; border-color: rgba(179,92,50,0.5); box-shadow: 0 0 0 4px rgba(179,92,50,0.08); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #7f7365; }
.search-clear svg { width: 16px; height: 16px; }
/* ═══ Queue ═══ */
.queue-header { display: flex; align-items: end; justify-content: space-between; gap: 14px; padding: 8px 4px 14px; }
.queue-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; }
.queue-title { margin-top: 4px; font-size: 1.35rem; letter-spacing: -0.04em; color: #1e1812; }
.queue-summary { margin-top: 6px; color: #6a5d50; font-size: 0.94rem; line-height: 1.45; }
/* ═══ Items ═══ */
.items-card {
background: rgba(255,255,255,0.82); border-radius: 24px;
border: 1px solid rgba(35,26,17,0.06); overflow: hidden;
box-shadow: 0 16px 44px rgba(42,30,19,0.05);
}
.item-row {
display: grid; grid-template-columns: 4px minmax(0, 1fr) auto;
align-items: center; gap: 16px; padding: 18px 16px;
width: 100%; background: none; border: none; text-align: left;
transition: background 160ms ease, transform 160ms ease;
}
.item-row:hover { background: rgba(255,248,242,0.88); transform: translateX(2px); }
.item-row + .item-row { border-top: 1px solid rgba(35,26,17,0.07); }
.row-accent { align-self: stretch; border-radius: 999px; background: rgba(35,26,17,0.08); }
.row-accent.processing { background: linear-gradient(180deg, #f6a33a, #e28615); }
.row-accent.failed { background: linear-gradient(180deg, #ff5d45, #ef3d2f); }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: 1rem; font-weight: 700; color: #1e1812; line-height: 1.25; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 6px; color: #5d5248; font-size: 0.88rem; }
.meta-folder { font-weight: 600; color: #8c7b69; }
.meta-url { color: #7d6f61; }
.meta-tag { background: rgba(35,26,17,0.06); padding: 1px 8px; border-radius: 999px; font-size: 0.8rem; color: #5d5248; }
.item-tail { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.conf-dot { width: 8px; height: 8px; border-radius: 50%; }
.conf-dot.high { background: #059669; }
.conf-dot.med { background: #D97706; }
.status-badge { font-size: 11px; font-weight: 700; padding: 5px 10px; border-radius: 999px; flex-shrink: 0; }
.processing-badge { background: rgba(217,119,6,0.12); color: #9a5d09; }
.status-badge.error { background: var(--error-dim); color: var(--error); }
.row-chevron { width: 14px; height: 14px; color: #7d6f61; flex-shrink: 0; opacity: 0.7; }
.empty { padding: 48px; text-align: center; color: #5f564b; font-size: 1rem; }
/* ═══ Detail sheet ═══ */
.detail-overlay {
position: fixed; inset: 0;
background: rgba(17,13,10,0.42); z-index: 60;
display: flex; justify-content: flex-end;
backdrop-filter: blur(12px);
}
.detail-sheet {
width: 560px; max-width: 100%; height: 100%;
background: linear-gradient(180deg, #f8f1e8 0%, #f3eadc 100%);
overflow-y: auto; padding: 28px;
box-shadow: -20px 0 50px rgba(18,13,10,0.16);
border-left: 1px solid rgba(35,26,17,0.08);
color: #1e1812;
animation: sheetIn 220ms ease;
}
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.detail-type { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #8c7b69; margin-bottom: 6px; }
.detail-title { font-size: 1.5rem; font-weight: 700; color: #1e1812; line-height: 1.15; letter-spacing: -0.04em; margin: 0; }
.close-btn { background: none; border: none; color: #7d6f61; padding: 4px; border-radius: 8px; }
.close-btn:hover { background: rgba(35,26,17,0.08); color: #1e1812; }
.close-btn svg { width: 20px; height: 20px; }
.detail-url {
display: block; font-size: 0.88rem; color: #8c7b69;
margin-bottom: 16px; word-break: break-all;
text-decoration: none;
}
.detail-url:hover { color: #1e1812; text-decoration: underline; }
.detail-summary {
font-size: 1rem; color: #3d342c; line-height: 1.6;
margin-bottom: 20px; padding-bottom: 20px;
border-bottom: 1px solid rgba(35,26,17,0.08);
}
.detail-meta-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 12px; margin-bottom: 20px;
}
.meta-block {
background: rgba(255,255,255,0.58); border-radius: 14px;
padding: 14px; border: 1px solid rgba(35,26,17,0.06);
}
.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; margin-bottom: 4px; }
.meta-value { font-size: 1rem; font-weight: 600; color: #1e1812; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.detail-tag {
background: rgba(35,26,17,0.06); padding: 4px 12px;
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
}
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.action-btn {
display: flex; align-items: center; gap: 5px;
padding: 8px 14px; border-radius: 10px;
background: rgba(255,255,255,0.8); border: 1px solid rgba(35,26,17,0.1);
font-size: 0.88rem; color: #1e1812; text-decoration: none;
font-family: var(--font); transition: background 160ms;
}
.action-btn:hover { background: rgba(255,255,255,0.95); }
.action-btn.ghost { background: none; color: #6b6256; }
/* ═══ Animations ═══ */
.reveal { animation: riseIn 280ms ease; }
.stagger .signal-card:nth-child(1) { animation: riseIn 220ms ease; }
.stagger .signal-card:nth-child(2) { animation: riseIn 260ms ease; }
.stagger .signal-card:nth-child(3) { animation: riseIn 300ms ease; }
.stagger .signal-card:nth-child(4) { animation: riseIn 340ms ease; }
.stagger .signal-card:nth-child(5) { animation: riseIn 380ms ease; }
.stagger .signal-card:nth-child(6) { animation: riseIn 420ms ease; }
@keyframes riseIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes sheetIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
/* ═══ Mobile ═══ */
@media (max-width: 768px) {
.brain-command { display: grid; gap: 14px; }
.command-actions { justify-items: start; }
.signal-strip { grid-template-columns: 1fr 1fr; }
.detail-sheet { width: 100%; padding: 20px; }
.detail-meta-grid { grid-template-columns: 1fr; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
// Hides nav items but does NOT block direct URL access. // Hides nav items but does NOT block direct URL access.
// This is intentional: all shared services are accessible to all authenticated users. // This is intentional: all shared services are accessible to all authenticated users.
// Hiding reduces clutter for users who don't need certain apps day-to-day. // Hiding reduces clutter for users who don't need certain apps day-to-day.
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media']; const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'brain'];
const hiddenByUser: Record<string, string[]> = { const hiddenByUser: Record<string, string[]> = {
'madiha': ['inventory', 'reader'], 'madiha': ['inventory', 'reader'],
}; };

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import AtelierBrainPage from '$lib/pages/brain/AtelierBrainPage.svelte';
const useAtelierShell = $derived((page as any).data?.useAtelierShell || false);
</script>
{#if useAtelierShell}
<AtelierBrainPage />
{:else}
<AtelierBrainPage />
{/if}