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
+109 -120
View File
@@ -1,171 +1,160 @@
# DESIGN.md — typst-leaf UI Rehaul
# DESIGN.md — typst-leaf UI
This document is the aesthetic reference for the next chunk in `PLAN.md`. Every UI decision in `PLAN.md` should match this guide. Direction is "document editor" — warmth, restraint, a thin layer of leaf.
## Vision
**"A fresh sheet of paper on a warm desk."** Refined editorial-paper aesthetic with a deep teal accent and warm cream neutrals. The app chrome (sidebar, topbar, statusbar) is a warm cream; editor and preview surfaces are a brighter near-white — so reading and writing happen on a sheet of paper that lifts off the desk.
## Brand
- Name: **typst-leaf** — warm, organic, paper-and-ink.
- Personality: quiet, considered, a serious editor for prose, papers, and beautiful documents.
- Not "developer marketing." Not "dashboard SaaS." Closer to a focused notebook.
- **Name:** typst-leaf — warm, organic, paper-and-ink.
- **Mark:** A teal `Leaf` icon (lucide) beside the wordmark.
- **Personality:** Quiet, considered, a serious editor for prose, papers, and beautiful documents. Like a focused notebook, not a dashboard.
## Aesthetic Pillars
## Palette (Tailwind v4 `@theme`)
1. **Paper, not glass.**
Surfaces are warm off-white, not slate. Borders separate panels; we don't blur or float.
2. **Functional color, decorative restraint.**
Greens only when communicating success/active/leaf. Use amber for warnings, slate for muted text, rose for errors. No gradients on chrome.
3. **Typography is the interface.**
System sans for chrome, monospace for paths/git/lsp status. We say things in words, not icons.
4. **A whisper, not a shout.**
Rounded corners are subtle (`rounded-md`). Focus rings are visible but quiet.
Custom tokens in `src/index.css`:
## Palette (Tailwind v4 via CSS-first)
| Token | Use | Tailwind class |
| Token | Value | Use |
|---|---|---|
| Page background | Shell chrome | `bg-stone-50` |
| Panel surface | Cards, panels | `bg-white` |
| Sidebar | Activity + sections | `bg-stone-50` with inner panels `bg-white` |
| Text primary | Body | `text-zinc-800` |
| Text muted | Meta | `text-zinc-500` |
| Text subtle | File paths | `text-zinc-400` |
| Divider | 1px borders | `border-zinc-200` |
| Hover surface | Buttons/list rows | `hover:bg-stone-100` |
| Accent (primary) | Save, commit | `bg-emerald-600 hover:bg-emerald-700 text-white` |
| Accent (soft) | Active file, focus | `bg-emerald-50 text-emerald-800 ring-emerald-200` |
| Warning | Behind | `text-amber-600` |
| Error | Dirty error | `text-rose-600` |
| Pill bg (status) | Status chips | `bg-white border-zinc-200` |
| `canvas` | `#faf8f4` | App chrome (sidebar, topbar, statusbar, app bg) |
| `surface` | `#fdfdfb` | Editor, preview, raised panels, inputs |
| `surface-hover` | `#f3f1ec` | Hover backgrounds |
| `ink` | `#1c1917` | Primary body text |
| `ink-muted` | `#78716c` | Secondary text, meta |
| `ink-subtle` | `#a8a29e` | Placeholders, subtle meta |
| `line` | `#e7e5e4` | Hairline borders, dividers |
| `line-strong` | `#d6d3d1` | Input borders |
We do not introduce dark classes. Tailwind v4 is in CSS-first mode (`@import "tailwindcss"`); tokens live in plain CSS, not `tailwind.config.js`.
| Token | Value | Use |
|---|---|---|
| `teal-50` | `#f0f9f8` | Active row tints, soft accents |
| `teal-100` | `#d5edea` | Focus ring, highlights |
| `teal-500` | `#14b8a6` | Status dots, focus border |
| `teal-600` | `#0f766e` | Primary actions, active text |
| `teal-700` | `#115e59` | Primary hover |
Functional: `amber-*` (warnings, dirty, behind), `rose-*` (errors, destructive). Monaco defaults (`zinc-*` border, body text) are left as-is.
No dark classes. Tailwind v4 CSS-first only.
## Typography
- UI: `font-sans` (system stack already from Tailwind).
- Monospace: `font-mono` for paths, git refs, LSP logs, status messages.
- Toolbar/header: `text-sm font-medium tracking-tight`.
- Meta and toasts: `text-xs text-zinc-500`.
- File names: `text-sm font-mono`.
- **UI:** `font-sans` → Inter Variable (via `@fontsource-variable/inter`).
- **Mono:** `font-mono` → JetBrains Mono Variable (via `@fontsource-variable/jetbrains-mono`).
- **Monaco editor** also uses JetBrains Mono for consistency.
- Brand wordmark: `font-semibold tracking-tight`.
- Section headers: `text-[11px] uppercase tracking-wider text-ink-subtle`.
- File paths: `font-mono text-ink-muted`.
- Meta: `text-xs text-ink-subtle`.
## Icons
lucide-react, tree-shakeable. Wrapped via `Icon` component with `size-4 stroke-[1.5] text-current`. Curated set: `Leaf`, `Plus`, `FolderPlus`, `Pencil`, `Trash2`, `ChevronDown`, `ChevronRight`, `X`, `Settings`, `RotateCw`, `GitBranch`, `ArrowUp`, `ArrowDown`, `Files`, `GitGraph`.
## Layout Skeleton
```
┌──────────────────────────────────────────────────────────────────────┐
│ TopBar: leaf · [project ▾] · main.typ · Save · ●LSP PRV │
│ TopBar: # typst-leaf · project · main.typ Save LSP PRV @
├──────────┬───────────────────────────────────────────────────────────┤
│ Sidebar │ │
│ Files │ │
▣ main │ Monaco Editor │
─────── │ Monaco Editor
│ ▣ main │ (typst-leaf-light theme) │
│ ▣ lib/ │ │
│ Git │ │
⚙ set │ │
│ ├─────────────────────────────────────┬─────────────────────┤
Settings├─────────────────────────────────────┬─────────────────────┤
│ │ Editor (resizable) │ Preview │
│ │ │ (iframe) │
│ │ │ │
└──────────┴─────────────────────────────────────┴─────────────────────┘
│ StatusBar: main · clean · ready · vim:n · 1:23
│ StatusBar: main · clean · LSP · Preview · vim · saving…
└──────────────────────────────────────────────────────────────────────┘
```
## Component Specs (Tailwind)
## Component Specs
### TopBar
```tsx
<header class="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11">
<span class="font-semibold tracking-tight">typst-leaf</span>
<span class="text-zinc-300">·</span>
<button class="text-sm hover:bg-stone-100 px-2 py-1 rounded-md">project </button>
<span class="ml-2 text-sm font-mono text-zinc-500 truncate">main.typ</span>
<span class="ml-auto flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">LSP · ready</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200">PRV · starting</span>
<button class="text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-md px-3 py-1">Save</button>
<button class="text-sm text-zinc-500 hover:bg-stone-100 px-2 py-1 rounded-md"></button>
</span>
</header>
```
- `h-12`, `bg-canvas`, `border-b border-line`.
- Left: `Leaf` icon (teal-600, size-5), "typst-leaf" (semibold, tracking-tight), middot divider, project name, `/` divider, file path (`font-mono text-ink-subtle`).
- Right: LSP/Preview Chip (hidden when no file), `Button primary` Save, `IconButton` Settings.
- When no project: text-centered "Select a project to begin".
### Sidebar (`PanelSection` + tabs)
```tsx
<aside class="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0">
<div class="px-3 py-2 text-[11px] uppercase tracking-wider text-zinc-400">Files</div>
<nav class="flex-1 overflow-auto p-1">
<button class="w-full text-left text-sm px-2 py-1 rounded-md bg-emerald-50 text-emerald-800">main.typ</button>
<button class="w-full text-left text-sm px-2 py-1 rounded-md hover:bg-stone-100">lib/util.typ</button>
</nav>
<div class="border-t border-zinc-200 p-2 text-xs text-zinc-500">12 uncommitted</div>
</aside>
```
### Sidebar
- `w-72`, `bg-canvas`, `border-r border-line`.
- ProjectPicker at top with `border-b border-line`.
- Three icon+label tabs (Files, Git, Settings): active = teal text + 2px teal bottom border on surface; inactive = ink-muted.
- Section headers: `uppercase tracking-wider text-ink-subtle text-[11px]`.
### StatusBar
- `h-7`, `bg-canvas`, `border-t border-line`.
- `GitBranch` icon + branch name, clean/dirty, ahead/behind with arrows.
- LSP/Preview Chip.
- Vim indicator and saving indicator.
### ResizableSplit
```tsx
<div class="flex h-full min-h-0">
<div class="min-w-[280px]" style={{ width: `${left}%` }}>/* editor */</div>
<div
role="separator"
aria-orientation="vertical"
class="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize"
onPointerDown={startDrag}
onDoubleClick={reset}
/>
<div class="flex-1 min-w-[280px]">/* preview */</div>
</div>
```
- `flex h-full min-h-0 w-full flex-1`.
- Divider: `w-1 bg-line hover:bg-teal-400`, `cursor-col-resize`, `transition-colors`.
- Panels: `min-w-[280px] overflow-hidden`.
### EmptyState
```tsx
<div class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-2">
<div class="text-sm">Open a file from the sidebar.</div>
<div class="text-xs text-zinc-500">Tip: P to jump, S to save.</div>
</div>
```
- Centered with muted `Leaf` icon, message, and optional hint.
- `text-ink-subtle`.
### Toast
```tsx
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div class="bg-white border border-zinc-200 rounded-md shadow-sm px-3 py-2 text-sm">
<span class="text-emerald-700 mr-2"></span>
Committed <span class="font-mono">a1b2c3d</span>
</div>
</div>
```
### ToastHost
- Fixed bottom-right, `rounded-lg shadow-sm`.
- `StatusDot` + message + lucide `X` close.
- Kinds: teal (success), rose (error), neutral (info).
### Status chips
```tsx
<span class="text-[11px] px-2 py-0.5 rounded-full border">
<span class="inline-block size-1.5 rounded-full bg-emerald-500 mr-1" />
LSP · ready
</span>
```
### Preview
- `bg-surface`, toolbar with "Preview" label, status Chip, `RotateCw` reload button.
- Content: full-height iframe or centered placeholder text.
### Editor
- `h-full flex flex-col min-h-0`. Toolbar: mono file path, Vim toggle, vim status.
- Monaco with custom `typst-leaf-light` theme (warm near-white background, restrained rainbow-free syntax palette).
### FileTree
- Nested tree with `hover:bg-surface-hover` rows, active file `bg-teal-50 text-teal-700`.
- Inline rename/create inputs with `border-teal-400`.
- Hover actions with lucide icons (Plus, FolderPlus, Pencil, Trash2).
- Dirty files marked with amber dot.
### GitPanel
- Section header with branch name, ahead/behind arrows.
- Changed files with lucide status icons (Plus/Pencil/X/HelpCircle).
- Button primary Commit, Button secondary Push/Pull with ArrowUp/ArrowDown icons.
- Collapsible Log with chevron.
### SettingsPanel
- "Git Identity" section.
- TextInputs for name/email, Button primary Save, Button secondary Clear.
## States
- **No project:** large empty state in main area; sidebar shows "No project yet."
- **Connecting:** preview shows "starting…" with a quiet spinner (CSS, not library).
- **External preview source:** toast "Source outside project — ignored."
- **Save error:** rose-600 message in toast + small dot in TopBar file path.
- **Git dirty:** emerald-50 row tint + small dot next to filename in tree.
- **No project:** large empty state in main area; sidebar shows project list.
- **Connecting:** amber pulse dot on LSP/Preview chips.
- **Error:** rose-600 text in error banner and toasts.
- **Save error:** rose toast + dirty marker on file name.
- **Git dirty:** amber dot on file in tree.
## Motion
- Hover: `transition-colors duration-100`.
- Splitter drag: no transition.
- Toast in/out: 150ms opacity, no spring.
- Hover/focus: `transition-colors duration-150`.
- Button press: `active:scale-[0.98]` on primary buttons.
- Toast: none (instant show/hide).
- Connecting dots: `animate-pulse`.
## Accessibility
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-1`.
- All interactive controls reachable by keyboard.
- Status communicated by text + color, never color alone.
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-2`.
- All interactive controls keyboard-reachable.
- State communicated by text + color, never color alone.
## Don'ts
- No gradients on chrome.
- No shadows on whitespace panels.
- No animated chrome unless it's a real state change.
- No emoji decorations in product UI (only as icons in narrow toolbars).
## Thumbnail: vibe check
> Imagine a warm, slightly off-white IDE with a single green accent for "commit ready" and a quiet three-dot status footer. It's the IDE equivalent of a blank page of cream paper, not a glass dashboard.
- No shadows on panels (only on toasts).
- No animated chrome unless real state change.
- No emoji in product UI — icons only from lucide.
- No dark mode.
-200
View File
@@ -1,200 +0,0 @@
# Next Chunk: IDE Shell, File Management, UI Rehaul, Reliability, Git Workflow
This chunk is the next deliverable on top of the `bfb4d8b` commit. It picks **options 1, 2, and 3**: IDE shell + file management + UI rehaul, reliability and LSP/Preview hardening, and Git workflow upgrade. Direction and aesthetic decisions live in `DESIGN.md` — every UI decision below should read against that doc.
## Guiding Constraints
- Tailwind only, light theme only, no dark variant.
- Browser-first; Tauri APIs only behind `window.__TAURI__` guards.
- No databases; filesystem + `.git` remain the source of truth.
- No polling for file or preview changes.
- Backend stays a thin proxy. No Typst or LSP reimplementation in TypeScript.
- `tinymist` remains the LSP/preview source of truth.
- Keep the raw JSON-RPC LSP client. Do not reintroduce `monaco-languageclient`.
- Strict TypeScript with zod at API boundaries.
## Aesthetic Direction (see DESIGN.md)
- Document-editor tone: warm paper background, subtle emerald accents, no marketing gradients.
- Dense-but-readable developer layout. System sans for chrome, monospace for paths/git.
- Rounded corners and borders restrained to "panel" and "card"; default to `rounded-md`.
- Status indicators must read clearly (label + color, never color alone).
- Splitter, focus rings, etc. all designed in DESIGN.md; the implementation must match.
## Phase 1 — Path Utilities and Jump Reliability
Move path/jump helpers into a single module so all callers use the same normalization rules.
**New file**
- `typst-leaf-frontend/src/path-utils.ts`:
- `normalizeFsPath(input: string): string`
- `encodeVirtualPathForFileUri(path: string): string`
- `relativePathFromProject(projectUri: string, filepath: string): string | null`
**State changes** (`app-state.tsx`)
- `jumpTarget: { id: number; file: string; start?: [number,number]; end?: [number,number] } | null`
- `setJumpTarget` action carries an `id`; reducer increments counter and stamps.
- New `pushNotice` action for transient messages ("Preview source outside project").
**LSP hardening** (`lsp.ts`)
- Validate `tinymist/preview/scrollSource` payload strictly: `filepath` string, optional numeric `start/end`.
- Forward only well-formed paths to `onJumpToSource`.
**Editor** (`Editor.tsx`)
- Use shared path utility.
- Compare URIs via `monaco.Uri.equals` after parsing both sides.
- Dedupe by `jump.id`, not object identity.
- Keep the existing `ResizeObserver` and `editor.layout()` call before reveal.
**Verification**
- External preview path ignored with non-blocking message.
- Numeric start/end coercion.
- Rapid clicks keep latest, no swallowed targets.
## Phase 2 — UI Foundation (Rehaul)
Replace the current sparse shell with the layered "IDE" layout in DESIGN.md.
**Files**
- `typst-leaf-frontend/src/App.tsx` (restructure)
- `typst-leaf-frontend/src/index.css` (extend `tailwindcss` import; nothing else without need)
- New components:
- `TopBar.tsx`
- `StatusBar.tsx`
- `Sidebar.tsx`
- `PanelSection.tsx`
- `ActivityBar.tsx`
- `ResizableSplit.tsx`
- `ToastHost.tsx`
- `EmptyState.tsx`
**Layout**
- Top bar: project switcher, current file/dirty indicator, save, LSP/preview/git chips, settings.
- Sidebar tabs: Files / Git / Settings.
- Main: resizable editor / preview split.
- Status bar: branch, clean/dirty, LSP, preview, vim state, cursor (optional).
**Aesthetic tokens** (mirror DESIGN.md)
- `bg-stone-50` for chrome, `bg-white` for editor surface, `bg-emerald-600` for primary actions, `border-zinc-200` for panel dividers, `text-zinc-500` for muted metadata.
- Tailwind reference snippets are kept in DESIGN.md.
**QOL**
- Toasts for save/commit/push/pull, configured via `pushNotice`.
- Clear empty states ("Select a project", "Open a file", "No changes to commit").
- Keyboard shortcuts surfaced in `StatusBar` hints later if time.
## Phase 3 — Resizable Workspace and Preview UX
- Build `ResizableSplit.tsx` (pointer-driven, double-click reset, persisted ratio).
- Persist `typst-leaf.split` in `localStorage`.
- Add `PreviewToolbar` on top of iframe: status chip, reload button, "Click to jump" hint.
- During drag and on drag-end, call `editor.layout()` so Monaco keeps accurate viewports.
## Phase 4 — File Management
Make projects usable from the UI.
### Backend
`typst-leaf-backend/src/routes/files.ts`:
- `POST /api/projects/:project/file`
- `DELETE /api/projects/:project/file?path=…`
- `PATCH /api/projects/:project/file`
- `POST /api/projects/:project/folder`
All paths through `resolveFile`/`relPathSchema`. zod validation stays.
### Frontend
`api.ts`:
- `createFile`, `deleteFile`, `renameFile`, `createFolder`.
`types.ts`:
- Add request types.
`FileTree.tsx`:
- Group tree view; folders from existing file paths.
- Inline create/rename/delete actions; confirm destructive deletes.
- After mutation: refresh tree, dispatch `bumpGitVersion`, open new file or close deleted open file (with model disposal).
`app-state.tsx`:
- Action `setTree` already exists. Add `bumpFileTree` counter or reuse `setProjects` invalidation by re-fetching tree on a `treeVersion`.
## Phase 5 — SyncTeX/LSP Reliability Hardening
(See Phase 1.) This phase covers the user-visible behavior of the jump path:
- Stable IDs prevent dropped or duplicated jumps.
- External path is announced via toast instead of silently dropping.
- Vue of preview-after-resize: drag resize triggers `editor.layout()`. Preview iframe reload button re-syncs.
- A small dev-only "Diagnostics" panel hidden behind `?debug=1` later is optional.
## Phase 6 — Git Workflow Upgrade
Backend
- `GET /api/projects/:project/git/diff` returning unified diffs (later; not blocking if skipped).
- Optional `POST /api/projects/:project/git/stage` for partial staging (later).
Frontend
- `GitPanel.tsx` becomes a workflow panel:
- Branch + ahead/behind + clean state
- Changed files list grouped by staged/unstaged/untracked (icons per status)
- Commit composer
- Push/Pull controls
- Remote inline input
- Recent commits list (keep existing)
- `FileTree.tsx` shows dirty file badges.
- Toasts on success/failure: "Committed abc1234", "Pushed to origin/main", etc.
- Disable commit when clean; message should explain why.
`types.ts`:
- Confirm `FileStatusInfo` carries `A|M|D|?` (already does).
## Phase 7 — State and UX Infrastructure
Keep state in `app-state.tsx` (no Zustand yet). Add UI actions:
- `setActivePanel` (files | git | settings)
- `setFileDirty`
- `setSaving`
- `pushNotice` / `dismissNotice`
- `setSplitRatio` (effect persists via localStorage directly; not strict state)
Toast component lives once at `App.tsx`, reads notices via context.
## Phase 8 — Doc and Metadata Cleanup
- `README.md`: port 1420, raw LSP client, preview-via-LSP-and-proxy model.
- `VISION.md`: note raw LSP client; soften `monaco-languageclient` claim.
- `typst-leaf-frontend/README.md`: replace Tauri-template text.
- `PLAN.md`: replace with this plan at commit time. Keep `DESIGN.md` separate and referenced.
## Implementation Order
1. Shared path utilities + jump IDs.
2. SyncTeX hardening (rely on shared utils, not yet aesthetic).
3. UI shell rehaul (TopBar, Sidebar, ResizableSplit, StatusBar, ToastHost).
4. File CRUD backend + frontend integration, FileTree rebuild.
5. Git workflow UI rebuild + badges in tree.
6. Preview toolbar and reload.
7. Doc cleanup.
8. Verification:
- `pnpm -r typecheck`
- `pnpm --filter typst-leaf-frontend build`
- Manual checks: file CRUD, resize-during-jump, Git flow, external preview path ignored.
## Risks
- File URI/path encoding is still the highest-risk area; reuse existing helpers in `Editor.tsx`.
- Rename/delete of currently open file must dispose Monaco model.
- Git status can stale if a file mutation does not bump `gitVersion`.
- Resizable panes must call `layout()` during and after drag.
- Preview iframe behavior is owned by typst-preview; do not over-customize it.
## Scope Boundary
In:
- IDE shell rehaul, resizable split, file CRUD, Git workflow UI upgrade, SyncTeX reliability, toasts, status bar.
Out:
- Per-file staging workflow.
- Rich diff viewer.
- Drag/drop reordering, multi-tab editor, collaborative cursors.
- Tauri native file picker.
+28
View File
@@ -48,6 +48,12 @@ importers:
typst-leaf-frontend:
dependencies:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8
version: 5.2.8
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -57,6 +63,9 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.4
lucide-react:
specifier: ^1.23.0
version: 1.23.0(react@19.2.7)
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
@@ -339,6 +348,12 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1076,6 +1091,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.23.0:
resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1565,6 +1585,10 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/jetbrains-mono@5.2.8': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2224,6 +2248,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.23.0(react@19.2.7):
dependencies:
react: 19.2.7
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+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"
}
+24 -34
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">
<div className="flex border-b border-line text-xs">
{panelTabs.map((tab) => (
<button
key={tab.id}
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"
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"
}`}
>
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
<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",
},
});
}