feat(app): UI parity with Pencil mockup — top bar, panel cards, sidebar/event-center polish

Top bar (breadcrumb + actions + account), rounded panel cards with active
accent + rich headers, sidebar count pills/collapsible groups/daemon footer,
preset chips + scrollback pill, Event Center tabs + external-notify footer,
JetBrains Mono + Inter via @fontsource, shared theme tokens. Backend-absent
pieces are mocked (search, zoom, uptime, channels) pending SP1–SP5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 06:47:38 +07:00
parent 807eab3f6c
commit 36964c9f21
13 changed files with 458 additions and 89 deletions
+59 -29
View File
@@ -1,9 +1,8 @@
import { useState } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
const RING: Record<SurfaceState | "stopped", string> = {
error: "#F4544E", wait: "#F2B84B", work: "#4C8DFF", done: "#3FB950", idle: "#5A6573", stopped: "#5A6573",
};
function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
const running = Object.values(w.surfaces).filter((s) => s.running);
@@ -23,36 +22,67 @@ export function Sidebar({
onSelect: (id: string) => void;
onNew: () => void;
}) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
const ungrouped = byGroup(null);
const row = (w: WorkspaceView) => (
<div key={w.id} onClick={() => onSelect(w.id)}
style={{
display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer",
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${RING[aggregate(w)]}`, boxSizing: "border-box" }} />
<span style={{ flex: 1 }}>{w.name}</span>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
</div>
);
const row = (w: WorkspaceView) => {
const isActive = w.id === activeId;
return (
<div key={w.id} onClick={() => onSelect(w.id)}
style={{
display: "flex", alignItems: "center", gap: 10, height: 34, padding: "0 8px", borderRadius: 6, cursor: "pointer",
background: isActive ? COLORS.bgElevated : "transparent", fontFamily: FONT.ui, fontSize: 13,
color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
<span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, flex: "0 0 7px" }} />}
<span style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 18, minWidth: 18, padding: "0 6px", borderRadius: 9, background: COLORS.bgApp, fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>
{Object.keys(w.surfaces).length}
</span>
</div>
);
};
return (
<div style={{ width: 248, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", overflowY: "auto" }}>
<button onClick={onNew} style={{ width: "100%", padding: 8, marginBottom: 16, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 7 }}>+ New workspace</button>
{groups.sort((a, b) => a.order - b.order).map((g) => (
<div key={g.id} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "0 4px", marginBottom: 4 }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
<span style={{ fontFamily: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span>
</div>
{byGroup(g.id).map(row)}
</div>
))}
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
<button onClick={onNew}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7,
fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
}}>
<Plus size={15} />
New workspace
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>N</span>
</button>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2, minHeight: 0 }}>
{groups.sort((a, b) => a.order - b.order).map((g) => {
const open = !collapsed[g.id];
return (
<div key={g.id} style={{ marginBottom: 8 }}>
<div onClick={() => setCollapsed((c) => ({ ...c, [g.id]: open }))}
style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}>
{open ? <ChevronDown size={13} color={COLORS.textMuted} /> : <ChevronRight size={13} color={COLORS.textMuted} />}
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>{g.name.toUpperCase()}</span>
</div>
{open && byGroup(g.id).map(row)}
</div>
);
})}
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
</div>
{/* Daemon status footer — uptime is mocked until the daemon reports it. */}
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.stDone, flex: "0 0 7px" }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>spaceshd · live</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>3d 4h</span>
</div>
</div>
);
}