feat(electrobun): redesign layout — numbered groups, right sidebar, notification drawer

- Fix group click locking UI (fire-and-forget RPC)
- Group icons: numbered circles with red badge for new items
- AGOR title: larger (900 weight, 1.25rem)
- Removed top title bar
- Right sidebar: vertical window controls + notification bell
- NotifDrawer.svelte: slide-in notification history
This commit is contained in:
Hibryda 2026-03-20 06:36:09 +01:00
parent a020f59cb4
commit d95cf122f0
5 changed files with 435 additions and 295 deletions

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte App</title> <title>Svelte App</title>
<script type="module" crossorigin src="/assets/index-CgAt0V08.js"></script> <script type="module" crossorigin src="/assets/index-CcWCxrMp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-rAZ5LabM.css"> <link rel="stylesheet" crossorigin href="/assets/index-B2TjuJxL.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -4,6 +4,7 @@
import SettingsDrawer from './SettingsDrawer.svelte'; import SettingsDrawer from './SettingsDrawer.svelte';
import CommandPalette from './CommandPalette.svelte'; import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte'; import ToastContainer from './ToastContainer.svelte';
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
import { themeStore } from './theme-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts'; import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts'; import { keybindingStore } from './keybinding-store.svelte.ts';
@ -45,6 +46,7 @@
name: string; name: string;
icon: string; icon: string;
position: number; position: number;
hasNew?: boolean;
} }
// ── Demo data ────────────────────────────────────────────────── // ── Demo data ──────────────────────────────────────────────────
@ -97,7 +99,7 @@
// ── Groups state ─────────────────────────────────────────────── // ── Groups state ───────────────────────────────────────────────
let groups = $state<Group[]>([ let groups = $state<Group[]>([
{ id: 'dev', name: 'Development', icon: '🔧', position: 0 }, { id: 'dev', name: 'Development', icon: '🔧', position: 0 },
{ id: 'test', name: 'Testing', icon: '🧪', position: 1 }, { id: 'test', name: 'Testing', icon: '🧪', position: 1, hasNew: true },
{ id: 'ops', name: 'DevOps', icon: '🚀', position: 2 }, { id: 'ops', name: 'DevOps', icon: '🚀', position: 2 },
{ id: 'research', name: 'Research', icon: '🔬', position: 3 }, { id: 'research', name: 'Research', icon: '🔬', position: 3 },
]); ]);
@ -110,16 +112,8 @@
); );
// Group projects into: top-level cards + clone groups // Group projects into: top-level cards + clone groups
// A "clone group" is a parent + its clones rendered side by side interface ProjectRow { type: 'standalone'; project: Project; }
interface ProjectRow { interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; }
type: 'standalone';
project: Project;
}
interface CloneGroupRow {
type: 'clone-group';
parent: Project;
clones: Project[];
}
type GridRow = ProjectRow | CloneGroupRow; type GridRow = ProjectRow | CloneGroupRow;
let gridRows = $derived((): GridRow[] => { let gridRows = $derived((): GridRow[] => {
@ -127,11 +121,9 @@
const cloneParentIds = new Set( const cloneParentIds = new Set(
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!) filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
); );
for (const p of filteredProjects) { for (const p of filteredProjects) {
if (p.cloneOf) continue; // Skip clones — they go into clone groups if (p.cloneOf) continue;
if (cloneParentIds.has(p.id)) { if (cloneParentIds.has(p.id)) {
// This parent has clones
const clones = filteredProjects const clones = filteredProjects
.filter(c => c.cloneOf === p.id) .filter(c => c.cloneOf === p.id)
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0)); .sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
@ -143,83 +135,65 @@
return standalone; return standalone;
}); });
// ── Clone count helpers ──────────────────────────────────────── // ── Clone helpers ──────────────────────────────────────────────
function cloneCountForProject(projectId: string): number { function cloneCountForProject(projectId: string): number {
return PROJECTS.filter(p => p.cloneOf === projectId).length; return PROJECTS.filter(p => p.cloneOf === projectId).length;
} }
function handleClone(projectId: string) { function handleClone(projectId: string) {
// In a real app: open the clone dialog and call RPC.
// Here we demonstrate the flow by calling the RPC directly with a demo branch.
const source = PROJECTS.find(p => p.id === projectId); const source = PROJECTS.find(p => p.id === projectId);
if (!source) return; if (!source) return;
const branchName = `feature/clone-${Date.now()}`; const branchName = `feature/clone-${Date.now()}`;
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => { appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
if (result.ok && result.project) { if (result.ok && result.project) {
const cloneConfig = JSON.parse(result.project.config) as Project; const cloneConfig = JSON.parse(result.project.config) as Project;
PROJECTS = [...PROJECTS, { PROJECTS = [...PROJECTS, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
...cloneConfig,
status: 'idle',
costUsd: 0,
tokens: 0,
messages: [],
}];
} else { } else {
console.error('[clone]', result.error); console.error('[clone]', result.error);
} }
}).catch(console.error); }).catch(console.error);
} }
// ── Init on mount ──────────────────────────────────────────────
onMount(() => {
themeStore.initTheme(appRpc).catch(console.error);
fontStore.initFonts(appRpc).catch(console.error);
keybindingStore.init(appRpc).catch(console.error);
// Load groups from DB
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
if (dbGroups.length > 0) groups = dbGroups;
}).catch(console.error);
// Restore active group from settings
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
if (value && groups.some(g => g.id === value)) activeGroupId = value;
}).catch(console.error);
// Register keybinding command handlers
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('minimize', () => appRpc.request["window.minimize"]({}).catch(console.error));
// Install listener (returns cleanup fn, but onMount handles lifecycle)
const cleanup = keybindingStore.installListener();
return cleanup;
});
// ── Reactive state ───────────────────────────────────────────── // ── Reactive state ─────────────────────────────────────────────
let settingsOpen = $state(false); let settingsOpen = $state(false);
let paletteOpen = $state(false); let paletteOpen = $state(false);
let notifCount = $state(2); let drawerOpen = $state(false);
let sessionStart = $state(Date.now()); 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) { function setActiveGroup(id: string | undefined) {
if (!id) return; if (!id) return;
activeGroupId = id; activeGroupId = id;
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error); // Fire and forget — don't let RPC failures block UI
appRpc?.request["settings.set"]?.({ key: 'active_group', value: id })?.catch?.(() => {});
} }
// Blink state // ── 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 ──────────────────────────────────────────────────────
let blinkVisible = $state(true); let blinkVisible = $state(true);
$effect(() => { $effect(() => {
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500); const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
return () => clearInterval(id); return () => clearInterval(id);
}); });
// Session duration // ── Session duration ───────────────────────────────────────────
let sessionDuration = $state('0m'); let sessionDuration = $state('0m');
$effect(() => { $effect(() => {
function update() { function update() {
@ -231,7 +205,7 @@
return () => clearInterval(id); return () => clearInterval(id);
}); });
// Window frame persistence (debounced 500ms) // ── Window frame persistence (debounced 500ms) ─────────────────
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null; let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
function saveWindowFrame() { function saveWindowFrame() {
if (frameSaveTimer) clearTimeout(frameSaveTimer); if (frameSaveTimer) clearTimeout(frameSaveTimer);
@ -246,63 +220,77 @@
} }
// ── Status bar aggregates ────────────────────────────────────── // ── Status bar aggregates ──────────────────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length); let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length); let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length); let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0)); let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0)); let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
let attentionItems = $derived( let attentionItems = $derived(PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75));
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
);
function fmtTokens(n: number): string { function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
}
function fmtCost(n: number): string { // ── Init ───────────────────────────────────────────────────────
return `$${n.toFixed(3)}`; onMount(() => {
} themeStore.initTheme(appRpc).catch(console.error);
fontStore.initFonts(appRpc).catch(console.error);
keybindingStore.init(appRpc).catch(console.error);
// ── Window control helpers ───────────────────────────────────── appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
function windowMinimize() { if (dbGroups.length > 0) groups = dbGroups;
appRpc.request["window.minimize"]({}).catch(console.error); }).catch(console.error);
}
function windowMaximize() { appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
appRpc.request["window.maximize"]({}).catch(console.error); if (value && groups.some(g => g.id === value)) activeGroupId = value;
} }).catch(console.error);
function windowClose() { keybindingStore.on('palette', () => { paletteOpen = !paletteOpen; });
appRpc.request["window.close"]({}).catch(console.error); 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('minimize', () => handleMinimize());
const cleanup = keybindingStore.installListener();
return cleanup;
});
</script> </script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} /> <SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} /> <CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<ToastContainer /> <ToastContainer />
<NotifDrawer
open={drawerOpen}
{notifications}
onClear={clearNotifications}
onClose={() => drawerOpen = false}
/>
<div <div
class="app-shell" class="app-shell"
role="presentation" role="presentation"
onresize={saveWindowFrame} onresize={saveWindowFrame}
> >
<!-- Sidebar icon rail --> <!-- Left sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation"> <aside class="sidebar" role="navigation" aria-label="Primary navigation">
<!-- AGOR vertical title --> <!-- AGOR vertical title -->
<div class="agor-title" aria-hidden="true">AGOR</div> <div class="agor-title" aria-hidden="true">AGOR</div>
<!-- Group icons --> <!-- Group icons — numbered circles -->
<div class="sidebar-groups" role="list" aria-label="Project groups"> <div class="sidebar-groups" role="list" aria-label="Project groups">
{#each groups as group, i} {#each groups as group, i}
<button <button
class="sidebar-icon group-icon" class="group-btn"
class:active={activeGroupId === group.id} class:active={activeGroupId === group.id}
onclick={() => setActiveGroup(group.id)} onclick={() => setActiveGroup(group.id)}
aria-label="{group.name} (Ctrl+{i + 1})" aria-label="{group.name} (Ctrl+{i + 1})"
title="{group.name} (Ctrl+{i + 1})" title="{group.name} (Ctrl+{i + 1})"
role="listitem"
> >
<span class="group-emoji" aria-hidden="true">{group.icon}</span> <span class="group-circle" aria-hidden="true">{i + 1}</span>
{#if group.hasNew}
<span class="group-badge" aria-label="New activity"></span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@ -326,32 +314,8 @@
<!-- Main workspace --> <!-- Main workspace -->
<main class="workspace"> <main class="workspace">
<!-- Draggable title bar area with window controls --> <!-- Draggable region at top of workspace (no title bar chrome) -->
<div class="title-bar" aria-label="Window title bar"> <div class="drag-region" aria-hidden="true"></div>
<div class="title-bar-drag" aria-hidden="true">
<span class="title-bar-text">{activeGroup?.name ?? 'Agent Orchestrator'}</span>
</div>
<div class="window-controls" role="toolbar" aria-label="Window controls">
<button
class="wc-btn"
onclick={windowMinimize}
aria-label="Minimize window"
title="Minimize"
>─</button>
<button
class="wc-btn"
onclick={windowMaximize}
aria-label="Maximize window"
title="Maximize"
>□</button>
<button
class="wc-btn close-btn"
onclick={windowClose}
aria-label="Close window"
title="Close"
>✕</button>
</div>
</div>
<!-- Project grid --> <!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects"> <div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
@ -378,7 +342,6 @@
/> />
</div> </div>
{:else} {:else}
<!-- Clone group: parent + clones in a flex row spanning full width -->
<div class="clone-group-row" role="listitem" aria-label="Project group: {row.parent.name}"> <div class="clone-group-row" role="listitem" aria-label="Project group: {row.parent.name}">
<ProjectCard <ProjectCard
id={row.parent.id} id={row.parent.id}
@ -399,7 +362,6 @@
onClone={handleClone} onClone={handleClone}
/> />
{#each row.clones as clone (clone.id)} {#each row.clones as clone (clone.id)}
<!-- Chain link icon between cards -->
<div class="chain-icon" aria-hidden="true"> <div class="chain-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
@ -431,12 +393,37 @@
{#if filteredProjects.length === 0} {#if filteredProjects.length === 0}
<div class="empty-group" role="listitem"> <div class="empty-group" role="listitem">
<span class="empty-group-icon" aria-hidden="true">{activeGroup?.icon ?? '📁'}</span>
<p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p> <p class="empty-group-text">No projects in {activeGroup?.name ?? 'this group'}</p>
</div> </div>
{/if} {/if}
</div> </div>
</main> </main>
<!-- 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>
</div>
<div class="right-spacer"></div>
<button
class="right-icon notif-btn"
class:active={drawerOpen}
onclick={() => drawerOpen = !drawerOpen}
aria-label="{notifCount > 0 ? `${notifCount} 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}
</button>
</aside>
</div> </div>
<!-- Status bar --> <!-- Status bar -->
@ -462,9 +449,8 @@
<span>stalled</span> <span>stalled</span>
</span> </span>
{/if} {/if}
{#if attentionItems.length > 0} {#if attentionItems.length > 0}
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}"> <span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p => p.name).join(', ')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg> </svg>
@ -475,17 +461,13 @@
<span class="status-bar-spacer"></span> <span class="status-bar-spacer"></span>
<!-- Group indicator -->
<span class="status-segment" title="Active group"> <span class="status-segment" title="Active group">
<span aria-hidden="true">{activeGroup?.icon}</span>
<span class="status-value">{activeGroup?.name}</span> <span class="status-value">{activeGroup?.name}</span>
</span> </span>
<span class="status-segment" title="Session duration"> <span class="status-segment" title="Session duration">
<span>session</span> <span>session</span>
<span class="status-value">{sessionDuration}</span> <span class="status-value">{sessionDuration}</span>
</span> </span>
<span class="status-segment" title="Total tokens used"> <span class="status-segment" title="Total tokens used">
<span>tokens</span> <span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span> <span class="status-value">{fmtTokens(totalTokens)}</span>
@ -495,20 +477,6 @@
<span class="status-value">{fmtCost(totalCost)}</span> <span class="status-value">{fmtCost(totalCost)}</span>
</span> </span>
<button
class="notif-btn"
onclick={() => notifCount = 0}
aria-label="{notifCount > 0 ? `${notifCount} unread 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="notif-badge" aria-hidden="true">{notifCount}</span>
{/if}
</button>
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd> <kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer> </footer>
@ -523,7 +491,7 @@
overflow: hidden; overflow: hidden;
} }
/* ── Sidebar ──────────────────────────────────────────────── */ /* ── Left sidebar ─────────────────────────────────────────── */
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
flex-shrink: 0; flex-shrink: 0;
@ -536,26 +504,24 @@
gap: 0.125rem; gap: 0.125rem;
} }
/* Vertical AGOR title */
.agor-title { .agor-title {
writing-mode: vertical-rl; writing-mode: vertical-rl;
transform: rotate(180deg); transform: rotate(180deg);
font-family: var(--ui-font-family); font-family: 'Inter', system-ui, sans-serif;
font-weight: 800; font-weight: 900;
font-size: 0.6875rem; font-size: 1.25rem;
letter-spacing: 0.18em; letter-spacing: 0.2em;
color: var(--ctp-overlay1); color: var(--ctp-overlay0);
padding: 0.625rem 0; padding: 1rem 0;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
} }
/* Group icons section */
.sidebar-groups { .sidebar-groups {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.125rem; gap: 0.25rem;
width: 100%; width: 100%;
padding: 0.25rem 0; padding: 0.25rem 0;
border-bottom: 1px solid var(--ctp-surface0); border-bottom: 1px solid var(--ctp-surface0);
@ -564,6 +530,57 @@
.sidebar-spacer { flex: 1; } .sidebar-spacer { flex: 1; }
/* Numbered group button */
.group-btn {
position: relative;
width: 2.25rem;
height: 2.25rem;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.group-circle {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 1.5px solid var(--ctp-surface1);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-subtext0);
transition: all 0.15s;
}
.group-btn:hover .group-circle {
border-color: var(--ctp-overlay1);
color: var(--ctp-text);
background: var(--ctp-surface0);
}
.group-btn.active .group-circle {
border-color: var(--accent, var(--ctp-mauve));
color: var(--accent, var(--ctp-mauve));
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 10%, transparent);
}
.group-badge {
position: absolute;
top: 0.125rem;
right: 0.125rem;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--ctp-red);
}
/* Settings icon button */
.sidebar-icon { .sidebar-icon {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
@ -577,31 +594,13 @@
justify-content: center; justify-content: center;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
padding: 0; padding: 0;
position: relative;
} }
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); } .sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); } .sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
.sidebar-icon svg { width: 1rem; height: 1rem; } .sidebar-icon svg { width: 1rem; height: 1rem; }
/* Active group: accent border-left indicator */ /* ── Main workspace ───────────────────────────────────────── */
.group-icon.active::before {
content: '';
position: absolute;
left: -0.375rem;
top: 25%;
bottom: 25%;
width: 2px;
background: var(--ctp-mauve);
border-radius: 0 1px 1px 0;
}
.group-emoji {
font-size: 1rem;
line-height: 1;
}
/* ── Workspace ────────────────────────────────────────────── */
.workspace { .workspace {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -610,64 +609,13 @@
overflow: hidden; overflow: hidden;
} }
/* ── Title bar (custom chrome) ────────────────────────────── */ /* Invisible draggable region at top of workspace */
.title-bar { .drag-region {
height: 2rem; height: 2.25rem;
flex-shrink: 0;
-webkit-app-region: drag;
background: var(--ctp-crust); background: var(--ctp-crust);
border-bottom: 1px solid var(--ctp-surface0); border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: stretch;
flex-shrink: 0;
/* Make entire title bar draggable; window controls override this */
-webkit-app-region: drag;
user-select: none;
}
.title-bar-drag {
flex: 1;
display: flex;
align-items: center;
padding: 0 0.75rem;
}
.title-bar-text {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-overlay1);
letter-spacing: 0.02em;
pointer-events: none;
}
.window-controls {
display: flex;
align-items: stretch;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
.wc-btn {
width: 2.75rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
font-size: 0.6875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s;
padding: 0;
font-family: var(--ui-font-family);
}
.wc-btn:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.close-btn:hover {
background: var(--ctp-red);
color: var(--ctp-base);
} }
/* ── Project grid ─────────────────────────────────────────── */ /* ── Project grid ─────────────────────────────────────────── */
@ -687,7 +635,6 @@
.project-grid::-webkit-scrollbar-track { background: transparent; } .project-grid::-webkit-scrollbar-track { background: transparent; }
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; } .project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
/* Clone group: spans both grid columns, flex row */
.clone-group-row { .clone-group-row {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@ -697,13 +644,8 @@
min-height: 0; min-height: 0;
} }
/* Each ProjectCard inside a clone group gets flex: 1 */ .clone-group-row :global(.project-card) { flex: 1; min-width: 0; }
.clone-group-row :global(.project-card) {
flex: 1;
min-width: 0;
}
/* Chain icon between linked cards */
.chain-icon { .chain-icon {
flex-shrink: 0; flex-shrink: 0;
width: 1.5rem; width: 1.5rem;
@ -713,12 +655,8 @@
color: var(--ctp-surface1); color: var(--ctp-surface1);
} }
.chain-icon svg { .chain-icon svg { width: 1rem; height: 1rem; }
width: 1rem;
height: 1rem;
}
/* Empty group placeholder */
.empty-group { .empty-group {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@ -730,9 +668,88 @@
color: var(--ctp-overlay0); color: var(--ctp-overlay0);
} }
.empty-group-icon { font-size: 2rem; }
.empty-group-text { font-size: 0.875rem; font-style: italic; } .empty-group-text { font-size: 0.875rem; font-style: italic; }
/* ── Right sidebar ────────────────────────────────────────── */
.right-bar {
width: 2.25rem;
flex-shrink: 0;
background: var(--ctp-mantle);
border-left: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
align-items: center;
padding: 0.375rem 0;
gap: 0.25rem;
}
.window-controls-vertical {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.wc-btn {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--ctp-overlay1);
font-size: 0.625rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s, color 0.12s;
padding: 0;
font-family: var(--ui-font-family);
-webkit-app-region: no-drag;
}
.wc-btn:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.close-btn:hover { background: var(--ctp-red); color: var(--ctp-base); }
.right-spacer { flex: 1; }
.right-icon {
width: 1.75rem;
height: 1.75rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
position: relative;
transition: color 0.12s, background 0.12s;
padding: 0;
}
.right-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.right-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
.right-icon svg { width: 0.875rem; height: 0.875rem; }
.right-badge {
position: absolute;
top: 0.125rem;
right: 0.125rem;
min-width: 0.875rem;
height: 0.875rem;
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.4375rem;
font-size: 0.5rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.125rem;
line-height: 1;
}
/* ── Status bar ───────────────────────────────────────────── */ /* ── Status bar ───────────────────────────────────────────── */
.status-bar { .status-bar {
height: var(--status-bar-height); height: var(--status-bar-height);
@ -765,50 +782,12 @@
.status-dot-sm.gray { background: var(--ctp-overlay0); } .status-dot-sm.gray { background: var(--ctp-overlay0); }
.status-dot-sm.orange { background: var(--ctp-peach); } .status-dot-sm.orange { background: var(--ctp-peach); }
.status-value { color: var(--ctp-text); font-weight: 500; } .status-value { color: var(--ctp-text); font-weight: 500; }
.status-bar-spacer { flex: 1; } .status-bar-spacer { flex: 1; }
.attn-badge { color: var(--ctp-yellow); } .attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); } .attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
.notif-btn {
position: relative;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: color 0.12s;
padding: 0;
flex-shrink: 0;
}
.notif-btn:hover { color: var(--ctp-text); }
.notif-btn svg { width: 0.875rem; height: 0.875rem; }
.notif-badge {
position: absolute;
top: 0.125rem;
right: 0.125rem;
min-width: 0.875rem;
height: 0.875rem;
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.4375rem;
font-size: 0.5rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.2rem;
line-height: 1;
}
.palette-hint { .palette-hint {
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
background: var(--ctp-surface0); background: var(--ctp-surface0);

View file

@ -0,0 +1,161 @@
<script lang="ts">
export interface Notification {
id: number;
message: string;
type: 'success' | 'warning' | 'info' | 'error';
time: string;
}
interface Props {
open: boolean;
notifications: Notification[];
onClear: () => void;
onClose: () => void;
}
let { open, notifications, onClear, onClose }: Props = $props();
</script>
{#if open}
<!-- Backdrop to close on outside click -->
<div class="notif-backdrop" role="presentation" onclick={onClose}></div>
<div class="notif-drawer" role="complementary" aria-label="Notification history">
<div class="drawer-header">
<span class="drawer-title">Notifications</span>
<button class="clear-btn" onclick={onClear} aria-label="Clear all notifications">
Clear all
</button>
</div>
<div class="drawer-body">
{#each notifications as notif (notif.id)}
<div class="notif-item" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'}>
<span class="notif-dot" class:success={notif.type === 'success'} class:warning={notif.type === 'warning'} class:error={notif.type === 'error'} aria-hidden="true"></span>
<div class="notif-content">
<span class="notif-text">{notif.message}</span>
<span class="notif-time">{notif.time}</span>
</div>
</div>
{/each}
{#if notifications.length === 0}
<div class="notif-empty">No notifications</div>
{/if}
</div>
</div>
{/if}
<style>
.notif-backdrop {
position: fixed;
inset: 0;
z-index: 90;
}
.notif-drawer {
position: fixed;
top: 0;
right: 2.25rem; /* right-bar width */
bottom: var(--status-bar-height, 1.5rem);
width: 18rem;
background: var(--ctp-mantle);
border-left: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
z-index: 91;
box-shadow: -0.25rem 0 1rem color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.drawer-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
letter-spacing: 0.02em;
}
.clear-btn {
background: transparent;
border: none;
font-size: 0.6875rem;
color: var(--ctp-overlay1);
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: var(--ui-font-family);
transition: color 0.12s;
}
.clear-btn:hover { color: var(--ctp-text); }
.drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.375rem 0;
}
.drawer-body::-webkit-scrollbar { width: 0.25rem; }
.drawer-body::-webkit-scrollbar-track { background: transparent; }
.drawer-body::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
.notif-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
transition: background 0.1s;
}
.notif-item:hover { background: var(--ctp-surface0); }
.notif-dot {
flex-shrink: 0;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
margin-top: 0.3rem;
background: var(--ctp-overlay1);
}
.notif-dot.success { background: var(--ctp-green); }
.notif-dot.warning { background: var(--ctp-yellow); }
.notif-dot.error { background: var(--ctp-red); }
.notif-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.notif-text {
font-size: 0.75rem;
color: var(--ctp-text);
line-height: 1.4;
word-break: break-word;
}
.notif-time {
font-size: 0.625rem;
color: var(--ctp-overlay0);
}
.notif-empty {
padding: 2rem 0.75rem;
text-align: center;
font-size: 0.75rem;
color: var(--ctp-overlay0);
font-style: italic;
}
</style>