refactor(electrobun): modularize stores + shared UI components

Stores:
- notifications-store.svelte.ts: owns notifications array (was inline in App)
- workspace-store.svelte.ts: extended with addProjectFromWizard, loadGroupsFromDb,
  loadProjectsFromDb, derived getters (totalCost, totalTokens, mountedGroupIds)

Shared UI components (ui/):
- SegmentedControl.svelte: replaces repeated .seg button groups
- SliderInput.svelte: labeled range slider with value display
- StatusDot.svelte: colored dot with pulse support
- IconButton.svelte: icon-only button with tooltip, 3 sizes
- Section.svelte: settings section wrapper with heading

App.svelte: script 390→221 lines (removed all inline CRUD, delegates to stores)
ProjectCard: uses StatusDot shared component
AgentSettings + OrchestrationSettings: use SegmentedControl, SliderInput, Section
This commit is contained in:
Hibryda 2026-03-23 19:42:47 +01:00
parent 265ddd3f1d
commit c88577a34a
12 changed files with 647 additions and 437 deletions

View file

@ -4,7 +4,7 @@
import SettingsDrawer from './SettingsDrawer.svelte';
import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte';
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
import NotifDrawer from './NotifDrawer.svelte';
import StatusBar from './StatusBar.svelte';
import SearchOverlay from './SearchOverlay.svelte';
import SplashScreen from './SplashScreen.svelte';
@ -12,211 +12,42 @@
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { trackProject } from './health-store.svelte.ts';
import { setAgentToastFn } from './agent-store.svelte.ts';
import { appRpc } from './rpc.ts';
import { initI18n, getDir, getLocale, t } from './i18n.svelte.ts';
import { initI18n, getDir, getLocale } from './i18n.svelte.ts';
import {
getProjects, getGroups, getActiveGroupId,
getMountedGroupIds, getActiveGroup, getFilteredProjects,
getTotalCostDerived, getTotalTokensDerived,
setActiveGroup, addProjectFromWizard, deleteProject,
cloneCountForProject, handleClone, addGroup as wsAddGroup,
loadGroupsFromDb, loadProjectsFromDb, trackAllProjects,
type WizardResult,
} from './workspace-store.svelte.ts';
import {
getNotifications, clearAll as clearNotifications, getNotifCount,
} from './notifications-store.svelte.ts';
// ── Types ─────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
interface AgentMessage {
id: number;
role: MsgRole;
content: string;
}
interface Project {
id: string;
name: string;
cwd: string;
accent: string;
status: AgentStatus;
costUsd: number;
tokens: number;
messages: AgentMessage[];
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
groupId?: string;
cloneOf?: string;
worktreeBranch?: string;
mainRepoPath?: string;
cloneIndex?: number;
}
interface Group {
id: string;
name: string;
icon: string;
position: number;
hasNew?: boolean;
}
// ── Accent colors for auto-assignment ─────────────────────────
const ACCENTS = [
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
];
// ── Projects state (loaded from SQLite) ─────────────────────────
let PROJECTS = $state<Project[]>([]);
// ── Groups state (loaded from SQLite) ────────────────────────────
let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '1', position: 0 },
]);
// ── Add/Remove project UI state ──────────────────────────────────
let showWizard = $state(false);
// ── Local UI state (view-only, not domain state) ────────────
let appReady = $state(false);
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let searchOpen = $state(false);
let showWizard = $state(false);
let projectToDelete = $state<string | null>(null);
let showAddGroup = $state(false);
let newGroupName = $state('');
let sessionStart = $state(Date.now());
function handleWizardCreated(result: {
id: string; name: string; cwd: string; provider?: string; model?: string;
systemPrompt?: string; autoStart?: boolean; groupId?: string;
useWorktrees?: boolean; shell?: string; icon?: string; color?: string;
modelConfig?: Record<string, unknown>;
}) {
const accent = result.color || ACCENTS[PROJECTS.length % ACCENTS.length];
const gid = result.groupId ?? activeGroupId;
const project: Project = {
id: result.id,
name: result.name,
cwd: result.cwd,
accent,
status: 'idle',
costUsd: 0,
tokens: 0,
messages: [],
provider: result.provider ?? 'claude',
model: result.model,
groupId: gid,
};
PROJECTS = [...PROJECTS, project];
trackProject(project.id);
// Persist full config including shell, icon, modelConfig etc.
const persistConfig = {
id: result.id, name: result.name, cwd: result.cwd, accent,
provider: result.provider ?? 'claude', model: result.model,
groupId: gid, shell: result.shell, icon: result.icon,
useWorktrees: result.useWorktrees, systemPrompt: result.systemPrompt,
autoStart: result.autoStart, modelConfig: result.modelConfig,
};
appRpc.request['settings.setProject']({
id: project.id,
config: JSON.stringify(persistConfig),
}).catch(console.error);
showWizard = false;
}
async function confirmDeleteProject() {
if (!projectToDelete) return;
PROJECTS = PROJECTS.filter(p => p.id !== projectToDelete);
await appRpc.request['settings.deleteProject']({ id: projectToDelete }).catch(console.error);
projectToDelete = null;
}
// ── Add/Remove group UI state ───────────────────────────────────
let showAddGroup = $state(false);
let newGroupName = $state('');
async function addGroup() {
const name = newGroupName.trim();
if (!name) return;
const id = `grp-${Date.now()}`;
const position = groups.length;
const group: Group = { id, name, icon: String(position + 1), position };
groups = [...groups, group];
await appRpc.request['groups.create']({ id, name, icon: group.icon, position }).catch(console.error);
showAddGroup = false;
newGroupName = '';
}
// Fix #19: removeGroup removed — was defined but never called from UI
let activeGroupId = $state('dev');
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
let previousGroupId = $state<string | null>(null);
let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
// ── Filtered projects for active group ────────────────────────
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
let filteredProjects = $derived(
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
);
// ── Clone helpers ──────────────────────────────────────────────
function cloneCountForProject(projectId: string): number {
return PROJECTS.filter(p => p.cloneOf === projectId).length;
}
function handleClone(projectId: string, branch: string) {
const source = PROJECTS.find(p => p.id === projectId);
if (!source) return;
const branchName = branch || `feature/clone-${Date.now()}`;
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
if (result.ok && result.project) {
const cloneConfig = JSON.parse(result.project.config) as Project;
PROJECTS = [...PROJECTS, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
} else {
console.error('[clone]', result.error);
}
}).catch(console.error);
}
// ── Splash screen ─────────────────────────────────────────────
let appReady = $state(false);
// ── Reactive state ─────────────────────────────────────────────
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let searchOpen = $state(false);
let sessionStart = $state(Date.now());
let notifications = $state<Notification[]>([
{ id: 1, message: 'Agent completed: wake scheduler implemented', type: 'success', time: '2m ago' },
{ id: 2, message: 'Context pressure: 78% on agent-orchestrator', type: 'warning', time: '5m ago' },
{ id: 3, message: 'PTY daemon connected', type: 'info', time: '12m ago' },
]);
let notifCount = $derived(notifications.length);
function clearNotifications() {
notifications = [];
drawerOpen = false;
}
// ── setActiveGroup: fire-and-forget RPC ───────────────────────
function setActiveGroup(id: string | undefined) {
if (!id) return;
// Fix #10: Track previous group for DOM mount limit
if (activeGroupId !== id) {
previousGroupId = activeGroupId;
}
activeGroupId = id;
// Fix #16: Persist active_group selection
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
}
// ── Window controls ────────────────────────────────────────────
function handleClose() { appRpc.request["window.close"]({}).catch(console.error); }
function handleMaximize() { appRpc.request["window.maximize"]({}).catch(console.error); }
function handleMinimize() { appRpc.request["window.minimize"]({}).catch(console.error); }
// ── Blink ──────────────────────────────────────────────────────
// ── Blink timer ─────────────────────────────────────────────
let blinkVisible = $state(true);
$effect(() => {
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
return () => clearInterval(id);
});
// ── Session duration ───────────────────────────────────────────
// ── Session duration ────────────────────────────────────────
let sessionDuration = $state('0m');
$effect(() => {
function update() {
@ -228,9 +59,12 @@
return () => clearInterval(id);
});
// Fix #19: onDragStart/onDragMove/onDragEnd removed — no longer referenced from template
// ── Window controls ─────────────────────────────────────────
function handleClose() { appRpc.request["window.close"]({}).catch(console.error); }
function handleMaximize() { appRpc.request["window.maximize"]({}).catch(console.error); }
function handleMinimize() { appRpc.request["window.minimize"]({}).catch(console.error); }
// ── Window frame persistence (debounced 500ms) ─────────────────
// ── Window frame persistence (debounced 500ms) ──────────────
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
function saveWindowFrame() {
if (frameSaveTimer) clearTimeout(frameSaveTimer);
@ -244,11 +78,55 @@
}, 500);
}
// ── Status bar aggregates ──────────────────────────────────────
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
// ── Wizard handler ──────────────────────────────────────────
function handleWizardCreated(result: WizardResult) {
addProjectFromWizard(result);
showWizard = false;
}
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
async function confirmDeleteProject() {
if (!projectToDelete) return;
await deleteProject(projectToDelete);
projectToDelete = null;
}
// ── Group add (local UI + store) ────────────────────────────
async function handleAddGroup() {
const name = newGroupName.trim();
if (!name) return;
await wsAddGroup(name);
showAddGroup = false;
newGroupName = '';
}
// ── Notification drawer ─────────────────────────────────────
function handleClearNotifications() {
clearNotifications();
drawerOpen = false;
}
// ── Toast ref for agent notifications ───────────────────────
let toastRef: ToastContainer | undefined;
function showToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
toastRef?.addToast(message, variant);
}
// ── Global error boundary ───────────────────────────────────
function setupErrorBoundary() {
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason);
console.error('[unhandled rejection]', e.reason);
showToast(`Unhandled error: ${msg.slice(0, 100)}`, 'error');
e.preventDefault();
});
window.addEventListener('error', (e: ErrorEvent) => {
console.error('[uncaught error]', e.error);
showToast(`Error: ${e.message.slice(0, 100)}`, 'error');
});
}
// ── DEBUG overlay ───────────────────────────────────────────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
let debugLog = $state<string[]>([]);
@ -279,86 +157,40 @@
};
});
// ── Toast ref for agent notifications ─────────────────────────
let toastRef: ToastContainer | undefined;
function showToast(message: string, variant: 'success' | 'warning' | 'error' | 'info') {
toastRef?.addToast(message, variant);
}
// ── Global error boundary ──────────────────────────────────────
function setupErrorBoundary() {
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const msg = e.reason instanceof Error ? e.reason.message : String(e.reason);
console.error('[unhandled rejection]', e.reason);
showToast(`Unhandled error: ${msg.slice(0, 100)}`, 'error');
e.preventDefault();
});
window.addEventListener('error', (e: ErrorEvent) => {
console.error('[uncaught error]', e.error);
showToast(`Error: ${e.message.slice(0, 100)}`, 'error');
});
}
// ── i18n: keep <html> lang and dir in sync ─────────────────────
// ── i18n: keep <html> lang and dir in sync ──────────────────
$effect(() => {
document.documentElement.lang = getLocale();
document.documentElement.dir = getDir();
});
// ── Init ───────────────────────────────────────────────────────
// ── Init ────────────────────────────────────────────────────
onMount(() => {
// Wire agent toast callback
setAgentToastFn(showToast);
// Set up global error boundary
setupErrorBoundary();
// Fix #8: Load groups FIRST, then apply saved active_group.
// Other init tasks run in parallel.
const initTasks = [
initI18n().catch(console.error),
themeStore.initTheme(appRpc).catch(console.error),
fontStore.initFonts(appRpc).catch(console.error),
keybindingStore.init(appRpc).catch(console.error),
// Sequential: groups.list -> active_group (depends on groups being loaded)
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }: { groups: Group[] }) => {
if (dbGroups.length > 0) groups = dbGroups;
// Now that groups are loaded, apply saved active_group
return appRpc.request["settings.get"]({ key: 'active_group' });
}).then(({ value }: { value: string | null }) => {
if (value && groups.some(g => g.id === value)) activeGroupId = value;
}).catch(console.error),
// Load projects from SQLite
appRpc.request["settings.getProjects"]({}).then(({ projects }: { projects: Array<{ id: string; config: string }> }) => {
if (projects.length > 0) {
const loaded: Project[] = projects.flatMap(({ config }) => {
try {
const p = JSON.parse(config) as Project;
return [{ ...p, status: p.status ?? 'idle', costUsd: p.costUsd ?? 0, tokens: p.tokens ?? 0, messages: p.messages ?? [] }];
} catch { return []; }
});
if (loaded.length > 0) PROJECTS = loaded;
}
}).catch(console.error),
loadGroupsFromDb().catch(console.error),
loadProjectsFromDb().catch(console.error),
];
// Timeout: if init hangs for 10s, force ready anyway
const timeout = new Promise(r => setTimeout(r, 10000));
Promise.race([Promise.allSettled(initTasks), timeout]).then(() => {
appReady = true;
for (const p of PROJECTS) trackProject(p.id);
trackAllProjects();
});
keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
keybindingStore.on('settings', () => { settingsOpen = !settingsOpen; });
keybindingStore.on('group1', () => setActiveGroup(groups[0]?.id));
keybindingStore.on('group2', () => setActiveGroup(groups[1]?.id));
keybindingStore.on('group3', () => setActiveGroup(groups[2]?.id));
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
keybindingStore.on('group1', () => setActiveGroup(getGroups()[0]?.id));
keybindingStore.on('group2', () => setActiveGroup(getGroups()[1]?.id));
keybindingStore.on('group3', () => setActiveGroup(getGroups()[2]?.id));
keybindingStore.on('group4', () => setActiveGroup(getGroups()[3]?.id));
keybindingStore.on('minimize', () => handleMinimize());
// Ctrl+Shift+F for search overlay
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
@ -367,7 +199,6 @@
}
document.addEventListener('keydown', handleSearchShortcut);
// Fix #18: Wire CommandPalette events to action handlers
function handlePaletteCommand(e: Event) {
const detail = (e as CustomEvent).detail;
switch (detail) {
@ -396,8 +227,8 @@
<ToastContainer bind:this={toastRef} />
<NotifDrawer
open={drawerOpen}
{notifications}
onClear={clearNotifications}
notifications={getNotifications()}
onClear={handleClearNotifications}
onClose={() => drawerOpen = false}
/>
@ -413,10 +244,10 @@
<!-- Group icons — numbered circles -->
<div class="sidebar-groups" role="list" aria-label="Project groups">
{#each groups as group, i}
{#each getGroups() as group, i}
<button
class="group-btn"
class:active={activeGroupId === group.id}
class:active={getActiveGroupId() === group.id}
onclick={() => setActiveGroup(group.id)}
aria-label="{group.name} (Ctrl+{i + 1})"
title="{group.name} (Ctrl+{i + 1})"
@ -446,7 +277,7 @@
type="text"
placeholder="Group name"
bind:value={newGroupName}
onkeydown={(e) => { if (e.key === 'Enter') addGroup(); if (e.key === 'Escape') showAddGroup = false; }}
onkeydown={(e) => { if (e.key === 'Enter') handleAddGroup(); if (e.key === 'Escape') showAddGroup = false; }}
autofocus
/>
</div>
@ -482,15 +313,13 @@
</button>
</aside>
<!-- Main workspace — no drag region (WebKitGTK captures clicks incorrectly) -->
<!-- Main workspace -->
<main class="workspace">
<!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
{#each PROJECTS as project (project.id)}
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
<div class="project-grid" role="list" aria-label="{getActiveGroup()?.name ?? 'Projects'} projects">
{#each getProjects() as project (project.id)}
{#if getMountedGroupIds().has(project.groupId ?? 'dev')}
<div role="listitem" style:display={(project.groupId ?? 'dev') === getActiveGroupId() ? 'flex' : 'none'}>
<ProjectCard
id={project.id}
name={project.name}
@ -511,10 +340,10 @@
{/if}
{/each}
<!-- Empty group — always in DOM, visibility toggled -->
<!-- Empty group -->
<div class="empty-group" role="listitem"
style:display={filteredProjects.length === 0 ? 'flex' : 'none'}>
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
style:display={getFilteredProjects().length === 0 ? 'flex' : 'none'}>
<p class="empty-group-text">No projects in {getActiveGroup()?.name ?? 'this group'}</p>
</div>
<!-- Project wizard overlay (display toggle) -->
@ -522,16 +351,16 @@
<ProjectWizard
onClose={() => showWizard = false}
onCreated={handleWizardCreated}
groupId={activeGroupId}
groups={groups.map(g => ({ id: g.id, name: g.name }))}
existingNames={PROJECTS.map(p => p.name)}
groupId={getActiveGroupId()}
groups={getGroups().map(g => ({ id: g.id, name: g.name }))}
existingNames={getProjects().map(p => p.name)}
/>
</div>
<!-- Delete project confirmation -->
{#if projectToDelete}
<div class="delete-overlay" role="listitem">
<p class="delete-text">Delete project "{PROJECTS.find(p => p.id === projectToDelete)?.name}"?</p>
<p class="delete-text">Delete project "{getProjects().find(p => p.id === projectToDelete)?.name}"?</p>
<div class="add-card-actions">
<button class="add-cancel" onclick={() => projectToDelete = null}>Cancel</button>
<button class="delete-confirm" onclick={confirmDeleteProject}>Delete</button>
@ -544,9 +373,9 @@
<!-- Right sidebar: window controls + notification bell -->
<aside class="right-bar" aria-label="Window controls and notifications">
<div class="window-controls-vertical" role="toolbar" aria-label="Window controls">
<button class="wc-btn close-btn" onclick={handleClose} aria-label="Close window" title="Close"></button>
<button class="wc-btn" onclick={handleMaximize} aria-label="Maximize window" title="Maximize"></button>
<button class="wc-btn" onclick={handleMinimize} aria-label="Minimize window" title="Minimize"></button>
<button class="wc-btn close-btn" onclick={handleClose} aria-label="Close window" title="Close">&#10005;</button>
<button class="wc-btn" onclick={handleMaximize} aria-label="Maximize window" title="Maximize">&#9633;</button>
<button class="wc-btn" onclick={handleMinimize} aria-label="Minimize window" title="Minimize">&#9472;</button>
</div>
<div class="right-spacer"></div>
@ -555,14 +384,14 @@
class="right-icon notif-btn"
class:active={drawerOpen}
onclick={() => drawerOpen = !drawerOpen}
aria-label="{notifCount > 0 ? `${notifCount} notifications` : 'Notifications'}"
aria-label="{getNotifCount() > 0 ? `${getNotifCount()} notifications` : 'Notifications'}"
title="Notifications"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{#if notifCount > 0}
<span class="right-badge" aria-hidden="true">{notifCount}</span>
{#if getNotifCount() > 0}
<span class="right-badge" aria-hidden="true">{getNotifCount()}</span>
{/if}
</button>
</aside>
@ -570,15 +399,14 @@
<!-- Status bar (health-backed) -->
<StatusBar
projectCount={filteredProjects.length}
{totalTokens}
totalCost={totalCost}
projectCount={getFilteredProjects().length}
totalTokens={getTotalTokensDerived()}
totalCost={getTotalCostDerived()}
{sessionDuration}
groupName={activeGroup?.name ?? ''}
groupName={getActiveGroup()?.name ?? ''}
/>
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
{#each debugLog as line}
<div>{line}</div>
@ -621,7 +449,6 @@
padding: 1rem 0;
user-select: none;
flex-shrink: 0;
/* NO -webkit-app-region — broken on WebKitGTK (captures all clicks in window) */
cursor: grab;
}
@ -638,7 +465,6 @@
.sidebar-spacer { flex: 1; }
/* Numbered group button */
.group-btn {
position: relative;
width: 2.25rem;
@ -688,7 +514,6 @@
background: var(--ctp-red);
}
/* Settings icon button */
.sidebar-icon {
width: 2rem;
height: 2rem;
@ -717,13 +542,12 @@
overflow: hidden;
}
/* ── Project grid ─────────────────────────────────────────── */
.project-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-rows: 1fr; /* equal rows that fill available height */
grid-auto-rows: 1fr;
gap: 0.5rem;
padding: 0.5rem;
background: var(--ctp-crust);
@ -782,7 +606,6 @@
transition: background 0.12s, color 0.12s;
padding: 0;
font-family: var(--ui-font-family);
/* no-drag not needed — all -webkit-app-region removed */
}
.wc-btn:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
@ -847,33 +670,10 @@
}
.add-group-input:focus { outline: none; border-color: var(--ctp-blue); }
/* ── Add project card ──────────────────────────────────────── */
.add-card {
grid-column: 1 / -1;
flex-direction: column;
background: var(--ctp-base);
border: 1px dashed var(--ctp-surface1);
border-radius: 0.5rem;
padding: 0.75rem;
}
.add-card-form { display: flex; flex-direction: column; gap: 0.375rem; }
.add-input {
padding: 0.375rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-size: 0.8125rem;
font-family: var(--ui-font-family);
}
.add-input:focus { outline: none; border-color: var(--ctp-blue); }
.add-input::placeholder { color: var(--ctp-overlay0); }
/* ── Delete project overlay ──────────────────────────────────── */
.add-card-actions { display: flex; gap: 0.375rem; justify-content: flex-end; }
.add-cancel, .add-confirm, .delete-confirm {
.add-cancel, .delete-confirm {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.75rem;
@ -883,8 +683,6 @@
.add-cancel { background: transparent; border: 1px solid var(--ctp-surface1); color: var(--ctp-subtext0); }
.add-cancel:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.add-confirm { background: color-mix(in srgb, var(--ctp-green) 20%, transparent); border: 1px solid var(--ctp-green); color: var(--ctp-green); }
.add-confirm:hover { background: color-mix(in srgb, var(--ctp-green) 35%, transparent); }
.delete-overlay {
grid-column: 1 / -1;
@ -901,6 +699,4 @@
.delete-text { font-size: 0.875rem; color: var(--ctp-text); margin: 0; }
.delete-confirm { background: color-mix(in srgb, var(--ctp-red) 20%, transparent); border: 1px solid var(--ctp-red); color: var(--ctp-red); }
.delete-confirm:hover { background: color-mix(in srgb, var(--ctp-red) 35%, transparent); }
/* Status bar styles are in StatusBar.svelte */
</style>