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:
parent
a020f59cb4
commit
d95cf122f0
5 changed files with 435 additions and 295 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Svelte App</title>
|
||||
<script type="module" crossorigin src="/assets/index-CgAt0V08.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-rAZ5LabM.css">
|
||||
<script type="module" crossorigin src="/assets/index-CcWCxrMp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B2TjuJxL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -4,6 +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 { themeStore } from './theme-store.svelte.ts';
|
||||
import { fontStore } from './font-store.svelte.ts';
|
||||
import { keybindingStore } from './keybinding-store.svelte.ts';
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
name: string;
|
||||
icon: string;
|
||||
position: number;
|
||||
hasNew?: boolean;
|
||||
}
|
||||
|
||||
// ── Demo data ──────────────────────────────────────────────────
|
||||
|
|
@ -97,7 +99,7 @@
|
|||
// ── Groups state ───────────────────────────────────────────────
|
||||
let groups = $state<Group[]>([
|
||||
{ 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: 'research', name: 'Research', icon: '🔬', position: 3 },
|
||||
]);
|
||||
|
|
@ -110,16 +112,8 @@
|
|||
);
|
||||
|
||||
// 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 CloneGroupRow {
|
||||
type: 'clone-group';
|
||||
parent: Project;
|
||||
clones: Project[];
|
||||
}
|
||||
interface ProjectRow { type: 'standalone'; project: Project; }
|
||||
interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; }
|
||||
type GridRow = ProjectRow | CloneGroupRow;
|
||||
|
||||
let gridRows = $derived((): GridRow[] => {
|
||||
|
|
@ -127,11 +121,9 @@
|
|||
const cloneParentIds = new Set(
|
||||
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
|
||||
);
|
||||
|
||||
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)) {
|
||||
// This parent has clones
|
||||
const clones = filteredProjects
|
||||
.filter(c => c.cloneOf === p.id)
|
||||
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
|
||||
|
|
@ -143,83 +135,65 @@
|
|||
return standalone;
|
||||
});
|
||||
|
||||
// ── Clone count helpers ────────────────────────────────────────
|
||||
// ── Clone helpers ──────────────────────────────────────────────
|
||||
function cloneCountForProject(projectId: string): number {
|
||||
return PROJECTS.filter(p => p.cloneOf === projectId).length;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!source) return;
|
||||
const branchName = `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: [],
|
||||
}];
|
||||
PROJECTS = [...PROJECTS, { ...cloneConfig, status: 'idle', costUsd: 0, tokens: 0, messages: [] }];
|
||||
} else {
|
||||
console.error('[clone]', result.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 ─────────────────────────────────────────────
|
||||
let settingsOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let notifCount = $state(2);
|
||||
let sessionStart = $state(Date.now());
|
||||
let settingsOpen = $state(false);
|
||||
let paletteOpen = $state(false);
|
||||
let drawerOpen = $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;
|
||||
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);
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Session duration
|
||||
// ── Session duration ───────────────────────────────────────────
|
||||
let sessionDuration = $state('0m');
|
||||
$effect(() => {
|
||||
function update() {
|
||||
|
|
@ -231,7 +205,7 @@
|
|||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Window frame persistence (debounced 500ms)
|
||||
// ── Window frame persistence (debounced 500ms) ─────────────────
|
||||
let frameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function saveWindowFrame() {
|
||||
if (frameSaveTimer) clearTimeout(frameSaveTimer);
|
||||
|
|
@ -246,63 +220,77 @@
|
|||
}
|
||||
|
||||
// ── Status bar aggregates ──────────────────────────────────────
|
||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
||||
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
|
||||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||
let attentionItems = $derived(
|
||||
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
|
||||
);
|
||||
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
|
||||
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
|
||||
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
|
||||
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
|
||||
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
|
||||
let attentionItems = $derived(PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75));
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
|
||||
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
|
||||
|
||||
function fmtCost(n: number): string {
|
||||
return `$${n.toFixed(3)}`;
|
||||
}
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
themeStore.initTheme(appRpc).catch(console.error);
|
||||
fontStore.initFonts(appRpc).catch(console.error);
|
||||
keybindingStore.init(appRpc).catch(console.error);
|
||||
|
||||
// ── Window control helpers ─────────────────────────────────────
|
||||
function windowMinimize() {
|
||||
appRpc.request["window.minimize"]({}).catch(console.error);
|
||||
}
|
||||
appRpc.request["groups.list"]({}).then(({ groups: dbGroups }) => {
|
||||
if (dbGroups.length > 0) groups = dbGroups;
|
||||
}).catch(console.error);
|
||||
|
||||
function windowMaximize() {
|
||||
appRpc.request["window.maximize"]({}).catch(console.error);
|
||||
}
|
||||
appRpc.request["settings.get"]({ key: 'active_group' }).then(({ value }) => {
|
||||
if (value && groups.some(g => g.id === value)) activeGroupId = value;
|
||||
}).catch(console.error);
|
||||
|
||||
function windowClose() {
|
||||
appRpc.request["window.close"]({}).catch(console.error);
|
||||
}
|
||||
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', () => handleMinimize());
|
||||
|
||||
const cleanup = keybindingStore.installListener();
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
|
||||
<ToastContainer />
|
||||
<NotifDrawer
|
||||
open={drawerOpen}
|
||||
{notifications}
|
||||
onClear={clearNotifications}
|
||||
onClose={() => drawerOpen = false}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="app-shell"
|
||||
role="presentation"
|
||||
onresize={saveWindowFrame}
|
||||
>
|
||||
<!-- Sidebar icon rail -->
|
||||
<!-- Left sidebar icon rail -->
|
||||
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
|
||||
<!-- AGOR vertical title -->
|
||||
<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">
|
||||
{#each groups as group, i}
|
||||
<button
|
||||
class="sidebar-icon group-icon"
|
||||
class="group-btn"
|
||||
class:active={activeGroupId === group.id}
|
||||
onclick={() => setActiveGroup(group.id)}
|
||||
aria-label="{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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -326,32 +314,8 @@
|
|||
|
||||
<!-- Main workspace -->
|
||||
<main class="workspace">
|
||||
<!-- Draggable title bar area with window controls -->
|
||||
<div class="title-bar" aria-label="Window title bar">
|
||||
<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>
|
||||
<!-- Draggable region at top of workspace (no title bar chrome) -->
|
||||
<div class="drag-region" aria-hidden="true"></div>
|
||||
|
||||
<!-- Project grid -->
|
||||
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
|
||||
|
|
@ -378,7 +342,6 @@
|
|||
/>
|
||||
</div>
|
||||
{: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}">
|
||||
<ProjectCard
|
||||
id={row.parent.id}
|
||||
|
|
@ -399,7 +362,6 @@
|
|||
onClone={handleClone}
|
||||
/>
|
||||
{#each row.clones as clone (clone.id)}
|
||||
<!-- Chain link icon between cards -->
|
||||
<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">
|
||||
<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}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Status bar -->
|
||||
|
|
@ -462,9 +449,8 @@
|
|||
<span>stalled</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<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>
|
||||
|
|
@ -475,17 +461,13 @@
|
|||
|
||||
<span class="status-bar-spacer"></span>
|
||||
|
||||
<!-- Group indicator -->
|
||||
<span class="status-segment" title="Active group">
|
||||
<span aria-hidden="true">{activeGroup?.icon}</span>
|
||||
<span class="status-value">{activeGroup?.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="status-segment" title="Session duration">
|
||||
<span>session</span>
|
||||
<span class="status-value">{sessionDuration}</span>
|
||||
</span>
|
||||
|
||||
<span class="status-segment" title="Total tokens used">
|
||||
<span>tokens</span>
|
||||
<span class="status-value">{fmtTokens(totalTokens)}</span>
|
||||
|
|
@ -495,20 +477,6 @@
|
|||
<span class="status-value">{fmtCost(totalCost)}</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>
|
||||
</footer>
|
||||
|
||||
|
|
@ -523,7 +491,7 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────── */
|
||||
/* ── Left sidebar ─────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
|
|
@ -536,26 +504,24 @@
|
|||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Vertical AGOR title */
|
||||
.agor-title {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--ui-font-family);
|
||||
font-weight: 800;
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ctp-overlay1);
|
||||
padding: 0.625rem 0;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 1rem 0;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Group icons section */
|
||||
.sidebar-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
|
|
@ -564,6 +530,57 @@
|
|||
|
||||
.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 {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
|
@ -577,31 +594,13 @@
|
|||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.sidebar-icon svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Active group: accent border-left indicator */
|
||||
.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 ────────────────────────────────────────────── */
|
||||
/* ── Main workspace ───────────────────────────────────────── */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -610,64 +609,13 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Title bar (custom chrome) ────────────────────────────── */
|
||||
.title-bar {
|
||||
height: 2rem;
|
||||
/* Invisible draggable region at top of workspace */
|
||||
.drag-region {
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
background: var(--ctp-crust);
|
||||
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 ─────────────────────────────────────────── */
|
||||
|
|
@ -687,7 +635,6 @@
|
|||
.project-grid::-webkit-scrollbar-track { background: transparent; }
|
||||
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
|
||||
|
||||
/* Clone group: spans both grid columns, flex row */
|
||||
.clone-group-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
|
|
@ -697,13 +644,8 @@
|
|||
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 {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
|
|
@ -713,12 +655,8 @@
|
|||
color: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.chain-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.chain-icon svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Empty group placeholder */
|
||||
.empty-group {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
|
|
@ -730,9 +668,88 @@
|
|||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.empty-group-icon { font-size: 2rem; }
|
||||
.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 {
|
||||
height: var(--status-bar-height);
|
||||
|
|
@ -765,50 +782,12 @@
|
|||
.status-dot-sm.gray { background: var(--ctp-overlay0); }
|
||||
.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; }
|
||||
|
||||
.attn-badge { color: 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 {
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--ctp-surface0);
|
||||
|
|
|
|||
161
ui-electrobun/src/mainview/NotifDrawer.svelte
Normal file
161
ui-electrobun/src/mainview/NotifDrawer.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue