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
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="A self-hosted, browser-first IDE for Typst." />
<meta name="theme-color" content="#faf8f4" />
<title>typst-leaf</title>
</head>
+5 -2
View File
@@ -11,9 +11,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.23.0",
"monaco-editor": "^0.55.1",
"monaco-vim": "^0.4.4",
"react": "^19.1.0",
@@ -22,13 +25,13 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.6",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.1.6",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
},
"packageManager": "pnpm@10.23.0"
}
+29 -39
View File
@@ -11,12 +11,20 @@ import { EmptyState } from "./components/EmptyState";
import { PanelSection } from "./components/PanelSection";
import { SettingsPanel } from "./components/SettingsPanel";
import { ResizableSplit } from "./components/ResizableSplit";
import { Files, GitGraph, Settings } from "lucide-react";
import { Icon } from "./components/ui/Icon";
function Shell() {
const { state, dispatch } = useApp();
const panelTabs = [
{ id: "files" as const, label: "Files", icon: Files },
{ id: "git" as const, label: "Git", icon: GitGraph },
{ id: "settings" as const, label: "Settings", icon: Settings },
];
return (
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
<div className="h-screen w-screen bg-canvas text-ink flex flex-col overflow-hidden">
<TopBar />
{state.error && (
@@ -27,51 +35,33 @@ function Shell() {
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<aside className="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0 shrink-0">
<aside className="w-72 border-r border-line bg-canvas flex flex-col min-h-0 shrink-0">
<ProjectPicker />
{/* Sidebar tabs */}
<div className="flex border-b border-zinc-200 text-xs">
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "files"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Files
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "git"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Git
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "settings" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "settings"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Settings
</button>
<div className="flex border-b border-line text-xs">
{panelTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: tab.id })}
className={`flex items-center justify-center gap-1.5 flex-1 py-1.5 transition-colors duration-150 ${
state.activePanel === tab.id
? "text-teal-600 border-b-2 border-teal-500 bg-surface"
: "text-ink-muted hover:text-ink hover:bg-surface-hover"
}`}
>
<Icon icon={tab.icon} className="size-3.5" />
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto">
{state.activePanel === "files" && (
<PanelSection title="Files">
{state.currentProject ? <FileTree /> : (
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
<div className="px-3 py-2 text-xs text-ink-subtle">select a project</div>
)}
</PanelSection>
)}
@@ -81,7 +71,7 @@ function Shell() {
</aside>
{/* Main workspace */}
<main className="flex-1 flex min-w-0 bg-white">
<main className="flex-1 flex min-w-0 bg-surface">
{state.currentProject && state.openFile ? (
<ResizableSplit
left={<EditorView />}
@@ -103,7 +93,7 @@ function Shell() {
<div className="flex-1">
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
</div>
<div className="w-96 border-l border-zinc-200">
<div className="w-96 border-l border-line">
<Preview />
</div>
</div>
+14 -8
View File
@@ -1,8 +1,9 @@
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
import Editor, { type BeforeMount, type Monaco, type OnMount } from "@monaco-editor/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
import { encodeVirtualPathForFileUri } from "../path-utils";
import { defineTypstLeafTheme } from "../theme/monaco-theme";
type ITextModel = Monaco["editor"]["ITextModel"];
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
@@ -148,6 +149,10 @@ export function EditorView() {
const saveRef = useRef(save);
saveRef.current = save;
const handleBeforeMount: BeforeMount = (m) => {
defineTypstLeafTheme(m);
};
const handleMount: OnMount = (editor, m) => {
editorRef.current = editor;
setEditorReady(true);
@@ -239,7 +244,7 @@ export function EditorView() {
if (error) {
return (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
<div className="flex-1 flex items-center justify-center text-rose-600 text-sm p-4">
{error}
</div>
);
@@ -247,20 +252,20 @@ export function EditorView() {
return (
<div className="h-full flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-line text-xs text-ink-muted font-mono">
<span className="truncate max-w-48">{file}</span>
<span className="ml-auto flex items-center gap-2">
<div
ref={vimStatusRef}
className="text-xs text-zinc-400 min-w-12"
className="text-xs text-ink-subtle min-w-12"
/>
<button
type="button"
onClick={toggleVim}
className={`px-2 py-0.5 rounded border text-xs ${
className={`px-2 py-0.5 rounded-md border text-xs transition-colors duration-150 ${
state.vimOn
? "bg-emerald-100 border-emerald-300 text-emerald-700"
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
? "bg-teal-50 border-teal-200 text-teal-700"
: "bg-surface text-ink-muted border-line-strong hover:bg-surface-hover"
}`}
>
Vim
@@ -270,7 +275,8 @@ export function EditorView() {
<div ref={editorContainerRef} className="flex-1 min-h-0">
<Editor
defaultLanguage="typst"
theme="vs"
theme="typst-leaf-light"
beforeMount={handleBeforeMount}
onMount={handleMount}
onChange={() => {
if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true });
@@ -1,3 +1,6 @@
import { Leaf } from "lucide-react";
import { Icon } from "./ui/Icon";
export function EmptyState({
message,
hint,
@@ -6,10 +9,11 @@ export function EmptyState({
hint?: string;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-1 p-4 text-center">
<div className="flex-1 flex flex-col items-center justify-center text-ink-subtle gap-2 p-4 text-center">
<Icon icon={Leaf} className="size-8 text-line" />
<div className="text-sm">{message}</div>
{hint && (
<div className="text-xs text-zinc-500">{hint}</div>
<div className="text-xs text-ink-subtle">{hint}</div>
)}
</div>
);
+21 -36
View File
@@ -1,12 +1,14 @@
import { useRef, useState } from "react";
import { Plus, FolderPlus, Pencil, Trash2 } from "lucide-react";
import { api } from "../api";
import { useApp } from "../app-state";
import { Icon } from "./ui/Icon";
interface TreeNode {
name: string;
children: TreeNode[];
file?: string; // present for leaf nodes → virtual path
folder?: string; // present for empty directory nodes → virtual path
file?: string;
folder?: string;
}
function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
@@ -31,18 +33,13 @@ function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
for (const p of filePaths) ensure(p.split("/"), true, p);
for (const p of folderPaths) {
const parts = p.split("/");
// Only create a node if the folder is not already represented as an
// ancestor of a known file (those are inferred directory nodes).
let exists = false;
let level = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
const isLast = i === parts.length - 1;
const node = level.find((n) => n.name === part);
if (!node) {
exists = false;
break;
}
if (!node) { exists = false; break; }
if (isLast) { exists = true; break; }
level = node.children;
}
@@ -62,7 +59,6 @@ export function FileTree() {
} | null>(null);
const [createValue, setCreateValue] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
// Guard against double-submit (Enter + blur both call the handler).
const submittingRef = useRef(false);
const dirtyFiles = new Set<string>();
@@ -88,7 +84,6 @@ export function FileTree() {
setRenaming(null);
return;
}
// Keep .typ files visible in the tree: require the extension to stay present.
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
newName += ".typ";
}
@@ -168,38 +163,33 @@ export function FileTree() {
function renderNode(node: TreeNode, depth: number, parentPath: string) {
const fullPath = parentPath ? parentPath + "/" + node.name : node.name;
// Directory if it has children or is an explicitly-known empty folder
const isDir = (node.children.length > 0 && !node.file) || !!node.folder;
if (isDir) {
return (
<div key={fullPath} className="group">
<div
className="flex items-center gap-1 px-2 py-0.5 text-xs text-zinc-400 font-medium tracking-wide"
className="flex items-center gap-1 px-2 py-0.5 text-xs text-ink-subtle font-medium tracking-wide"
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
<span>{node.name}</span>
<button
type="button"
onClick={() => startCreate(fullPath, "file")}
className="ml-1 text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
className="ml-1 text-ink-subtle hover:text-ink opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
title="New file"
aria-label="New file"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M8.75 1.75a.75.75 0 0 0-1.5 0v5.5h-5.5a.75.75 0 0 0 0 1.5h5.5v5.5a.75.75 0 0 0 1.5 0v-5.5h5.5a.75.75 0 0 0 0-1.5h-5.5v-5.5Z" />
</svg>
<Icon icon={Plus} className="size-3" />
</button>
<button
type="button"
onClick={() => startCreate(fullPath, "folder")}
className="text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
className="text-ink-subtle hover:text-ink opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
title="New folder"
aria-label="New folder"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z" />
</svg>
<Icon icon={FolderPlus} className="size-3" />
</button>
</div>
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
@@ -230,7 +220,7 @@ export function FileTree() {
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void handleRename(node.file!)}
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
className="flex-1 text-xs px-1 py-0.5 border border-teal-400 rounded bg-surface outline-none"
autoFocus
/>
</form>
@@ -240,15 +230,15 @@ export function FileTree() {
onClick={() => handleOpen(node.file!)}
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
isActive
? "bg-emerald-50 text-emerald-800 font-medium"
: "hover:bg-stone-100 text-zinc-600"
? "bg-teal-50 text-teal-700 font-medium"
: "hover:bg-surface-hover text-ink-muted"
}`}
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
{node.name}
{isDirty && (
<span className="ml-1 text-amber-500" aria-label="modified">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5 inline">
<circle cx="4" cy="4" r="4" />
</svg>
</span>
@@ -260,26 +250,22 @@ export function FileTree() {
<button
type="button"
onClick={() => startRename(node.file!)}
className="text-zinc-400 hover:text-zinc-600 p-0.5"
className="text-ink-muted hover:text-ink p-0.5 rounded"
title="Rename"
aria-label="Rename"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064l6.286-6.286Z" />
</svg>
<Icon icon={Pencil} className="size-3" />
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
}}
className="text-zinc-400 hover:text-rose-500 p-0.5"
className="text-ink-muted hover:text-rose-500 p-0.5 rounded"
title="Delete"
aria-label="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.152A2 2 0 0 0 5.852 15.5h4.296a2 2 0 0 0 1.987-1.848l.815-8.152h.3a.75.75 0 0 0 0-1.5H11v-.75A1.75 1.75 0 0 0 9.25 1.75h-2.5A1.75 1.75 0 0 0 5 3.25Zm2.25-.75a.25.25 0 0 0-.25.25V4h2V2.75a.25.25 0 0 0-.25-.25h-2.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .787-.712Z" clipRule="evenodd" />
</svg>
<Icon icon={Trash2} className="size-3" />
</button>
</span>
)}
@@ -289,20 +275,19 @@ export function FileTree() {
return (
<div className="flex flex-col gap-0.5 py-1">
{/* Create buttons at root */}
{state.currentProject && (
<div className="flex items-center gap-1 px-3 py-1">
<button
type="button"
onClick={() => startCreate("", "file")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
className="text-xs text-ink-muted hover:text-ink px-1 py-0.5 rounded transition-colors"
>
New file
</button>
<button
type="button"
onClick={() => startCreate("", "folder")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
className="text-xs text-ink-muted hover:text-ink px-1 py-0.5 rounded transition-colors"
>
New folder
</button>
@@ -325,7 +310,7 @@ export function FileTree() {
onBlur={() => void handleCreate()}
onKeyDown={(e) => e.key === "Escape" && setCreating(null)}
placeholder={creating.type === "file" ? "file.typ" : "folder-name"}
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
className="flex-1 text-xs px-1 py-0.5 border border-teal-400 rounded bg-surface outline-none"
autoFocus
/>
</form>
+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>
@@ -10,8 +10,8 @@ export function PanelSection({
actions?: ReactNode;
}) {
return (
<div className="border-b border-zinc-200 last:border-b-0">
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-zinc-400">
<div className="border-b border-line last:border-b-0">
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-ink-subtle">
<span>{title}</span>
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
</div>
+20 -28
View File
@@ -1,37 +1,28 @@
import { useRef } from "react";
import { RotateCw } from "lucide-react";
import { useApp } from "../app-state";
import { Chip } from "./ui/Chip";
import { Icon } from "./ui/Icon";
export function Preview() {
const { state, lsp } = useApp();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const statusText = () => {
if (!state.currentProject) return "no project";
if (!state.openFile) return "open a file";
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
if (!lsp.previewReady) return "starting…";
return "live";
};
const statusColor = () => {
if (lsp.previewReady) return "text-emerald-600 bg-emerald-50";
if (lsp.status === "ready") return "text-zinc-400 bg-stone-50";
return "text-amber-600 bg-amber-50";
};
const statusDot = () => {
if (lsp.previewReady) return "bg-emerald-500";
return "bg-zinc-300";
const statusChip = () => {
if (!state.currentProject) return <Chip tone="neutral">no project</Chip>;
if (!state.openFile) return <Chip tone="neutral">open a file</Chip>;
if (lsp.status !== "ready") return <Chip tone="amber" dot dotPulse>LSP {lsp.status}</Chip>;
if (!lsp.previewReady) return <Chip tone="neutral">starting...</Chip>;
return <Chip tone="teal" dot>live</Chip>;
};
return (
<div className="h-full flex flex-col bg-white">
<div className="h-full flex flex-col bg-surface">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 shrink-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-line text-xs text-ink-muted shrink-0">
<span className="font-medium">Preview</span>
<span className={`ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] ${statusColor()}`}>
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
{statusText()}
<span className="ml-auto flex items-center gap-1">
{statusChip()}
</span>
{lsp.previewReady && (
<button
@@ -39,16 +30,17 @@ export function Preview() {
onClick={() => {
iframeRef.current?.contentWindow?.location.reload();
}}
className="px-2 py-0.5 rounded border border-zinc-200 hover:bg-stone-100 text-zinc-500"
className="p-1 rounded-md text-ink-muted hover:bg-surface-hover transition-colors"
aria-label="Reload preview"
>
Reload
<Icon icon={RotateCw} className="size-3.5" />
</button>
)}
<span className="text-zinc-300 text-[11px] hidden sm:inline">Click preview to jump to source</span>
<span className="text-ink-subtle text-[11px] hidden sm:inline">Click preview to jump to source</span>
</div>
{/* Content */}
<div className="flex-1 bg-zinc-50 relative min-h-0">
<div className="flex-1 bg-surface relative min-h-0">
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
<iframe
ref={iframeRef}
@@ -58,8 +50,8 @@ export function Preview() {
className="absolute inset-0 w-full h-full bg-white"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
<span>{statusText()}</span>
<div className="absolute inset-0 flex flex-col items-center justify-center text-ink-subtle text-sm p-6 gap-2">
<span>{statusChip()}</span>
{lsp.status === "error" && lsp.error && (
<pre className="mt-2 max-w-full overflow-auto text-xs text-rose-600 bg-rose-50 p-3 rounded whitespace-pre-wrap break-all">
{lsp.error}
@@ -1,6 +1,8 @@
import { useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
import { Button } from "./ui/Button";
import { TextInput } from "./ui/TextInput";
export function ProjectPicker() {
const { state, dispatch } = useApp();
@@ -24,8 +26,8 @@ export function ProjectPicker() {
}
return (
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
<div className="text-xs uppercase tracking-wide text-zinc-400">
<div className="flex flex-col gap-2 p-2 border-b border-line">
<div className="text-xs uppercase tracking-wide text-ink-subtle">
Projects
</div>
<ul className="flex flex-col gap-0.5">
@@ -34,10 +36,10 @@ export function ProjectPicker() {
<button
type="button"
onClick={() => select(p)}
className={`w-full text-left text-sm px-2 py-1 rounded ${
className={`w-full text-left text-sm px-2 py-1 rounded flex items-center gap-1.5 ${
p === state.currentProject
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
? "bg-teal-50 text-teal-700 font-medium border-l-2 border-teal-500"
: "hover:bg-surface-hover text-ink-muted border-l-2 border-transparent"
}`}
>
{p}
@@ -45,26 +47,21 @@ export function ProjectPicker() {
</li>
))}
{state.projects.length === 0 && (
<li className="text-xs text-zinc-400 px-2">no projects yet</li>
<li className="text-xs text-ink-subtle px-2">no projects yet</li>
)}
</ul>
<div className="flex gap-1">
<input
<TextInput
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create();
}}
placeholder="new-project"
className="flex-1 bg-white text-zinc-800 text-sm px-2 py-1 rounded border border-zinc-300 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 placeholder:text-zinc-400"
/>
<button
type="button"
onClick={create}
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
>
<Button size="sm" onClick={create}>
New
</button>
</Button>
</div>
</div>
);
@@ -66,7 +66,7 @@ export function ResizableSplit({
<div
role="separator"
aria-orientation="vertical"
className="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize shrink-0 transition-colors duration-75"
className="w-1 bg-line hover:bg-teal-400 cursor-col-resize shrink-0 transition-colors duration-75"
onPointerDown={startDrag}
onDoubleClick={() => {
const pct = DEFAULT_PCT;
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "./ui/Button";
import { TextInput } from "./ui/TextInput";
const STORAGE_KEY = "typst-leaf.git-identity";
@@ -43,12 +45,12 @@ export function SettingsPanel() {
}, [name, email]);
return (
<div className="px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
<div className="px-3 pt-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-ink-subtle">
Git Identity
</div>
<input
<TextInput
type="text"
value={name}
onChange={(e) => {
@@ -56,10 +58,9 @@ export function SettingsPanel() {
setDirty(true);
}}
placeholder="Author name"
className="w-full 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"
/>
<input
<TextInput
type="email"
value={email}
onChange={(e) => {
@@ -67,33 +68,27 @@ export function SettingsPanel() {
setDirty(true);
}}
placeholder="Author email"
className="w-full 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"
/>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
<Button size="sm" onClick={save} disabled={!dirty}>
Save
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</Button>
</div>
<div className="text-xs text-zinc-400">
<div className="text-xs text-ink-subtle">
Used for Git commits. Stored locally in browser.
</div>
</div>
@@ -1,52 +1,62 @@
import { GitBranch, ArrowUp, ArrowDown } from "lucide-react";
import { useApp } from "../app-state";
import { Chip } from "./ui/Chip";
import { Icon } from "./ui/Icon";
export function StatusBar() {
const { state, lsp } = useApp();
const s = state.gitStatus;
const gitSummary = () => {
if (!s) return <span className="text-xs text-zinc-400">git · --</span>;
if (!s) return <span className="text-xs text-ink-subtle">git · --</span>;
return (
<span className="text-xs text-zinc-500 flex items-center gap-1">
<span className="text-xs text-ink-muted flex items-center gap-1">
<Icon icon={GitBranch} className="size-3 text-ink-subtle" />
<span className="font-mono">{s.branch}</span>
{s.clean ? (
<span className="text-emerald-600">· clean</span>
<span className="text-teal-600">· clean</span>
) : (
<span className="text-amber-600">· {s.unstaged.length + s.staged.length} uncommitted</span>
)}
{s.ahead > 0 && <span className="text-emerald-600">· {s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600">· {s.behind}</span>}
{s.ahead > 0 && (
<span className="text-teal-600 flex items-center gap-0.5">
<Icon icon={ArrowUp} className="size-3" /> {s.ahead}
</span>
)}
{s.behind > 0 && (
<span className="text-amber-600 flex items-center gap-0.5">
<Icon icon={ArrowDown} className="size-3" /> {s.behind}
</span>
)}
</span>
);
};
const lspStatus = () => {
if (lsp.status === "ready") return <span className="text-xs text-emerald-600">LSP · ready</span>;
if (lsp.status === "connecting") return <span className="text-xs text-amber-600">LSP · connecting</span>;
if (lsp.status === "error") return <span className="text-xs text-rose-600">LSP · error</span>;
return <span className="text-xs text-zinc-400">LSP · idle</span>;
if (lsp.status === "ready") return <Chip tone="teal" dot>LSP</Chip>;
if (lsp.status === "connecting") return <Chip tone="amber" dot dotPulse>LSP</Chip>;
if (lsp.status === "error") return <Chip tone="rose" dot>LSP</Chip>;
return <Chip tone="neutral">LSP · idle</Chip>;
};
const previewStatus = () => {
if (!state.openFile) return null;
if (lsp.previewReady) return <span className="text-xs text-emerald-600">Preview · live</span>;
if (lsp.status === "ready" && !lsp.previewReady) return <span className="text-xs text-zinc-400">Preview · starting</span>;
if (lsp.previewReady) return <Chip tone="teal" dot>Preview</Chip>;
if (lsp.status === "ready" && !lsp.previewReady) return <Chip tone="neutral">Preview · starting</Chip>;
return null;
};
return (
<footer className="flex items-center gap-4 border-t border-zinc-200 bg-white px-4 h-7 shrink-0">
<div className="flex items-center gap-2">
{gitSummary()}
</div>
<span className="text-zinc-200 select-none">|</span>
<footer className="flex items-center gap-4 border-t border-line bg-canvas px-4 h-7 shrink-0">
<div className="flex items-center gap-2">{gitSummary()}</div>
<span className="text-line select-none">|</span>
<div className="flex items-center gap-2">
{lspStatus()}
{previewStatus()}
{state.vimOn && <span className="text-xs text-zinc-400">vim</span>}
{state.vimOn && <span className="text-xs text-ink-subtle font-mono">vim</span>}
</div>
<span className="ml-auto text-xs text-zinc-400">
{state.saving && <span className="text-amber-500">saving</span>}
<span className="ml-auto text-xs text-ink-subtle">
{state.saving && <span className="text-amber-600">saving</span>}
</span>
</footer>
);
@@ -1,5 +1,8 @@
import { useEffect } from "react";
import { X } from "lucide-react";
import { useApp } from "../app-state";
import { StatusDot } from "./ui/StatusDot";
import { Icon } from "./ui/Icon";
export function ToastHost() {
const { state, dispatch } = useApp();
@@ -12,33 +15,32 @@ export function ToastHost() {
if (!state.notice) return null;
const colors = {
success: "bg-emerald-50 text-emerald-800 border-emerald-200",
const colors: Record<string, string> = {
success: "bg-teal-50 text-teal-800 border-teal-200",
error: "bg-rose-50 text-rose-800 border-rose-200",
info: "bg-stone-50 text-zinc-700 border-zinc-200",
info: "bg-surface text-ink border-line",
};
const dots = {
success: "bg-emerald-500",
error: "bg-rose-500",
info: "bg-zinc-400",
const dotColor: Record<string, "teal" | "rose" | "neutral"> = {
success: "teal",
error: "rose",
info: "neutral",
};
return (
<div className="fixed bottom-8 right-4 z-50 flex flex-col gap-2">
<div className="fixed bottom-8 right-4 z-50">
<div
className={`border rounded-md shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
className={`border rounded-lg shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
>
<span className={`inline-block size-2 rounded-full shrink-0 ${dots[state.notice.kind]}`} />
<StatusDot color={dotColor[state.notice.kind]} />
{state.notice.message}
<button
type="button"
onClick={() => dispatch({ type: "dismissNotice" })}
className="ml-2 text-zinc-400 hover:text-zinc-600"
className="ml-2 text-ink-subtle hover:text-ink transition-colors"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
<Icon icon={X} className="size-3" />
</button>
</div>
</div>
+34 -54
View File
@@ -1,4 +1,9 @@
import { Leaf, Settings } from "lucide-react";
import { useApp } from "../app-state";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { Icon } from "./ui/Icon";
import { IconButton } from "./ui/IconButton";
export function TopBar() {
const { state, dispatch, lsp } = useApp();
@@ -6,99 +11,74 @@ export function TopBar() {
const lspChip = () => {
switch (lsp.status) {
case "ready":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
LSP
</span>
);
return <Chip tone="teal" dot>LSP</Chip>;
case "connecting":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
LSP
</span>
);
return <Chip tone="amber" dot dotPulse>LSP</Chip>;
case "error":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-rose-500" />
LSP
</span>
);
return <Chip tone="rose" dot>LSP</Chip>;
default:
return null;
}
};
const previewChip = () => {
// No preview without an open file (tinymist previews a document).
if (!state.openFile) return null;
if (lsp.previewReady) {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
Preview
</span>
);
return <Chip tone="teal" dot>Preview</Chip>;
}
if (lsp.status === "ready") {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-400 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-zinc-300" />
Preview · starting
</span>
);
return <Chip tone="neutral" dot={false}>Preview · starting</Chip>;
}
return null;
};
return (
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11 shrink-0">
<span className="font-semibold tracking-tight text-zinc-800">typst-leaf</span>
<span className="text-zinc-300 select-none">·</span>
<header className="flex items-center gap-2 border-b border-line bg-canvas px-4 h-12 shrink-0">
<Icon icon={Leaf} className="text-teal-600 size-5" />
<span className="font-semibold tracking-tight text-ink">typst-leaf</span>
{state.currentProject ? (
<>
<span className="text-sm text-zinc-800 font-medium truncate max-w-36">
<span className="text-line select-none">·</span>
<span className="text-sm text-ink-muted font-medium truncate max-w-36">
{state.currentProject}
</span>
{state.openFile && (
<>
<span className="text-zinc-300 select-none">/</span>
<span className="text-sm font-mono text-zinc-500 truncate max-w-48">
<span className="text-line select-none">/</span>
<span className="text-sm font-mono text-ink-subtle truncate max-w-48">
{state.openFile}
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
</span>
</>
)}
<div className="ml-auto flex items-center gap-2">
<span className="ml-auto flex items-center gap-2">
{lspChip()}
{previewChip()}
<button
type="button"
<Button
size="sm"
onClick={() => {
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
}}
disabled={!state.openFile}
className="px-3 py-1 text-sm rounded bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: state.activePanel === "settings" ? "files" : "settings" })}
className={`p-1.5 rounded-md ${state.activePanel === "settings" ? "text-emerald-700 bg-emerald-50" : "text-zinc-500 hover:bg-stone-100"}`}
title="Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4">
<path d="M10 3.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 6.25a2.25 2.25 0 0 0 4.312-.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 3.75zM10 13.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 16.25a2.25 2.25 0 0 0 4.312.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 13.75z" />
</svg>
</button>
</div>
</Button>
<IconButton
icon={Settings}
label="Settings"
onClick={() =>
dispatch({
type: "setActivePanel",
panel: state.activePanel === "settings" ? "files" : "settings",
})
}
className={state.activePanel === "settings" ? "text-teal-600 bg-teal-50 hover:bg-teal-50" : ""}
/>
</span>
</>
) : (
<span className="text-sm text-zinc-400">Select a project to begin</span>
<span className="text-sm text-ink-subtle ml-auto">Select a project to begin</span>
)}
</header>
);
@@ -0,0 +1,35 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md";
}
export function Button({
variant = "primary",
size = "md",
className = "",
...props
}: ButtonProps) {
const base =
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors duration-150 active:scale-[0.98] disabled:opacity-40 disabled:pointer-events-none focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-2";
const sizes = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-1.5 text-sm",
};
const variants = {
primary: "bg-teal-600 text-white hover:bg-teal-700",
secondary:
"bg-surface text-ink-muted border border-line-strong hover:bg-surface-hover",
ghost: "text-ink-muted hover:bg-surface-hover",
danger: "bg-rose-600 text-white hover:bg-rose-700",
};
return (
<button
type="button"
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,26 @@
import { StatusDot } from "./StatusDot";
const chipStyles: Record<string, string> = {
teal: "bg-teal-50 text-teal-700 border-teal-200",
amber: "bg-amber-50 text-amber-700 border-amber-200",
rose: "bg-rose-50 text-rose-700 border-rose-200",
neutral: "bg-surface text-ink-muted border-line",
};
interface ChipProps {
tone?: "teal" | "amber" | "rose" | "neutral";
dot?: boolean;
dotPulse?: boolean;
children: React.ReactNode;
}
export function Chip({ tone = "neutral", dot = false, dotPulse = false, children }: ChipProps) {
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full border ${chipStyles[tone]}`}
>
{dot && <StatusDot color={tone === "neutral" ? "neutral" : tone} pulse={dotPulse} />}
{children}
</span>
);
}
@@ -0,0 +1,9 @@
import { type LucideIcon, type LucideProps } from "lucide-react";
interface IconProps extends LucideProps {
icon: LucideIcon;
}
export function Icon({ icon: LucideIcon, className = "", ...props }: IconProps) {
return <LucideIcon className={`size-4 stroke-[1.5] ${className}`} {...props} />;
}
@@ -0,0 +1,20 @@
import { type LucideIcon } from "lucide-react";
import { Icon } from "./Icon";
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: LucideIcon;
label: string;
}
export function IconButton({ icon, label, className = "", ...props }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
className={`inline-flex items-center justify-center size-7 rounded-md text-ink-muted hover:bg-surface-hover transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-1 ${className}`}
{...props}
>
<Icon icon={icon} />
</button>
);
}
@@ -0,0 +1,13 @@
interface SectionHeaderProps {
children: React.ReactNode;
actions?: React.ReactNode;
}
export function SectionHeader({ children, actions }: SectionHeaderProps) {
return (
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-ink-subtle">
<span className="truncate">{children}</span>
{actions && <span className="ml-auto flex items-center gap-0.5">{actions}</span>}
</div>
);
}
@@ -0,0 +1,19 @@
const dotColors: Record<string, string> = {
teal: "bg-teal-500",
amber: "bg-amber-500",
rose: "bg-rose-500",
neutral: "bg-ink-subtle",
};
interface StatusDotProps {
color?: "teal" | "amber" | "rose" | "neutral";
pulse?: boolean;
}
export function StatusDot({ color = "neutral", pulse = false }: StatusDotProps) {
return (
<span
className={`inline-block size-1.5 rounded-full ${dotColors[color]} ${pulse ? "animate-pulse" : ""}`}
/>
);
}
@@ -0,0 +1,12 @@
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
// styled input
}
export function TextInput({ className = "", ...props }: TextInputProps) {
return (
<input
className={`w-full 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 ${className}`}
{...props}
/>
);
}
+27
View File
@@ -1 +1,28 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter Variable", system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", monospace;
--color-canvas: #faf8f4;
--color-surface: #fdfdfb;
--color-surface-hover: #f3f1ec;
--color-ink: #1c1917;
--color-ink-muted: #78716c;
--color-ink-subtle: #a8a29e;
--color-line: #e7e5e4;
--color-line-strong: #d6d3d1;
--color-teal-50: #f0f9f8;
--color-teal-100: #d5edea;
--color-teal-500: #14b8a6;
--color-teal-600: #0f766e;
--color-teal-700: #115e59;
}
@layer base {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
+2
View File
@@ -1,5 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import App from "./App";
import "./index.css";
@@ -0,0 +1,54 @@
export function defineTypstLeafTheme(monaco: {
editor: {
defineTheme: (name: string, theme: object) => void;
};
}) {
monaco.editor.defineTheme("typst-leaf-light", {
base: "vs",
inherit: true,
rules: [
{ token: "keyword", foreground: "115e59", fontStyle: "bold" },
{ token: "tag", foreground: "115e59", fontStyle: "bold" },
{ token: "string", foreground: "047857" },
{ token: "string.quoted", foreground: "047857" },
{ token: "comment", foreground: "a8a29e", fontStyle: "italic" },
{ token: "number", foreground: "b45309" },
{ token: "type", foreground: "1c1917" },
{ token: "type.identifier", foreground: "1c1917" },
{ token: "function", foreground: "1c1917" },
{ token: "function.name", foreground: "1c1917" },
{ token: "operator", foreground: "78716c" },
{ token: "delimiter", foreground: "78716c" },
{ token: "variable", foreground: "1c1917" },
{ token: "parameter", foreground: "1c1917" },
{ token: "attribute", foreground: "0f766e" },
],
colors: {
"editor.background": "#fdfdfb",
"editor.foreground": "#1c1917",
"editorLineNumber.foreground": "#a8a29e",
"editorLineNumber.activeForeground": "#78716c",
"editor.selectionBackground": "#f0f9f8",
"editor.selectionHighlightBackground": "#d5edea80",
"editor.lineHighlightBackground": "#faf8f4",
"editorCursor.foreground": "#0f766e",
"editorWhitespace.foreground": "#e7e5e4",
"editorBracketMatch.background": "#d5edea",
"editorBracketMatch.border": "#0f766e40",
"editorGutter.background": "#faf8f4",
"editorRuler.foreground": "#e7e5e4",
"editorOverviewRuler.border": "#e7e5e4",
"editorWidget.background": "#fdfdfb",
"editorWidget.border": "#e7e5e4",
"editorSuggestWidget.background": "#fdfdfb",
"editorSuggestWidget.border": "#e7e5e4",
"editorSuggestWidget.selectedBackground": "#f0f9f8",
"editorHoverWidget.background": "#fdfdfb",
"editorHoverWidget.border": "#e7e5e4",
"editorIndentGuide.background": "#e7e5e4",
"editorIndentGuide.activeBackground": "#d6d3d1",
"editorInlayHint.background": "#f3f1ec",
"editorInlayHint.foreground": "#78716c",
},
});
}