feat: complete UI redesign — editorial-paper palette, Monaco theme, lucide icons, primitives

Palette overhaul:
- Warm cream canvas (#faf8f4) + near-white surfaces (#fdfdfb) + deep teal accent (#0f766e)
- Unified on stone-warm neutrals via @theme block in index.css

Typography:
- Inter Variable + JetBrains Mono Variable via fontsource (self-hosted)
- Monaco editor set to JetBrains Mono for consistency

Icons:
- lucide-react replaces all 8 hand-rolled SVGs
- Leaf brand mark beside wordmark

Shared primitives (src/components/ui/):
- Icon, Button (4 variants, 2 sizes), IconButton, TextInput, SectionHeader, StatusDot, Chip

Custom Monaco theme (src/theme/monaco-theme.ts):
- typst-leaf-light: warm near-white background, restrained rainbow-free syntax
- Keywords teal, strings warm green, comments stone italic, most tokens ink

Component reworks:
- TopBar: Leaf icon, h-12, chip-based status, Button/IconButton primitives
- Sidebar: w-72, icon+label tabs with teal bottom border
- StatusBar: lucide GitBranch/ArrowUp/ArrowDown, chip-based status
- FileTree: lucide Plus/FolderPlus/Pencil/Trash2, teal active state
- GitPanel: lucide status icons, restored staged/unstaged bg tint
- Preview: RotateCw reload icon, chip status
- EmptyState: muted Leaf icon
- ToastHost: StatusDot + lucide X

Fixes:
- GitPanel staged/unstaged/untracked rows now have distinct bg tints
- Button defaults to type="button" to prevent accidental form submission
- Editor error fallback from red-400 to rose-600
- DESIGN.md: removed emoji from layout skeleton, fixed Modals/Monaco typo

Build: pnpm -r typecheck + pnpm build pass.
This commit is contained in:
2026-07-03 13:34:47 +05:30
parent 008dab1cf9
commit ae0d888ddf
28 changed files with 616 additions and 622 deletions
+59 -65
View File
@@ -1,19 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Pencil, X, HelpCircle, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from "lucide-react";
import { api } from "../api";
import { useApp } from "../app-state";
import type { CommitInfo } from "../types";
import { Icon } from "./ui/Icon";
import { Button } from "./ui/Button";
const statusIcon: Record<string, string> = {
A: "+",
M: "~",
D: "",
"?": "?",
};
const statusColor: Record<string, string> = {
staged: "text-emerald-600",
unstaged: "text-amber-600",
untracked: "text-zinc-400",
const statusIcon: Record<string, React.ReactNode> = {
A: <Icon icon={Plus} className="size-3 text-teal-600" />,
M: <Icon icon={Pencil} className="size-3 text-amber-600" />,
D: <Icon icon={X} className="size-3 text-rose-500" />,
"?": <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />,
};
export function GitPanel() {
@@ -151,45 +148,56 @@ export function GitPanel() {
f: { path: string; status: string },
group: "staged" | "unstaged" | "untracked",
) {
const groupClasses: Record<string, string> = {
staged: "bg-teal-50 hover:bg-teal-100",
unstaged: "hover:bg-surface-hover",
untracked: "hover:bg-surface-hover",
};
return (
<div
key={f.path}
className="flex items-center gap-1 text-xs font-mono cursor-pointer hover:bg-stone-100 px-1 py-0.5 rounded"
className={`flex items-center gap-1.5 text-xs font-mono cursor-pointer px-1 py-0.5 rounded transition-colors ${groupClasses[group]}`}
onClick={() => dispatch({ type: "openFile", path: f.path })}
>
<span className={`w-3 text-center ${statusColor[group]}`}>
{statusIcon[f.status] ?? "?"}
<span className="w-3.5 flex items-center justify-center">
{statusIcon[f.status] ?? <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />}
</span>
<span className="truncate text-zinc-600">{f.path}</span>
<span className="truncate text-ink-muted">{f.path}</span>
</div>
);
}
return (
<div className="border-t border-zinc-200">
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
<div className="border-t border-line">
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-ink-subtle flex items-center gap-1">
<span>Git</span>
{s && (
<span className="ml-auto text-zinc-400 font-mono normal-case">
{s.branch}
{s.ahead > 0 && <span className="text-emerald-600 ml-1">{s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600 ml-1">{s.behind}</span>}
<span className="ml-auto text-ink-muted font-mono normal-case flex items-center gap-1">
<span>{s.branch}</span>
{s.ahead > 0 && (
<span className="text-teal-600 flex items-center gap-0.5">
<Icon icon={ArrowUp} className="size-2.5" />{s.ahead}
</span>
)}
{s.behind > 0 && (
<span className="text-amber-600 flex items-center gap-0.5">
<Icon icon={ArrowDown} className="size-2.5" />{s.behind}
</span>
)}
</span>
)}
</div>
<div className="px-3 pb-1.5 text-xs text-zinc-500">
<div className="px-3 pb-1.5 text-xs text-ink-muted">
{s && !s.clean && (
<span className="text-amber-600">
{s.unstaged.length + s.staged.length} uncommitted
</span>
)}
{s && s.clean && <span className="text-emerald-600">clean</span>}
{s && s.clean && <span className="text-teal-600">clean</span>}
</div>
{/* Changed files — dedupe by path; unstaged wins for paths in both
buckets (a file can be both staged and unstaged for different
parts, but unstaged is what the user still needs to act on). */}
{/* Changed files — dedupe by path; unstaged wins */}
{s && !s.clean && (() => {
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" });
@@ -197,7 +205,7 @@ export function GitPanel() {
const rows = Array.from(byPath.values());
return (
<div className="px-3 pb-1">
<div className="text-[10px] uppercase tracking-wider text-zinc-400 mb-0.5">
<div className="text-[10px] uppercase tracking-wider text-ink-subtle mb-0.5">
Changed files
</div>
{rows.map((f) => renderFileRow(f, f.group))}
@@ -218,35 +226,34 @@ export function GitPanel() {
}}
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
disabled={!hasChanges}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed"
className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100 transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
/>
<button
type="button"
onClick={() => void handleCommit()}
disabled={!canCommit}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Button size="sm" onClick={() => void handleCommit()} disabled={!canCommit}>
{busy ? "..." : "Commit"}
</button>
</Button>
</div>
<div className="flex gap-1">
<button
type="button"
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => void handlePush()}
disabled={pushBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
<Icon icon={ArrowUp} className="size-3" />
{pushBusy ? "..." : "Push"}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => void handlePull()}
disabled={pullBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
<Icon icon={ArrowDown} className="size-3" />
{pullBusy ? "..." : "Pull"}
</button>
</Button>
</div>
{showRemoteInput && (
@@ -256,16 +263,11 @@ export function GitPanel() {
value={remoteUrl}
onChange={(e) => setRemoteUrl(e.target.value)}
placeholder="git remote URL"
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100"
/>
<button
type="button"
onClick={() => void handleSetRemote()}
disabled={!remoteUrl.trim()}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
<Button size="sm" onClick={() => void handleSetRemote()} disabled={!remoteUrl.trim()}>
Set
</button>
</Button>
</div>
)}
@@ -276,23 +278,15 @@ export function GitPanel() {
{/* Commit log */}
{log.length > 0 && (
<div className="border-t border-zinc-200">
<div className="border-t border-line">
<button
type="button"
onClick={() => setLogOpen(!logOpen)}
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide"
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-ink-subtle hover:text-ink uppercase tracking-wide transition-colors"
>
<span>Log</span>
<span className="ml-auto">
{logOpen ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" />
</svg>
)}
<Icon icon={logOpen ? ChevronDown : ChevronRight} className="size-3" />
</span>
</button>
{logOpen && (
@@ -300,12 +294,12 @@ export function GitPanel() {
{log.map((c) => (
<div
key={c.sha}
className="text-xs text-zinc-500 leading-tight"
className="text-xs text-ink-muted leading-tight"
title={`${c.sha}${c.author} on ${c.date}`}
>
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
<span className="font-mono text-ink-subtle">{c.sha.slice(0, 7)}</span>
{" "}
<span className="text-zinc-600">{c.message}</span>
<span className="text-ink">{c.message}</span>
</div>
))}
</div>