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:
@@ -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
|
## Brand
|
||||||
|
|
||||||
- Name: **typst-leaf** — warm, organic, paper-and-ink.
|
- **Name:** typst-leaf — warm, organic, paper-and-ink.
|
||||||
- Personality: quiet, considered, a serious editor for prose, papers, and beautiful documents.
|
- **Mark:** A teal `Leaf` icon (lucide) beside the wordmark.
|
||||||
- Not "developer marketing." Not "dashboard SaaS." Closer to a focused notebook.
|
- **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.**
|
Custom tokens in `src/index.css`:
|
||||||
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.
|
|
||||||
|
|
||||||
## Palette (Tailwind v4 via CSS-first)
|
| Token | Value | Use |
|
||||||
|
|
||||||
| Token | Use | Tailwind class |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Page background | Shell chrome | `bg-stone-50` |
|
| `canvas` | `#faf8f4` | App chrome (sidebar, topbar, statusbar, app bg) |
|
||||||
| Panel surface | Cards, panels | `bg-white` |
|
| `surface` | `#fdfdfb` | Editor, preview, raised panels, inputs |
|
||||||
| Sidebar | Activity + sections | `bg-stone-50` with inner panels `bg-white` |
|
| `surface-hover` | `#f3f1ec` | Hover backgrounds |
|
||||||
| Text primary | Body | `text-zinc-800` |
|
| `ink` | `#1c1917` | Primary body text |
|
||||||
| Text muted | Meta | `text-zinc-500` |
|
| `ink-muted` | `#78716c` | Secondary text, meta |
|
||||||
| Text subtle | File paths | `text-zinc-400` |
|
| `ink-subtle` | `#a8a29e` | Placeholders, subtle meta |
|
||||||
| Divider | 1px borders | `border-zinc-200` |
|
| `line` | `#e7e5e4` | Hairline borders, dividers |
|
||||||
| Hover surface | Buttons/list rows | `hover:bg-stone-100` |
|
| `line-strong` | `#d6d3d1` | Input borders |
|
||||||
| 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` |
|
|
||||||
|
|
||||||
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
|
## Typography
|
||||||
|
|
||||||
- UI: `font-sans` (system stack already from Tailwind).
|
- **UI:** `font-sans` → Inter Variable (via `@fontsource-variable/inter`).
|
||||||
- Monospace: `font-mono` for paths, git refs, LSP logs, status messages.
|
- **Mono:** `font-mono` → JetBrains Mono Variable (via `@fontsource-variable/jetbrains-mono`).
|
||||||
- Toolbar/header: `text-sm font-medium tracking-tight`.
|
- **Monaco editor** also uses JetBrains Mono for consistency.
|
||||||
- Meta and toasts: `text-xs text-zinc-500`.
|
- Brand wordmark: `font-semibold tracking-tight`.
|
||||||
- File names: `text-sm font-mono`.
|
- 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
|
## Layout Skeleton
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ TopBar: leaf · [project ▾] · main.typ · Save · ●LSP ◌PRV │
|
│ TopBar: # typst-leaf · project · main.typ Save LSP PRV @ │
|
||||||
├──────────┬───────────────────────────────────────────────────────────┤
|
├──────────┬───────────────────────────────────────────────────────────┤
|
||||||
│ Sidebar │ │
|
│ Sidebar │ │
|
||||||
│ Files │ │
|
│ Files │ │
|
||||||
│ ▣ main │ Monaco Editor │
|
│ ─────── │ Monaco Editor │
|
||||||
|
│ ▣ main │ (typst-leaf-light theme) │
|
||||||
│ ▣ lib/ │ │
|
│ ▣ lib/ │ │
|
||||||
│ Git │ │
|
│ Git │ │
|
||||||
│ ⚙ set │ │
|
│ Settings├─────────────────────────────────────┬─────────────────────┤
|
||||||
│ ├─────────────────────────────────────┬─────────────────────┤
|
|
||||||
│ │ Editor (resizable) │ Preview │
|
│ │ Editor (resizable) │ Preview │
|
||||||
│ │ │ (iframe) │
|
│ │ │ (iframe) │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
└──────────┴─────────────────────────────────────┴─────────────────────┘
|
└──────────┴─────────────────────────────────────┴─────────────────────┘
|
||||||
│ StatusBar: main · clean · ready · vim:n · 1:23 │
|
│ StatusBar: main · clean · LSP · Preview · vim · saving… │
|
||||||
└──────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Component Specs (Tailwind)
|
## Component Specs
|
||||||
|
|
||||||
### TopBar
|
### TopBar
|
||||||
```tsx
|
- `h-12`, `bg-canvas`, `border-b border-line`.
|
||||||
<header class="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11">
|
- Left: `Leaf` icon (teal-600, size-5), "typst-leaf" (semibold, tracking-tight), middot divider, project name, `/` divider, file path (`font-mono text-ink-subtle`).
|
||||||
<span class="font-semibold tracking-tight">typst-leaf</span>
|
- Right: LSP/Preview Chip (hidden when no file), `Button primary` Save, `IconButton` Settings.
|
||||||
<span class="text-zinc-300">·</span>
|
- When no project: text-centered "Select a project to begin".
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sidebar (`PanelSection` + tabs)
|
### Sidebar
|
||||||
```tsx
|
- `w-72`, `bg-canvas`, `border-r border-line`.
|
||||||
<aside class="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0">
|
- ProjectPicker at top with `border-b border-line`.
|
||||||
<div class="px-3 py-2 text-[11px] uppercase tracking-wider text-zinc-400">Files</div>
|
- Three icon+label tabs (Files, Git, Settings): active = teal text + 2px teal bottom border on surface; inactive = ink-muted.
|
||||||
<nav class="flex-1 overflow-auto p-1">
|
- Section headers: `uppercase tracking-wider text-ink-subtle text-[11px]`.
|
||||||
<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>
|
### StatusBar
|
||||||
</nav>
|
- `h-7`, `bg-canvas`, `border-t border-line`.
|
||||||
<div class="border-t border-zinc-200 p-2 text-xs text-zinc-500">12 uncommitted</div>
|
- `GitBranch` icon + branch name, clean/dirty, ahead/behind with arrows.
|
||||||
</aside>
|
- LSP/Preview Chip.
|
||||||
```
|
- Vim indicator and saving indicator.
|
||||||
|
|
||||||
### ResizableSplit
|
### ResizableSplit
|
||||||
```tsx
|
- `flex h-full min-h-0 w-full flex-1`.
|
||||||
<div class="flex h-full min-h-0">
|
- Divider: `w-1 bg-line hover:bg-teal-400`, `cursor-col-resize`, `transition-colors`.
|
||||||
<div class="min-w-[280px]" style={{ width: `${left}%` }}>/* editor */</div>
|
- Panels: `min-w-[280px] overflow-hidden`.
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### EmptyState
|
### EmptyState
|
||||||
```tsx
|
- Centered with muted `Leaf` icon, message, and optional hint.
|
||||||
<div class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-2">
|
- `text-ink-subtle`.
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Toast
|
### ToastHost
|
||||||
```tsx
|
- Fixed bottom-right, `rounded-lg shadow-sm`.
|
||||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
- `StatusDot` + message + lucide `X` close.
|
||||||
<div class="bg-white border border-zinc-200 rounded-md shadow-sm px-3 py-2 text-sm">
|
- Kinds: teal (success), rose (error), neutral (info).
|
||||||
<span class="text-emerald-700 mr-2">●</span>
|
|
||||||
Committed <span class="font-mono">a1b2c3d</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status chips
|
### Preview
|
||||||
```tsx
|
- `bg-surface`, toolbar with "Preview" label, status Chip, `RotateCw` reload button.
|
||||||
<span class="text-[11px] px-2 py-0.5 rounded-full border">
|
- Content: full-height iframe or centered placeholder text.
|
||||||
<span class="inline-block size-1.5 rounded-full bg-emerald-500 mr-1" />
|
|
||||||
LSP · ready
|
### Editor
|
||||||
</span>
|
- `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
|
## States
|
||||||
|
|
||||||
- **No project:** large empty state in main area; sidebar shows "No project yet."
|
- **No project:** large empty state in main area; sidebar shows project list.
|
||||||
- **Connecting:** preview shows "starting…" with a quiet spinner (CSS, not library).
|
- **Connecting:** amber pulse dot on LSP/Preview chips.
|
||||||
- **External preview source:** toast "Source outside project — ignored."
|
- **Error:** rose-600 text in error banner and toasts.
|
||||||
- **Save error:** rose-600 message in toast + small dot in TopBar file path.
|
- **Save error:** rose toast + dirty marker on file name.
|
||||||
- **Git dirty:** emerald-50 row tint + small dot next to filename in tree.
|
- **Git dirty:** amber dot on file in tree.
|
||||||
|
|
||||||
## Motion
|
## Motion
|
||||||
|
|
||||||
- Hover: `transition-colors duration-100`.
|
- Hover/focus: `transition-colors duration-150`.
|
||||||
- Splitter drag: no transition.
|
- Button press: `active:scale-[0.98]` on primary buttons.
|
||||||
- Toast in/out: 150ms opacity, no spring.
|
- Toast: none (instant show/hide).
|
||||||
|
- Connecting dots: `animate-pulse`.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-1`.
|
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-2`.
|
||||||
- All interactive controls reachable by keyboard.
|
- All interactive controls keyboard-reachable.
|
||||||
- Status communicated by text + color, never color alone.
|
- State communicated by text + color, never color alone.
|
||||||
|
|
||||||
## Don'ts
|
## Don'ts
|
||||||
|
|
||||||
- No gradients on chrome.
|
- No gradients on chrome.
|
||||||
- No shadows on whitespace panels.
|
- No shadows on panels (only on toasts).
|
||||||
- No animated chrome unless it's a real state change.
|
- No animated chrome unless real state change.
|
||||||
- No emoji decorations in product UI (only as icons in narrow toolbars).
|
- No emoji in product UI — icons only from lucide.
|
||||||
|
- No dark mode.
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Generated
+28
@@ -48,6 +48,12 @@ importers:
|
|||||||
|
|
||||||
typst-leaf-frontend:
|
typst-leaf-frontend:
|
||||||
dependencies:
|
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':
|
'@monaco-editor/react':
|
||||||
specifier: ^4.7.0
|
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)
|
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':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.4
|
version: 2.5.4
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.23.0
|
||||||
|
version: 1.23.0(react@19.2.7)
|
||||||
monaco-editor:
|
monaco-editor:
|
||||||
specifier: ^0.55.1
|
specifier: ^0.55.1
|
||||||
version: 0.55.1
|
version: 0.55.1
|
||||||
@@ -339,6 +348,12 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -1076,6 +1091,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
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:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -1565,6 +1585,10 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.28.1':
|
'@esbuild/win32-x64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@fontsource-variable/inter@5.2.8': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/jetbrains-mono@5.2.8': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -2224,6 +2248,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@1.23.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>typst-leaf</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"monaco-vim": "^0.4.4",
|
"monaco-vim": "^0.4.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -22,13 +25,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.6",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"tailwindcss": "^4.1.6",
|
"tailwindcss": "^4.1.6",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4"
|
||||||
"@tauri-apps/cli": "^2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0"
|
"packageManager": "pnpm@10.23.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,20 @@ import { EmptyState } from "./components/EmptyState";
|
|||||||
import { PanelSection } from "./components/PanelSection";
|
import { PanelSection } from "./components/PanelSection";
|
||||||
import { SettingsPanel } from "./components/SettingsPanel";
|
import { SettingsPanel } from "./components/SettingsPanel";
|
||||||
import { ResizableSplit } from "./components/ResizableSplit";
|
import { ResizableSplit } from "./components/ResizableSplit";
|
||||||
|
import { Files, GitGraph, Settings } from "lucide-react";
|
||||||
|
import { Icon } from "./components/ui/Icon";
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const { state, dispatch } = useApp();
|
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 (
|
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 />
|
<TopBar />
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
@@ -27,51 +35,33 @@ function Shell() {
|
|||||||
|
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* Sidebar */}
|
{/* 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 />
|
<ProjectPicker />
|
||||||
|
|
||||||
{/* Sidebar tabs */}
|
{/* Sidebar tabs */}
|
||||||
<div className="flex border-b border-zinc-200 text-xs">
|
<div className="flex border-b border-line text-xs">
|
||||||
<button
|
{panelTabs.map((tab) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
|
key={tab.id}
|
||||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
type="button"
|
||||||
state.activePanel === "files"
|
onClick={() => dispatch({ type: "setActivePanel", panel: tab.id })}
|
||||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
className={`flex items-center justify-center gap-1.5 flex-1 py-1.5 transition-colors duration-150 ${
|
||||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
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
|
<Icon icon={tab.icon} className="size-3.5" />
|
||||||
type="button"
|
{tab.label}
|
||||||
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
|
</button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{state.activePanel === "files" && (
|
{state.activePanel === "files" && (
|
||||||
<PanelSection title="Files">
|
<PanelSection title="Files">
|
||||||
{state.currentProject ? <FileTree /> : (
|
{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>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
@@ -81,7 +71,7 @@ function Shell() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main workspace */}
|
{/* 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 ? (
|
{state.currentProject && state.openFile ? (
|
||||||
<ResizableSplit
|
<ResizableSplit
|
||||||
left={<EditorView />}
|
left={<EditorView />}
|
||||||
@@ -103,7 +93,7 @@ function Shell() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
|
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-96 border-l border-zinc-200">
|
<div className="w-96 border-l border-line">
|
||||||
<Preview />
|
<Preview />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
import { encodeVirtualPathForFileUri } from "../path-utils";
|
import { encodeVirtualPathForFileUri } from "../path-utils";
|
||||||
|
import { defineTypstLeafTheme } from "../theme/monaco-theme";
|
||||||
|
|
||||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||||
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
||||||
@@ -148,6 +149,10 @@ export function EditorView() {
|
|||||||
const saveRef = useRef(save);
|
const saveRef = useRef(save);
|
||||||
saveRef.current = save;
|
saveRef.current = save;
|
||||||
|
|
||||||
|
const handleBeforeMount: BeforeMount = (m) => {
|
||||||
|
defineTypstLeafTheme(m);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMount: OnMount = (editor, m) => {
|
const handleMount: OnMount = (editor, m) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
setEditorReady(true);
|
setEditorReady(true);
|
||||||
@@ -239,7 +244,7 @@ export function EditorView() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -247,20 +252,20 @@ export function EditorView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col min-h-0">
|
<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="truncate max-w-48">{file}</span>
|
||||||
<span className="ml-auto flex items-center gap-2">
|
<span className="ml-auto flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
ref={vimStatusRef}
|
ref={vimStatusRef}
|
||||||
className="text-xs text-zinc-400 min-w-12"
|
className="text-xs text-ink-subtle min-w-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleVim}
|
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
|
state.vimOn
|
||||||
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
? "bg-teal-50 border-teal-200 text-teal-700"
|
||||||
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
|
: "bg-surface text-ink-muted border-line-strong hover:bg-surface-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Vim
|
Vim
|
||||||
@@ -270,7 +275,8 @@ export function EditorView() {
|
|||||||
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
||||||
<Editor
|
<Editor
|
||||||
defaultLanguage="typst"
|
defaultLanguage="typst"
|
||||||
theme="vs"
|
theme="typst-leaf-light"
|
||||||
|
beforeMount={handleBeforeMount}
|
||||||
onMount={handleMount}
|
onMount={handleMount}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true });
|
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({
|
export function EmptyState({
|
||||||
message,
|
message,
|
||||||
hint,
|
hint,
|
||||||
@@ -6,10 +9,11 @@ export function EmptyState({
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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>
|
<div className="text-sm">{message}</div>
|
||||||
{hint && (
|
{hint && (
|
||||||
<div className="text-xs text-zinc-500">{hint}</div>
|
<div className="text-xs text-ink-subtle">{hint}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { Plus, FolderPlus, Pencil, Trash2 } from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
file?: string; // present for leaf nodes → virtual path
|
file?: string;
|
||||||
folder?: string; // present for empty directory nodes → virtual path
|
folder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
|
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 filePaths) ensure(p.split("/"), true, p);
|
||||||
for (const p of folderPaths) {
|
for (const p of folderPaths) {
|
||||||
const parts = p.split("/");
|
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 exists = false;
|
||||||
let level = root;
|
let level = root;
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const part = parts[i]!;
|
const part = parts[i]!;
|
||||||
const isLast = i === parts.length - 1;
|
const isLast = i === parts.length - 1;
|
||||||
const node = level.find((n) => n.name === part);
|
const node = level.find((n) => n.name === part);
|
||||||
if (!node) {
|
if (!node) { exists = false; break; }
|
||||||
exists = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isLast) { exists = true; break; }
|
if (isLast) { exists = true; break; }
|
||||||
level = node.children;
|
level = node.children;
|
||||||
}
|
}
|
||||||
@@ -62,7 +59,6 @@ export function FileTree() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [createValue, setCreateValue] = useState("");
|
const [createValue, setCreateValue] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// Guard against double-submit (Enter + blur both call the handler).
|
|
||||||
const submittingRef = useRef(false);
|
const submittingRef = useRef(false);
|
||||||
|
|
||||||
const dirtyFiles = new Set<string>();
|
const dirtyFiles = new Set<string>();
|
||||||
@@ -88,7 +84,6 @@ export function FileTree() {
|
|||||||
setRenaming(null);
|
setRenaming(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Keep .typ files visible in the tree: require the extension to stay present.
|
|
||||||
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
|
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
|
||||||
newName += ".typ";
|
newName += ".typ";
|
||||||
}
|
}
|
||||||
@@ -168,38 +163,33 @@ export function FileTree() {
|
|||||||
|
|
||||||
function renderNode(node: TreeNode, depth: number, parentPath: string) {
|
function renderNode(node: TreeNode, depth: number, parentPath: string) {
|
||||||
const fullPath = parentPath ? parentPath + "/" + node.name : node.name;
|
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;
|
const isDir = (node.children.length > 0 && !node.file) || !!node.folder;
|
||||||
|
|
||||||
if (isDir) {
|
if (isDir) {
|
||||||
return (
|
return (
|
||||||
<div key={fullPath} className="group">
|
<div key={fullPath} className="group">
|
||||||
<div
|
<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` }}
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
>
|
>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startCreate(fullPath, "file")}
|
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"
|
title="New file"
|
||||||
aria-label="New file"
|
aria-label="New file"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
<Icon icon={Plus} 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>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startCreate(fullPath, "folder")}
|
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"
|
title="New folder"
|
||||||
aria-label="New folder"
|
aria-label="New folder"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
<Icon icon={FolderPlus} 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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
|
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
|
||||||
@@ -230,7 +220,7 @@ export function FileTree() {
|
|||||||
onChange={(e) => setRenameValue(e.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
onBlur={() => void handleRename(node.file!)}
|
onBlur={() => void handleRename(node.file!)}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
@@ -240,15 +230,15 @@ export function FileTree() {
|
|||||||
onClick={() => handleOpen(node.file!)}
|
onClick={() => handleOpen(node.file!)}
|
||||||
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
|
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-emerald-50 text-emerald-800 font-medium"
|
? "bg-teal-50 text-teal-700 font-medium"
|
||||||
: "hover:bg-stone-100 text-zinc-600"
|
: "hover:bg-surface-hover text-ink-muted"
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<span className="ml-1 text-amber-500" aria-label="modified">
|
<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" />
|
<circle cx="4" cy="4" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
@@ -260,26 +250,22 @@ export function FileTree() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startRename(node.file!)}
|
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"
|
title="Rename"
|
||||||
aria-label="Rename"
|
aria-label="Rename"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
<Icon icon={Pencil} 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>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
|
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"
|
title="Delete"
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
<Icon icon={Trash2} 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>
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -289,20 +275,19 @@ export function FileTree() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 py-1">
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{/* Create buttons at root */}
|
|
||||||
{state.currentProject && (
|
{state.currentProject && (
|
||||||
<div className="flex items-center gap-1 px-3 py-1">
|
<div className="flex items-center gap-1 px-3 py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startCreate("", "file")}
|
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
|
New file
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startCreate("", "folder")}
|
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
|
New folder
|
||||||
</button>
|
</button>
|
||||||
@@ -325,7 +310,7 @@ export function FileTree() {
|
|||||||
onBlur={() => void handleCreate()}
|
onBlur={() => void handleCreate()}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setCreating(null)}
|
onKeyDown={(e) => e.key === "Escape" && setCreating(null)}
|
||||||
placeholder={creating.type === "file" ? "file.typ" : "folder-name"}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Plus, Pencil, X, HelpCircle, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
import type { CommitInfo } from "../types";
|
import type { CommitInfo } from "../types";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
|
||||||
const statusIcon: Record<string, string> = {
|
const statusIcon: Record<string, React.ReactNode> = {
|
||||||
A: "+",
|
A: <Icon icon={Plus} className="size-3 text-teal-600" />,
|
||||||
M: "~",
|
M: <Icon icon={Pencil} className="size-3 text-amber-600" />,
|
||||||
D: "–",
|
D: <Icon icon={X} className="size-3 text-rose-500" />,
|
||||||
"?": "?",
|
"?": <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />,
|
||||||
};
|
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
|
||||||
staged: "text-emerald-600",
|
|
||||||
unstaged: "text-amber-600",
|
|
||||||
untracked: "text-zinc-400",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GitPanel() {
|
export function GitPanel() {
|
||||||
@@ -151,45 +148,56 @@ export function GitPanel() {
|
|||||||
f: { path: string; status: string },
|
f: { path: string; status: string },
|
||||||
group: "staged" | "unstaged" | "untracked",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.path}
|
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 })}
|
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
||||||
>
|
>
|
||||||
<span className={`w-3 text-center ${statusColor[group]}`}>
|
<span className="w-3.5 flex items-center justify-center">
|
||||||
{statusIcon[f.status] ?? "?"}
|
{statusIcon[f.status] ?? <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-zinc-600">{f.path}</span>
|
<span className="truncate text-ink-muted">{f.path}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-zinc-200">
|
<div className="border-t border-line">
|
||||||
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
|
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-ink-subtle flex items-center gap-1">
|
||||||
<span>Git</span>
|
<span>Git</span>
|
||||||
{s && (
|
{s && (
|
||||||
<span className="ml-auto text-zinc-400 font-mono normal-case">
|
<span className="ml-auto text-ink-muted font-mono normal-case flex items-center gap-1">
|
||||||
{s.branch}
|
<span>{s.branch}</span>
|
||||||
{s.ahead > 0 && <span className="text-emerald-600 ml-1">↑{s.ahead}</span>}
|
{s.ahead > 0 && (
|
||||||
{s.behind > 0 && <span className="text-amber-600 ml-1">↓{s.behind}</span>}
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{s && !s.clean && (
|
||||||
<span className="text-amber-600">
|
<span className="text-amber-600">
|
||||||
{s.unstaged.length + s.staged.length} uncommitted
|
{s.unstaged.length + s.staged.length} uncommitted
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
{s && s.clean && <span className="text-teal-600">clean</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Changed files — dedupe by path; unstaged wins for paths in both
|
{/* Changed files — dedupe by path; unstaged wins */}
|
||||||
buckets (a file can be both staged and unstaged for different
|
|
||||||
parts, but unstaged is what the user still needs to act on). */}
|
|
||||||
{s && !s.clean && (() => {
|
{s && !s.clean && (() => {
|
||||||
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
|
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" });
|
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());
|
const rows = Array.from(byPath.values());
|
||||||
return (
|
return (
|
||||||
<div className="px-3 pb-1">
|
<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
|
Changed files
|
||||||
</div>
|
</div>
|
||||||
{rows.map((f) => renderFileRow(f, f.group))}
|
{rows.map((f) => renderFileRow(f, f.group))}
|
||||||
@@ -218,35 +226,34 @@ export function GitPanel() {
|
|||||||
}}
|
}}
|
||||||
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
|
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
|
||||||
disabled={!hasChanges}
|
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
|
<Button size="sm" onClick={() => void handleCommit()} disabled={!canCommit}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
{busy ? "..." : "Commit"}
|
{busy ? "..." : "Commit"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
onClick={() => void handlePush()}
|
onClick={() => void handlePush()}
|
||||||
disabled={pushBusy}
|
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"}
|
{pushBusy ? "..." : "Push"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
onClick={() => void handlePull()}
|
onClick={() => void handlePull()}
|
||||||
disabled={pullBusy}
|
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"}
|
{pullBusy ? "..." : "Pull"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showRemoteInput && (
|
{showRemoteInput && (
|
||||||
@@ -256,16 +263,11 @@ export function GitPanel() {
|
|||||||
value={remoteUrl}
|
value={remoteUrl}
|
||||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||||
placeholder="git remote URL"
|
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
|
<Button size="sm" onClick={() => void handleSetRemote()} disabled={!remoteUrl.trim()}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
Set
|
Set
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -276,23 +278,15 @@ export function GitPanel() {
|
|||||||
|
|
||||||
{/* Commit log */}
|
{/* Commit log */}
|
||||||
{log.length > 0 && (
|
{log.length > 0 && (
|
||||||
<div className="border-t border-zinc-200">
|
<div className="border-t border-line">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLogOpen(!logOpen)}
|
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>Log</span>
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
{logOpen ? (
|
<Icon icon={logOpen ? ChevronDown : ChevronRight} className="size-3" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{logOpen && (
|
{logOpen && (
|
||||||
@@ -300,12 +294,12 @@ export function GitPanel() {
|
|||||||
{log.map((c) => (
|
{log.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.sha}
|
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}`}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export function PanelSection({
|
|||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-zinc-200 last:border-b-0">
|
<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-zinc-400">
|
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-ink-subtle">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
|
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import { RotateCw } from "lucide-react";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { Chip } from "./ui/Chip";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
|
||||||
export function Preview() {
|
export function Preview() {
|
||||||
const { state, lsp } = useApp();
|
const { state, lsp } = useApp();
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
const statusText = () => {
|
const statusChip = () => {
|
||||||
if (!state.currentProject) return "no project";
|
if (!state.currentProject) return <Chip tone="neutral">no project</Chip>;
|
||||||
if (!state.openFile) return "open a file";
|
if (!state.openFile) return <Chip tone="neutral">open a file</Chip>;
|
||||||
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
|
if (lsp.status !== "ready") return <Chip tone="amber" dot dotPulse>LSP {lsp.status}</Chip>;
|
||||||
if (!lsp.previewReady) return "starting…";
|
if (!lsp.previewReady) return <Chip tone="neutral">starting...</Chip>;
|
||||||
return "live";
|
return <Chip tone="teal" dot>live</Chip>;
|
||||||
};
|
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-white">
|
<div className="h-full flex flex-col bg-surface">
|
||||||
{/* Toolbar */}
|
{/* 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="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="ml-auto flex items-center gap-1">
|
||||||
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
|
{statusChip()}
|
||||||
{statusText()}
|
|
||||||
</span>
|
</span>
|
||||||
{lsp.previewReady && (
|
{lsp.previewReady && (
|
||||||
<button
|
<button
|
||||||
@@ -39,16 +30,17 @@ export function Preview() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
iframeRef.current?.contentWindow?.location.reload();
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 ? (
|
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
@@ -58,8 +50,8 @@ export function Preview() {
|
|||||||
className="absolute inset-0 w-full h-full bg-white"
|
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">
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-ink-subtle text-sm p-6 gap-2">
|
||||||
<span>{statusText()}</span>
|
<span>{statusChip()}</span>
|
||||||
{lsp.status === "error" && lsp.error && (
|
{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">
|
<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}
|
{lsp.error}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { TextInput } from "./ui/TextInput";
|
||||||
|
|
||||||
export function ProjectPicker() {
|
export function ProjectPicker() {
|
||||||
const { state, dispatch } = useApp();
|
const { state, dispatch } = useApp();
|
||||||
@@ -24,8 +26,8 @@ export function ProjectPicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
|
<div className="flex flex-col gap-2 p-2 border-b border-line">
|
||||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
<div className="text-xs uppercase tracking-wide text-ink-subtle">
|
||||||
Projects
|
Projects
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col gap-0.5">
|
<ul className="flex flex-col gap-0.5">
|
||||||
@@ -34,10 +36,10 @@ export function ProjectPicker() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => select(p)}
|
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
|
p === state.currentProject
|
||||||
? "bg-emerald-100 text-emerald-800 font-medium"
|
? "bg-teal-50 text-teal-700 font-medium border-l-2 border-teal-500"
|
||||||
: "hover:bg-zinc-100 text-zinc-600"
|
: "hover:bg-surface-hover text-ink-muted border-l-2 border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -45,26 +47,21 @@ export function ProjectPicker() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{state.projects.length === 0 && (
|
{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>
|
</ul>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<TextInput
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") create();
|
if (e.key === "Enter") create();
|
||||||
}}
|
}}
|
||||||
placeholder="new-project"
|
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
|
<Button size="sm" onClick={create}>
|
||||||
type="button"
|
|
||||||
onClick={create}
|
|
||||||
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
New
|
New
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function ResizableSplit({
|
|||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
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}
|
onPointerDown={startDrag}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
const pct = DEFAULT_PCT;
|
const pct = DEFAULT_PCT;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
|
import { TextInput } from "./ui/TextInput";
|
||||||
|
|
||||||
const STORAGE_KEY = "typst-leaf.git-identity";
|
const STORAGE_KEY = "typst-leaf.git-identity";
|
||||||
|
|
||||||
@@ -43,12 +45,12 @@ export function SettingsPanel() {
|
|||||||
}, [name, email]);
|
}, [name, email]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 flex flex-col gap-2">
|
<div className="px-3 pt-2 flex flex-col gap-2">
|
||||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
<div className="text-xs uppercase tracking-wide text-ink-subtle">
|
||||||
Git Identity
|
Git Identity
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<TextInput
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -56,10 +58,9 @@ export function SettingsPanel() {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
placeholder="Author name"
|
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"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -67,33 +68,27 @@ export function SettingsPanel() {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
placeholder="Author email"
|
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">
|
<div className="flex gap-1">
|
||||||
<button
|
<Button size="sm" onClick={save} disabled={!dirty}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setName("");
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
|
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-zinc-400">
|
<div className="text-xs text-ink-subtle">
|
||||||
Used for Git commits. Stored locally in browser.
|
Used for Git commits. Stored locally in browser.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,52 +1,62 @@
|
|||||||
|
import { GitBranch, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { Chip } from "./ui/Chip";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
const { state, lsp } = useApp();
|
const { state, lsp } = useApp();
|
||||||
const s = state.gitStatus;
|
const s = state.gitStatus;
|
||||||
|
|
||||||
const gitSummary = () => {
|
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 (
|
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>
|
<span className="font-mono">{s.branch}</span>
|
||||||
{s.clean ? (
|
{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>
|
<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.ahead > 0 && (
|
||||||
{s.behind > 0 && <span className="text-amber-600">· ↓{s.behind}</span>}
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const lspStatus = () => {
|
const lspStatus = () => {
|
||||||
if (lsp.status === "ready") return <span className="text-xs text-emerald-600">LSP · ready</span>;
|
if (lsp.status === "ready") return <Chip tone="teal" dot>LSP</Chip>;
|
||||||
if (lsp.status === "connecting") return <span className="text-xs text-amber-600">LSP · connecting</span>;
|
if (lsp.status === "connecting") return <Chip tone="amber" dot dotPulse>LSP</Chip>;
|
||||||
if (lsp.status === "error") return <span className="text-xs text-rose-600">LSP · error</span>;
|
if (lsp.status === "error") return <Chip tone="rose" dot>LSP</Chip>;
|
||||||
return <span className="text-xs text-zinc-400">LSP · idle</span>;
|
return <Chip tone="neutral">LSP · idle</Chip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewStatus = () => {
|
const previewStatus = () => {
|
||||||
if (!state.openFile) return null;
|
if (!state.openFile) return null;
|
||||||
if (lsp.previewReady) return <span className="text-xs text-emerald-600">Preview · live</span>;
|
if (lsp.previewReady) return <Chip tone="teal" dot>Preview</Chip>;
|
||||||
if (lsp.status === "ready" && !lsp.previewReady) return <span className="text-xs text-zinc-400">Preview · starting</span>;
|
if (lsp.status === "ready" && !lsp.previewReady) return <Chip tone="neutral">Preview · starting</Chip>;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="flex items-center gap-4 border-t border-zinc-200 bg-white px-4 h-7 shrink-0">
|
<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">
|
<div className="flex items-center gap-2">{gitSummary()}</div>
|
||||||
{gitSummary()}
|
<span className="text-line select-none">|</span>
|
||||||
</div>
|
|
||||||
<span className="text-zinc-200 select-none">|</span>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{lspStatus()}
|
{lspStatus()}
|
||||||
{previewStatus()}
|
{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>
|
</div>
|
||||||
<span className="ml-auto text-xs text-zinc-400">
|
<span className="ml-auto text-xs text-ink-subtle">
|
||||||
{state.saving && <span className="text-amber-500">saving…</span>}
|
{state.saving && <span className="text-amber-600">saving…</span>}
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { StatusDot } from "./ui/StatusDot";
|
||||||
|
import { Icon } from "./ui/Icon";
|
||||||
|
|
||||||
export function ToastHost() {
|
export function ToastHost() {
|
||||||
const { state, dispatch } = useApp();
|
const { state, dispatch } = useApp();
|
||||||
@@ -12,33 +15,32 @@ export function ToastHost() {
|
|||||||
|
|
||||||
if (!state.notice) return null;
|
if (!state.notice) return null;
|
||||||
|
|
||||||
const colors = {
|
const colors: Record<string, string> = {
|
||||||
success: "bg-emerald-50 text-emerald-800 border-emerald-200",
|
success: "bg-teal-50 text-teal-800 border-teal-200",
|
||||||
error: "bg-rose-50 text-rose-800 border-rose-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",
|
const dotColor: Record<string, "teal" | "rose" | "neutral"> = {
|
||||||
error: "bg-rose-500",
|
success: "teal",
|
||||||
info: "bg-zinc-400",
|
error: "rose",
|
||||||
|
info: "neutral",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<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}
|
{state.notice.message}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dispatch({ type: "dismissNotice" })}
|
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"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
<Icon icon={X} 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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { Leaf, Settings } from "lucide-react";
|
||||||
import { useApp } from "../app-state";
|
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() {
|
export function TopBar() {
|
||||||
const { state, dispatch, lsp } = useApp();
|
const { state, dispatch, lsp } = useApp();
|
||||||
@@ -6,99 +11,74 @@ export function TopBar() {
|
|||||||
const lspChip = () => {
|
const lspChip = () => {
|
||||||
switch (lsp.status) {
|
switch (lsp.status) {
|
||||||
case "ready":
|
case "ready":
|
||||||
return (
|
return <Chip tone="teal" dot>LSP</Chip>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
case "connecting":
|
case "connecting":
|
||||||
return (
|
return <Chip tone="amber" dot dotPulse>LSP</Chip>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
case "error":
|
case "error":
|
||||||
return (
|
return <Chip tone="rose" dot>LSP</Chip>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewChip = () => {
|
const previewChip = () => {
|
||||||
// No preview without an open file (tinymist previews a document).
|
|
||||||
if (!state.openFile) return null;
|
if (!state.openFile) return null;
|
||||||
if (lsp.previewReady) {
|
if (lsp.previewReady) {
|
||||||
return (
|
return <Chip tone="teal" dot>Preview</Chip>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (lsp.status === "ready") {
|
if (lsp.status === "ready") {
|
||||||
return (
|
return <Chip tone="neutral" dot={false}>Preview · starting</Chip>;
|
||||||
<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 null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11 shrink-0">
|
<header className="flex items-center gap-2 border-b border-line bg-canvas px-4 h-12 shrink-0">
|
||||||
<span className="font-semibold tracking-tight text-zinc-800">typst-leaf</span>
|
<Icon icon={Leaf} className="text-teal-600 size-5" />
|
||||||
<span className="text-zinc-300 select-none">·</span>
|
<span className="font-semibold tracking-tight text-ink">typst-leaf</span>
|
||||||
|
|
||||||
{state.currentProject ? (
|
{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}
|
{state.currentProject}
|
||||||
</span>
|
</span>
|
||||||
{state.openFile && (
|
{state.openFile && (
|
||||||
<>
|
<>
|
||||||
<span className="text-zinc-300 select-none">/</span>
|
<span className="text-line select-none">/</span>
|
||||||
<span className="text-sm font-mono text-zinc-500 truncate max-w-48">
|
<span className="text-sm font-mono text-ink-subtle truncate max-w-48">
|
||||||
{state.openFile}
|
{state.openFile}
|
||||||
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
|
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<span className="ml-auto flex items-center gap-2">
|
||||||
{lspChip()}
|
{lspChip()}
|
||||||
{previewChip()}
|
{previewChip()}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
|
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
|
||||||
}}
|
}}
|
||||||
disabled={!state.openFile}
|
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
|
Save
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
icon={Settings}
|
||||||
onClick={() => dispatch({ type: "setActivePanel", panel: state.activePanel === "settings" ? "files" : "settings" })}
|
label="Settings"
|
||||||
className={`p-1.5 rounded-md ${state.activePanel === "settings" ? "text-emerald-700 bg-emerald-50" : "text-zinc-500 hover:bg-stone-100"}`}
|
onClick={() =>
|
||||||
title="Settings"
|
dispatch({
|
||||||
>
|
type: "setActivePanel",
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4">
|
panel: state.activePanel === "settings" ? "files" : "settings",
|
||||||
<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>
|
className={state.activePanel === "settings" ? "text-teal-600 bg-teal-50 hover:bg-teal-50" : ""}
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,28 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "@fontsource-variable/inter";
|
||||||
|
import "@fontsource-variable/jetbrains-mono";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user