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
|
||||
|
||||
- Name: **typst-leaf** — warm, organic, paper-and-ink.
|
||||
- Personality: quiet, considered, a serious editor for prose, papers, and beautiful documents.
|
||||
- Not "developer marketing." Not "dashboard SaaS." Closer to a focused notebook.
|
||||
- **Name:** typst-leaf — warm, organic, paper-and-ink.
|
||||
- **Mark:** A teal `Leaf` icon (lucide) beside the wordmark.
|
||||
- **Personality:** Quiet, considered, a serious editor for prose, papers, and beautiful documents. Like a focused notebook, not a dashboard.
|
||||
|
||||
## Aesthetic Pillars
|
||||
## Palette (Tailwind v4 `@theme`)
|
||||
|
||||
1. **Paper, not glass.**
|
||||
Surfaces are warm off-white, not slate. Borders separate panels; we don't blur or float.
|
||||
2. **Functional color, decorative restraint.**
|
||||
Greens only when communicating success/active/leaf. Use amber for warnings, slate for muted text, rose for errors. No gradients on chrome.
|
||||
3. **Typography is the interface.**
|
||||
System sans for chrome, monospace for paths/git/lsp status. We say things in words, not icons.
|
||||
4. **A whisper, not a shout.**
|
||||
Rounded corners are subtle (`rounded-md`). Focus rings are visible but quiet.
|
||||
Custom tokens in `src/index.css`:
|
||||
|
||||
## Palette (Tailwind v4 via CSS-first)
|
||||
|
||||
| Token | Use | Tailwind class |
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| Page background | Shell chrome | `bg-stone-50` |
|
||||
| Panel surface | Cards, panels | `bg-white` |
|
||||
| Sidebar | Activity + sections | `bg-stone-50` with inner panels `bg-white` |
|
||||
| Text primary | Body | `text-zinc-800` |
|
||||
| Text muted | Meta | `text-zinc-500` |
|
||||
| Text subtle | File paths | `text-zinc-400` |
|
||||
| Divider | 1px borders | `border-zinc-200` |
|
||||
| Hover surface | Buttons/list rows | `hover:bg-stone-100` |
|
||||
| Accent (primary) | Save, commit | `bg-emerald-600 hover:bg-emerald-700 text-white` |
|
||||
| Accent (soft) | Active file, focus | `bg-emerald-50 text-emerald-800 ring-emerald-200` |
|
||||
| Warning | Behind | `text-amber-600` |
|
||||
| Error | Dirty error | `text-rose-600` |
|
||||
| Pill bg (status) | Status chips | `bg-white border-zinc-200` |
|
||||
| `canvas` | `#faf8f4` | App chrome (sidebar, topbar, statusbar, app bg) |
|
||||
| `surface` | `#fdfdfb` | Editor, preview, raised panels, inputs |
|
||||
| `surface-hover` | `#f3f1ec` | Hover backgrounds |
|
||||
| `ink` | `#1c1917` | Primary body text |
|
||||
| `ink-muted` | `#78716c` | Secondary text, meta |
|
||||
| `ink-subtle` | `#a8a29e` | Placeholders, subtle meta |
|
||||
| `line` | `#e7e5e4` | Hairline borders, dividers |
|
||||
| `line-strong` | `#d6d3d1` | Input borders |
|
||||
|
||||
We do not introduce dark classes. Tailwind v4 is in CSS-first mode (`@import "tailwindcss"`); tokens live in plain CSS, not `tailwind.config.js`.
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `teal-50` | `#f0f9f8` | Active row tints, soft accents |
|
||||
| `teal-100` | `#d5edea` | Focus ring, highlights |
|
||||
| `teal-500` | `#14b8a6` | Status dots, focus border |
|
||||
| `teal-600` | `#0f766e` | Primary actions, active text |
|
||||
| `teal-700` | `#115e59` | Primary hover |
|
||||
|
||||
Functional: `amber-*` (warnings, dirty, behind), `rose-*` (errors, destructive). Monaco defaults (`zinc-*` border, body text) are left as-is.
|
||||
|
||||
No dark classes. Tailwind v4 CSS-first only.
|
||||
|
||||
## Typography
|
||||
|
||||
- UI: `font-sans` (system stack already from Tailwind).
|
||||
- Monospace: `font-mono` for paths, git refs, LSP logs, status messages.
|
||||
- Toolbar/header: `text-sm font-medium tracking-tight`.
|
||||
- Meta and toasts: `text-xs text-zinc-500`.
|
||||
- File names: `text-sm font-mono`.
|
||||
- **UI:** `font-sans` → Inter Variable (via `@fontsource-variable/inter`).
|
||||
- **Mono:** `font-mono` → JetBrains Mono Variable (via `@fontsource-variable/jetbrains-mono`).
|
||||
- **Monaco editor** also uses JetBrains Mono for consistency.
|
||||
- Brand wordmark: `font-semibold tracking-tight`.
|
||||
- Section headers: `text-[11px] uppercase tracking-wider text-ink-subtle`.
|
||||
- File paths: `font-mono text-ink-muted`.
|
||||
- Meta: `text-xs text-ink-subtle`.
|
||||
|
||||
## Icons
|
||||
|
||||
lucide-react, tree-shakeable. Wrapped via `Icon` component with `size-4 stroke-[1.5] text-current`. Curated set: `Leaf`, `Plus`, `FolderPlus`, `Pencil`, `Trash2`, `ChevronDown`, `ChevronRight`, `X`, `Settings`, `RotateCw`, `GitBranch`, `ArrowUp`, `ArrowDown`, `Files`, `GitGraph`.
|
||||
|
||||
## Layout Skeleton
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ TopBar: leaf · [project ▾] · main.typ · Save · ●LSP ◌PRV │
|
||||
│ TopBar: # typst-leaf · project · main.typ Save LSP PRV @ │
|
||||
├──────────┬───────────────────────────────────────────────────────────┤
|
||||
│ Sidebar │ │
|
||||
│ Files │ │
|
||||
│ ▣ main │ Monaco Editor │
|
||||
│ ─────── │ Monaco Editor │
|
||||
│ ▣ main │ (typst-leaf-light theme) │
|
||||
│ ▣ lib/ │ │
|
||||
│ Git │ │
|
||||
│ ⚙ set │ │
|
||||
│ ├─────────────────────────────────────┬─────────────────────┤
|
||||
│ Settings├─────────────────────────────────────┬─────────────────────┤
|
||||
│ │ Editor (resizable) │ Preview │
|
||||
│ │ │ (iframe) │
|
||||
│ │ │ │
|
||||
└──────────┴─────────────────────────────────────┴─────────────────────┘
|
||||
│ StatusBar: main · clean · ready · vim:n · 1:23 │
|
||||
│ StatusBar: main · clean · LSP · Preview · vim · saving… │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Specs (Tailwind)
|
||||
## Component Specs
|
||||
|
||||
### TopBar
|
||||
```tsx
|
||||
<header class="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11">
|
||||
<span class="font-semibold tracking-tight">typst-leaf</span>
|
||||
<span class="text-zinc-300">·</span>
|
||||
<button class="text-sm hover:bg-stone-100 px-2 py-1 rounded-md">project ▾</button>
|
||||
<span class="ml-2 text-sm font-mono text-zinc-500 truncate">main.typ</span>
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">LSP · ready</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200">PRV · starting</span>
|
||||
<button class="text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-md px-3 py-1">Save</button>
|
||||
<button class="text-sm text-zinc-500 hover:bg-stone-100 px-2 py-1 rounded-md">⚙</button>
|
||||
</span>
|
||||
</header>
|
||||
```
|
||||
- `h-12`, `bg-canvas`, `border-b border-line`.
|
||||
- Left: `Leaf` icon (teal-600, size-5), "typst-leaf" (semibold, tracking-tight), middot divider, project name, `/` divider, file path (`font-mono text-ink-subtle`).
|
||||
- Right: LSP/Preview Chip (hidden when no file), `Button primary` Save, `IconButton` Settings.
|
||||
- When no project: text-centered "Select a project to begin".
|
||||
|
||||
### Sidebar (`PanelSection` + tabs)
|
||||
```tsx
|
||||
<aside class="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0">
|
||||
<div class="px-3 py-2 text-[11px] uppercase tracking-wider text-zinc-400">Files</div>
|
||||
<nav class="flex-1 overflow-auto p-1">
|
||||
<button class="w-full text-left text-sm px-2 py-1 rounded-md bg-emerald-50 text-emerald-800">main.typ</button>
|
||||
<button class="w-full text-left text-sm px-2 py-1 rounded-md hover:bg-stone-100">lib/util.typ</button>
|
||||
</nav>
|
||||
<div class="border-t border-zinc-200 p-2 text-xs text-zinc-500">12 uncommitted</div>
|
||||
</aside>
|
||||
```
|
||||
### Sidebar
|
||||
- `w-72`, `bg-canvas`, `border-r border-line`.
|
||||
- ProjectPicker at top with `border-b border-line`.
|
||||
- Three icon+label tabs (Files, Git, Settings): active = teal text + 2px teal bottom border on surface; inactive = ink-muted.
|
||||
- Section headers: `uppercase tracking-wider text-ink-subtle text-[11px]`.
|
||||
|
||||
### StatusBar
|
||||
- `h-7`, `bg-canvas`, `border-t border-line`.
|
||||
- `GitBranch` icon + branch name, clean/dirty, ahead/behind with arrows.
|
||||
- LSP/Preview Chip.
|
||||
- Vim indicator and saving indicator.
|
||||
|
||||
### ResizableSplit
|
||||
```tsx
|
||||
<div class="flex h-full min-h-0">
|
||||
<div class="min-w-[280px]" style={{ width: `${left}%` }}>/* editor */</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
class="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize"
|
||||
onPointerDown={startDrag}
|
||||
onDoubleClick={reset}
|
||||
/>
|
||||
<div class="flex-1 min-w-[280px]">/* preview */</div>
|
||||
</div>
|
||||
```
|
||||
- `flex h-full min-h-0 w-full flex-1`.
|
||||
- Divider: `w-1 bg-line hover:bg-teal-400`, `cursor-col-resize`, `transition-colors`.
|
||||
- Panels: `min-w-[280px] overflow-hidden`.
|
||||
|
||||
### EmptyState
|
||||
```tsx
|
||||
<div class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-2">
|
||||
<div class="text-sm">Open a file from the sidebar.</div>
|
||||
<div class="text-xs text-zinc-500">Tip: ⌘P to jump, ⌘S to save.</div>
|
||||
</div>
|
||||
```
|
||||
- Centered with muted `Leaf` icon, message, and optional hint.
|
||||
- `text-ink-subtle`.
|
||||
|
||||
### Toast
|
||||
```tsx
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div class="bg-white border border-zinc-200 rounded-md shadow-sm px-3 py-2 text-sm">
|
||||
<span class="text-emerald-700 mr-2">●</span>
|
||||
Committed <span class="font-mono">a1b2c3d</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
### ToastHost
|
||||
- Fixed bottom-right, `rounded-lg shadow-sm`.
|
||||
- `StatusDot` + message + lucide `X` close.
|
||||
- Kinds: teal (success), rose (error), neutral (info).
|
||||
|
||||
### Status chips
|
||||
```tsx
|
||||
<span class="text-[11px] px-2 py-0.5 rounded-full border">
|
||||
<span class="inline-block size-1.5 rounded-full bg-emerald-500 mr-1" />
|
||||
LSP · ready
|
||||
</span>
|
||||
```
|
||||
### Preview
|
||||
- `bg-surface`, toolbar with "Preview" label, status Chip, `RotateCw` reload button.
|
||||
- Content: full-height iframe or centered placeholder text.
|
||||
|
||||
### Editor
|
||||
- `h-full flex flex-col min-h-0`. Toolbar: mono file path, Vim toggle, vim status.
|
||||
- Monaco with custom `typst-leaf-light` theme (warm near-white background, restrained rainbow-free syntax palette).
|
||||
|
||||
### FileTree
|
||||
- Nested tree with `hover:bg-surface-hover` rows, active file `bg-teal-50 text-teal-700`.
|
||||
- Inline rename/create inputs with `border-teal-400`.
|
||||
- Hover actions with lucide icons (Plus, FolderPlus, Pencil, Trash2).
|
||||
- Dirty files marked with amber dot.
|
||||
|
||||
### GitPanel
|
||||
- Section header with branch name, ahead/behind arrows.
|
||||
- Changed files with lucide status icons (Plus/Pencil/X/HelpCircle).
|
||||
- Button primary Commit, Button secondary Push/Pull with ArrowUp/ArrowDown icons.
|
||||
- Collapsible Log with chevron.
|
||||
|
||||
### SettingsPanel
|
||||
- "Git Identity" section.
|
||||
- TextInputs for name/email, Button primary Save, Button secondary Clear.
|
||||
|
||||
## States
|
||||
|
||||
- **No project:** large empty state in main area; sidebar shows "No project yet."
|
||||
- **Connecting:** preview shows "starting…" with a quiet spinner (CSS, not library).
|
||||
- **External preview source:** toast "Source outside project — ignored."
|
||||
- **Save error:** rose-600 message in toast + small dot in TopBar file path.
|
||||
- **Git dirty:** emerald-50 row tint + small dot next to filename in tree.
|
||||
- **No project:** large empty state in main area; sidebar shows project list.
|
||||
- **Connecting:** amber pulse dot on LSP/Preview chips.
|
||||
- **Error:** rose-600 text in error banner and toasts.
|
||||
- **Save error:** rose toast + dirty marker on file name.
|
||||
- **Git dirty:** amber dot on file in tree.
|
||||
|
||||
## Motion
|
||||
|
||||
- Hover: `transition-colors duration-100`.
|
||||
- Splitter drag: no transition.
|
||||
- Toast in/out: 150ms opacity, no spring.
|
||||
- Hover/focus: `transition-colors duration-150`.
|
||||
- Button press: `active:scale-[0.98]` on primary buttons.
|
||||
- Toast: none (instant show/hide).
|
||||
- Connecting dots: `animate-pulse`.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-1`.
|
||||
- All interactive controls reachable by keyboard.
|
||||
- Status communicated by text + color, never color alone.
|
||||
- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-2`.
|
||||
- All interactive controls keyboard-reachable.
|
||||
- State communicated by text + color, never color alone.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- No gradients on chrome.
|
||||
- No shadows on whitespace panels.
|
||||
- No animated chrome unless it's a real state change.
|
||||
- No emoji decorations in product UI (only as icons in narrow toolbars).
|
||||
|
||||
## Thumbnail: vibe check
|
||||
|
||||
> Imagine a warm, slightly off-white IDE with a single green accent for "commit ready" and a quiet three-dot status footer. It's the IDE equivalent of a blank page of cream paper, not a glass dashboard.
|
||||
- No shadows on panels (only on toasts).
|
||||
- No animated chrome unless real state change.
|
||||
- No emoji in product UI — icons only from lucide.
|
||||
- No dark mode.
|
||||
|
||||
@@ -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:
|
||||
dependencies:
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@fontsource-variable/jetbrains-mono':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -57,6 +63,9 @@ importers:
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2
|
||||
version: 2.5.4
|
||||
lucide-react:
|
||||
specifier: ^1.23.0
|
||||
version: 1.23.0(react@19.2.7)
|
||||
monaco-editor:
|
||||
specifier: ^0.55.1
|
||||
version: 0.55.1
|
||||
@@ -339,6 +348,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fontsource-variable/inter@5.2.8':
|
||||
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
||||
|
||||
'@fontsource-variable/jetbrains-mono@5.2.8':
|
||||
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -1076,6 +1091,11 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucide-react@1.23.0:
|
||||
resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -1565,6 +1585,10 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@fontsource-variable/inter@5.2.8': {}
|
||||
|
||||
'@fontsource-variable/jetbrains-mono@5.2.8': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -2224,6 +2248,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@1.23.0(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="A self-hosted, browser-first IDE for Typst." />
|
||||
<meta name="theme-color" content="#faf8f4" />
|
||||
<title>typst-leaf</title>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"monaco-vim": "^0.4.4",
|
||||
"react": "^19.1.0",
|
||||
@@ -22,13 +25,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0"
|
||||
}
|
||||
|
||||
@@ -11,12 +11,20 @@ import { EmptyState } from "./components/EmptyState";
|
||||
import { PanelSection } from "./components/PanelSection";
|
||||
import { SettingsPanel } from "./components/SettingsPanel";
|
||||
import { ResizableSplit } from "./components/ResizableSplit";
|
||||
import { Files, GitGraph, Settings } from "lucide-react";
|
||||
import { Icon } from "./components/ui/Icon";
|
||||
|
||||
function Shell() {
|
||||
const { state, dispatch } = useApp();
|
||||
|
||||
const panelTabs = [
|
||||
{ id: "files" as const, label: "Files", icon: Files },
|
||||
{ id: "git" as const, label: "Git", icon: GitGraph },
|
||||
{ id: "settings" as const, label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
|
||||
<div className="h-screen w-screen bg-canvas text-ink flex flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
{state.error && (
|
||||
@@ -27,51 +35,33 @@ function Shell() {
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0 shrink-0">
|
||||
<aside className="w-72 border-r border-line bg-canvas flex flex-col min-h-0 shrink-0">
|
||||
<ProjectPicker />
|
||||
|
||||
{/* Sidebar tabs */}
|
||||
<div className="flex border-b border-zinc-200 text-xs">
|
||||
<div className="flex border-b border-line text-xs">
|
||||
{panelTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "files"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: tab.id })}
|
||||
className={`flex items-center justify-center gap-1.5 flex-1 py-1.5 transition-colors duration-150 ${
|
||||
state.activePanel === tab.id
|
||||
? "text-teal-600 border-b-2 border-teal-500 bg-surface"
|
||||
: "text-ink-muted hover:text-ink hover:bg-surface-hover"
|
||||
}`}
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "git"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
Git
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "settings" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "settings"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
<Icon icon={tab.icon} className="size-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{state.activePanel === "files" && (
|
||||
<PanelSection title="Files">
|
||||
{state.currentProject ? <FileTree /> : (
|
||||
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
|
||||
<div className="px-3 py-2 text-xs text-ink-subtle">select a project</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
@@ -81,7 +71,7 @@ function Shell() {
|
||||
</aside>
|
||||
|
||||
{/* Main workspace */}
|
||||
<main className="flex-1 flex min-w-0 bg-white">
|
||||
<main className="flex-1 flex min-w-0 bg-surface">
|
||||
{state.currentProject && state.openFile ? (
|
||||
<ResizableSplit
|
||||
left={<EditorView />}
|
||||
@@ -103,7 +93,7 @@ function Shell() {
|
||||
<div className="flex-1">
|
||||
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
|
||||
</div>
|
||||
<div className="w-96 border-l border-zinc-200">
|
||||
<div className="w-96 border-l border-line">
|
||||
<Preview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
|
||||
import Editor, { type BeforeMount, type Monaco, type OnMount } from "@monaco-editor/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import { encodeVirtualPathForFileUri } from "../path-utils";
|
||||
import { defineTypstLeafTheme } from "../theme/monaco-theme";
|
||||
|
||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
||||
@@ -148,6 +149,10 @@ export function EditorView() {
|
||||
const saveRef = useRef(save);
|
||||
saveRef.current = save;
|
||||
|
||||
const handleBeforeMount: BeforeMount = (m) => {
|
||||
defineTypstLeafTheme(m);
|
||||
};
|
||||
|
||||
const handleMount: OnMount = (editor, m) => {
|
||||
editorRef.current = editor;
|
||||
setEditorReady(true);
|
||||
@@ -239,7 +244,7 @@ export function EditorView() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
|
||||
<div className="flex-1 flex items-center justify-center text-rose-600 text-sm p-4">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
@@ -247,20 +252,20 @@ export function EditorView() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-line text-xs text-ink-muted font-mono">
|
||||
<span className="truncate max-w-48">{file}</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<div
|
||||
ref={vimStatusRef}
|
||||
className="text-xs text-zinc-400 min-w-12"
|
||||
className="text-xs text-ink-subtle min-w-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVim}
|
||||
className={`px-2 py-0.5 rounded border text-xs ${
|
||||
className={`px-2 py-0.5 rounded-md border text-xs transition-colors duration-150 ${
|
||||
state.vimOn
|
||||
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
||||
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
|
||||
? "bg-teal-50 border-teal-200 text-teal-700"
|
||||
: "bg-surface text-ink-muted border-line-strong hover:bg-surface-hover"
|
||||
}`}
|
||||
>
|
||||
Vim
|
||||
@@ -270,7 +275,8 @@ export function EditorView() {
|
||||
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
||||
<Editor
|
||||
defaultLanguage="typst"
|
||||
theme="vs"
|
||||
theme="typst-leaf-light"
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleMount}
|
||||
onChange={() => {
|
||||
if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true });
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Leaf } from "lucide-react";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
export function EmptyState({
|
||||
message,
|
||||
hint,
|
||||
@@ -6,10 +9,11 @@ export function EmptyState({
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-1 p-4 text-center">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-ink-subtle gap-2 p-4 text-center">
|
||||
<Icon icon={Leaf} className="size-8 text-line" />
|
||||
<div className="text-sm">{message}</div>
|
||||
{hint && (
|
||||
<div className="text-xs text-zinc-500">{hint}</div>
|
||||
<div className="text-xs text-ink-subtle">{hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Plus, FolderPlus, Pencil, Trash2 } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
children: TreeNode[];
|
||||
file?: string; // present for leaf nodes → virtual path
|
||||
folder?: string; // present for empty directory nodes → virtual path
|
||||
file?: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
|
||||
@@ -31,18 +33,13 @@ function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
|
||||
for (const p of filePaths) ensure(p.split("/"), true, p);
|
||||
for (const p of folderPaths) {
|
||||
const parts = p.split("/");
|
||||
// Only create a node if the folder is not already represented as an
|
||||
// ancestor of a known file (those are inferred directory nodes).
|
||||
let exists = false;
|
||||
let level = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
const isLast = i === parts.length - 1;
|
||||
const node = level.find((n) => n.name === part);
|
||||
if (!node) {
|
||||
exists = false;
|
||||
break;
|
||||
}
|
||||
if (!node) { exists = false; break; }
|
||||
if (isLast) { exists = true; break; }
|
||||
level = node.children;
|
||||
}
|
||||
@@ -62,7 +59,6 @@ export function FileTree() {
|
||||
} | null>(null);
|
||||
const [createValue, setCreateValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Guard against double-submit (Enter + blur both call the handler).
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const dirtyFiles = new Set<string>();
|
||||
@@ -88,7 +84,6 @@ export function FileTree() {
|
||||
setRenaming(null);
|
||||
return;
|
||||
}
|
||||
// Keep .typ files visible in the tree: require the extension to stay present.
|
||||
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
|
||||
newName += ".typ";
|
||||
}
|
||||
@@ -168,38 +163,33 @@ export function FileTree() {
|
||||
|
||||
function renderNode(node: TreeNode, depth: number, parentPath: string) {
|
||||
const fullPath = parentPath ? parentPath + "/" + node.name : node.name;
|
||||
// Directory if it has children or is an explicitly-known empty folder
|
||||
const isDir = (node.children.length > 0 && !node.file) || !!node.folder;
|
||||
|
||||
if (isDir) {
|
||||
return (
|
||||
<div key={fullPath} className="group">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs text-zinc-400 font-medium tracking-wide"
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs text-ink-subtle font-medium tracking-wide"
|
||||
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCreate(fullPath, "file")}
|
||||
className="ml-1 text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
|
||||
className="ml-1 text-ink-subtle hover:text-ink opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
|
||||
title="New file"
|
||||
aria-label="New file"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M8.75 1.75a.75.75 0 0 0-1.5 0v5.5h-5.5a.75.75 0 0 0 0 1.5h5.5v5.5a.75.75 0 0 0 1.5 0v-5.5h5.5a.75.75 0 0 0 0-1.5h-5.5v-5.5Z" />
|
||||
</svg>
|
||||
<Icon icon={Plus} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCreate(fullPath, "folder")}
|
||||
className="text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
|
||||
className="text-ink-subtle hover:text-ink opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
|
||||
title="New folder"
|
||||
aria-label="New folder"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z" />
|
||||
</svg>
|
||||
<Icon icon={FolderPlus} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
|
||||
@@ -230,7 +220,7 @@ export function FileTree() {
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void handleRename(node.file!)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
|
||||
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
|
||||
className="flex-1 text-xs px-1 py-0.5 border border-teal-400 rounded bg-surface outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
@@ -240,15 +230,15 @@ export function FileTree() {
|
||||
onClick={() => handleOpen(node.file!)}
|
||||
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
|
||||
isActive
|
||||
? "bg-emerald-50 text-emerald-800 font-medium"
|
||||
: "hover:bg-stone-100 text-zinc-600"
|
||||
? "bg-teal-50 text-teal-700 font-medium"
|
||||
: "hover:bg-surface-hover text-ink-muted"
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||
>
|
||||
{node.name}
|
||||
{isDirty && (
|
||||
<span className="ml-1 text-amber-500" aria-label="modified">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5 inline">
|
||||
<circle cx="4" cy="4" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
@@ -260,26 +250,22 @@ export function FileTree() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(node.file!)}
|
||||
className="text-zinc-400 hover:text-zinc-600 p-0.5"
|
||||
className="text-ink-muted hover:text-ink p-0.5 rounded"
|
||||
title="Rename"
|
||||
aria-label="Rename"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064l6.286-6.286Z" />
|
||||
</svg>
|
||||
<Icon icon={Pencil} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
|
||||
}}
|
||||
className="text-zinc-400 hover:text-rose-500 p-0.5"
|
||||
className="text-ink-muted hover:text-rose-500 p-0.5 rounded"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.152A2 2 0 0 0 5.852 15.5h4.296a2 2 0 0 0 1.987-1.848l.815-8.152h.3a.75.75 0 0 0 0-1.5H11v-.75A1.75 1.75 0 0 0 9.25 1.75h-2.5A1.75 1.75 0 0 0 5 3.25Zm2.25-.75a.25.25 0 0 0-.25.25V4h2V2.75a.25.25 0 0 0-.25-.25h-2.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .787-.712Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Icon icon={Trash2} className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -289,20 +275,19 @@ export function FileTree() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{/* Create buttons at root */}
|
||||
{state.currentProject && (
|
||||
<div className="flex items-center gap-1 px-3 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCreate("", "file")}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
|
||||
className="text-xs text-ink-muted hover:text-ink px-1 py-0.5 rounded transition-colors"
|
||||
>
|
||||
New file
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCreate("", "folder")}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
|
||||
className="text-xs text-ink-muted hover:text-ink px-1 py-0.5 rounded transition-colors"
|
||||
>
|
||||
New folder
|
||||
</button>
|
||||
@@ -325,7 +310,7 @@ export function FileTree() {
|
||||
onBlur={() => void handleCreate()}
|
||||
onKeyDown={(e) => e.key === "Escape" && setCreating(null)}
|
||||
placeholder={creating.type === "file" ? "file.typ" : "folder-name"}
|
||||
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
|
||||
className="flex-1 text-xs px-1 py-0.5 border border-teal-400 rounded bg-surface outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Pencil, X, HelpCircle, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import type { CommitInfo } from "../types";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
const statusIcon: Record<string, string> = {
|
||||
A: "+",
|
||||
M: "~",
|
||||
D: "–",
|
||||
"?": "?",
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
staged: "text-emerald-600",
|
||||
unstaged: "text-amber-600",
|
||||
untracked: "text-zinc-400",
|
||||
const statusIcon: Record<string, React.ReactNode> = {
|
||||
A: <Icon icon={Plus} className="size-3 text-teal-600" />,
|
||||
M: <Icon icon={Pencil} className="size-3 text-amber-600" />,
|
||||
D: <Icon icon={X} className="size-3 text-rose-500" />,
|
||||
"?": <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />,
|
||||
};
|
||||
|
||||
export function GitPanel() {
|
||||
@@ -151,45 +148,56 @@ export function GitPanel() {
|
||||
f: { path: string; status: string },
|
||||
group: "staged" | "unstaged" | "untracked",
|
||||
) {
|
||||
const groupClasses: Record<string, string> = {
|
||||
staged: "bg-teal-50 hover:bg-teal-100",
|
||||
unstaged: "hover:bg-surface-hover",
|
||||
untracked: "hover:bg-surface-hover",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={f.path}
|
||||
className="flex items-center gap-1 text-xs font-mono cursor-pointer hover:bg-stone-100 px-1 py-0.5 rounded"
|
||||
className={`flex items-center gap-1.5 text-xs font-mono cursor-pointer px-1 py-0.5 rounded transition-colors ${groupClasses[group]}`}
|
||||
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
||||
>
|
||||
<span className={`w-3 text-center ${statusColor[group]}`}>
|
||||
{statusIcon[f.status] ?? "?"}
|
||||
<span className="w-3.5 flex items-center justify-center">
|
||||
{statusIcon[f.status] ?? <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />}
|
||||
</span>
|
||||
<span className="truncate text-zinc-600">{f.path}</span>
|
||||
<span className="truncate text-ink-muted">{f.path}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-200">
|
||||
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
|
||||
<div className="border-t border-line">
|
||||
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-ink-subtle flex items-center gap-1">
|
||||
<span>Git</span>
|
||||
{s && (
|
||||
<span className="ml-auto text-zinc-400 font-mono normal-case">
|
||||
{s.branch}
|
||||
{s.ahead > 0 && <span className="text-emerald-600 ml-1">↑{s.ahead}</span>}
|
||||
{s.behind > 0 && <span className="text-amber-600 ml-1">↓{s.behind}</span>}
|
||||
<span className="ml-auto text-ink-muted font-mono normal-case flex items-center gap-1">
|
||||
<span>{s.branch}</span>
|
||||
{s.ahead > 0 && (
|
||||
<span className="text-teal-600 flex items-center gap-0.5">
|
||||
<Icon icon={ArrowUp} className="size-2.5" />{s.ahead}
|
||||
</span>
|
||||
)}
|
||||
{s.behind > 0 && (
|
||||
<span className="text-amber-600 flex items-center gap-0.5">
|
||||
<Icon icon={ArrowDown} className="size-2.5" />{s.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-1.5 text-xs text-zinc-500">
|
||||
<div className="px-3 pb-1.5 text-xs text-ink-muted">
|
||||
{s && !s.clean && (
|
||||
<span className="text-amber-600">
|
||||
{s.unstaged.length + s.staged.length} uncommitted
|
||||
</span>
|
||||
)}
|
||||
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
||||
{s && s.clean && <span className="text-teal-600">clean</span>}
|
||||
</div>
|
||||
|
||||
{/* Changed files — dedupe by path; unstaged wins for paths in both
|
||||
buckets (a file can be both staged and unstaged for different
|
||||
parts, but unstaged is what the user still needs to act on). */}
|
||||
{/* Changed files — dedupe by path; unstaged wins */}
|
||||
{s && !s.clean && (() => {
|
||||
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
|
||||
for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" });
|
||||
@@ -197,7 +205,7 @@ export function GitPanel() {
|
||||
const rows = Array.from(byPath.values());
|
||||
return (
|
||||
<div className="px-3 pb-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-400 mb-0.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-ink-subtle mb-0.5">
|
||||
Changed files
|
||||
</div>
|
||||
{rows.map((f) => renderFileRow(f, f.group))}
|
||||
@@ -218,35 +226,34 @@ export function GitPanel() {
|
||||
}}
|
||||
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100 transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCommit()}
|
||||
disabled={!canCommit}
|
||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Button size="sm" onClick={() => void handleCommit()} disabled={!canCommit}>
|
||||
{busy ? "..." : "Commit"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => void handlePush()}
|
||||
disabled={pushBusy}
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
>
|
||||
<Icon icon={ArrowUp} className="size-3" />
|
||||
{pushBusy ? "..." : "Push"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => void handlePull()}
|
||||
disabled={pullBusy}
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
>
|
||||
<Icon icon={ArrowDown} className="size-3" />
|
||||
{pullBusy ? "..." : "Pull"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showRemoteInput && (
|
||||
@@ -256,16 +263,11 @@ export function GitPanel() {
|
||||
value={remoteUrl}
|
||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||
placeholder="git remote URL"
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||
className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSetRemote()}
|
||||
disabled={!remoteUrl.trim()}
|
||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
|
||||
>
|
||||
<Button size="sm" onClick={() => void handleSetRemote()} disabled={!remoteUrl.trim()}>
|
||||
Set
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -276,23 +278,15 @@ export function GitPanel() {
|
||||
|
||||
{/* Commit log */}
|
||||
{log.length > 0 && (
|
||||
<div className="border-t border-zinc-200">
|
||||
<div className="border-t border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogOpen(!logOpen)}
|
||||
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide"
|
||||
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-ink-subtle hover:text-ink uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<span>Log</span>
|
||||
<span className="ml-auto">
|
||||
{logOpen ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
)}
|
||||
<Icon icon={logOpen ? ChevronDown : ChevronRight} className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
{logOpen && (
|
||||
@@ -300,12 +294,12 @@ export function GitPanel() {
|
||||
{log.map((c) => (
|
||||
<div
|
||||
key={c.sha}
|
||||
className="text-xs text-zinc-500 leading-tight"
|
||||
className="text-xs text-ink-muted leading-tight"
|
||||
title={`${c.sha} — ${c.author} on ${c.date}`}
|
||||
>
|
||||
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
|
||||
<span className="font-mono text-ink-subtle">{c.sha.slice(0, 7)}</span>
|
||||
{" "}
|
||||
<span className="text-zinc-600">{c.message}</span>
|
||||
<span className="text-ink">{c.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@ export function PanelSection({
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-zinc-200 last:border-b-0">
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-zinc-400">
|
||||
<div className="border-b border-line last:border-b-0">
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-ink-subtle">
|
||||
<span>{title}</span>
|
||||
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { useRef } from "react";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useApp } from "../app-state";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
export function Preview() {
|
||||
const { state, lsp } = useApp();
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const statusText = () => {
|
||||
if (!state.currentProject) return "no project";
|
||||
if (!state.openFile) return "open a file";
|
||||
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
|
||||
if (!lsp.previewReady) return "starting…";
|
||||
return "live";
|
||||
};
|
||||
|
||||
const statusColor = () => {
|
||||
if (lsp.previewReady) return "text-emerald-600 bg-emerald-50";
|
||||
if (lsp.status === "ready") return "text-zinc-400 bg-stone-50";
|
||||
return "text-amber-600 bg-amber-50";
|
||||
};
|
||||
|
||||
const statusDot = () => {
|
||||
if (lsp.previewReady) return "bg-emerald-500";
|
||||
return "bg-zinc-300";
|
||||
const statusChip = () => {
|
||||
if (!state.currentProject) return <Chip tone="neutral">no project</Chip>;
|
||||
if (!state.openFile) return <Chip tone="neutral">open a file</Chip>;
|
||||
if (lsp.status !== "ready") return <Chip tone="amber" dot dotPulse>LSP {lsp.status}</Chip>;
|
||||
if (!lsp.previewReady) return <Chip tone="neutral">starting...</Chip>;
|
||||
return <Chip tone="teal" dot>live</Chip>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
<div className="h-full flex flex-col bg-surface">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 shrink-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-line text-xs text-ink-muted shrink-0">
|
||||
<span className="font-medium">Preview</span>
|
||||
<span className={`ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] ${statusColor()}`}>
|
||||
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
|
||||
{statusText()}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{statusChip()}
|
||||
</span>
|
||||
{lsp.previewReady && (
|
||||
<button
|
||||
@@ -39,16 +30,17 @@ export function Preview() {
|
||||
onClick={() => {
|
||||
iframeRef.current?.contentWindow?.location.reload();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded border border-zinc-200 hover:bg-stone-100 text-zinc-500"
|
||||
className="p-1 rounded-md text-ink-muted hover:bg-surface-hover transition-colors"
|
||||
aria-label="Reload preview"
|
||||
>
|
||||
Reload
|
||||
<Icon icon={RotateCw} className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-zinc-300 text-[11px] hidden sm:inline">Click preview to jump to source</span>
|
||||
<span className="text-ink-subtle text-[11px] hidden sm:inline">Click preview to jump to source</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-zinc-50 relative min-h-0">
|
||||
<div className="flex-1 bg-surface relative min-h-0">
|
||||
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
@@ -58,8 +50,8 @@ export function Preview() {
|
||||
className="absolute inset-0 w-full h-full bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
|
||||
<span>{statusText()}</span>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-ink-subtle text-sm p-6 gap-2">
|
||||
<span>{statusChip()}</span>
|
||||
{lsp.status === "error" && lsp.error && (
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs text-rose-600 bg-rose-50 p-3 rounded whitespace-pre-wrap break-all">
|
||||
{lsp.error}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import { Button } from "./ui/Button";
|
||||
import { TextInput } from "./ui/TextInput";
|
||||
|
||||
export function ProjectPicker() {
|
||||
const { state, dispatch } = useApp();
|
||||
@@ -24,8 +26,8 @@ export function ProjectPicker() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
||||
<div className="flex flex-col gap-2 p-2 border-b border-line">
|
||||
<div className="text-xs uppercase tracking-wide text-ink-subtle">
|
||||
Projects
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
@@ -34,10 +36,10 @@ export function ProjectPicker() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(p)}
|
||||
className={`w-full text-left text-sm px-2 py-1 rounded ${
|
||||
className={`w-full text-left text-sm px-2 py-1 rounded flex items-center gap-1.5 ${
|
||||
p === state.currentProject
|
||||
? "bg-emerald-100 text-emerald-800 font-medium"
|
||||
: "hover:bg-zinc-100 text-zinc-600"
|
||||
? "bg-teal-50 text-teal-700 font-medium border-l-2 border-teal-500"
|
||||
: "hover:bg-surface-hover text-ink-muted border-l-2 border-transparent"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@@ -45,26 +47,21 @@ export function ProjectPicker() {
|
||||
</li>
|
||||
))}
|
||||
{state.projects.length === 0 && (
|
||||
<li className="text-xs text-zinc-400 px-2">no projects yet</li>
|
||||
<li className="text-xs text-ink-subtle px-2">no projects yet</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create();
|
||||
}}
|
||||
placeholder="new-project"
|
||||
className="flex-1 bg-white text-zinc-800 text-sm px-2 py-1 rounded border border-zinc-300 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
|
||||
>
|
||||
<Button size="sm" onClick={create}>
|
||||
New
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ResizableSplit({
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
className="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize shrink-0 transition-colors duration-75"
|
||||
className="w-1 bg-line hover:bg-teal-400 cursor-col-resize shrink-0 transition-colors duration-75"
|
||||
onPointerDown={startDrag}
|
||||
onDoubleClick={() => {
|
||||
const pct = DEFAULT_PCT;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { TextInput } from "./ui/TextInput";
|
||||
|
||||
const STORAGE_KEY = "typst-leaf.git-identity";
|
||||
|
||||
@@ -43,12 +45,12 @@ export function SettingsPanel() {
|
||||
}, [name, email]);
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 flex flex-col gap-2">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
||||
<div className="px-3 pt-2 flex flex-col gap-2">
|
||||
<div className="text-xs uppercase tracking-wide text-ink-subtle">
|
||||
Git Identity
|
||||
</div>
|
||||
|
||||
<input
|
||||
<TextInput
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
@@ -56,10 +58,9 @@ export function SettingsPanel() {
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder="Author name"
|
||||
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
|
||||
<input
|
||||
<TextInput
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
@@ -67,33 +68,27 @@ export function SettingsPanel() {
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder="Author email"
|
||||
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
|
||||
>
|
||||
<Button size="sm" onClick={save} disabled={!dirty}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setName("");
|
||||
setEmail("");
|
||||
setDirty(false);
|
||||
}}
|
||||
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
<div className="text-xs text-ink-subtle">
|
||||
Used for Git commits. Stored locally in browser.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,62 @@
|
||||
import { GitBranch, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { useApp } from "../app-state";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
export function StatusBar() {
|
||||
const { state, lsp } = useApp();
|
||||
const s = state.gitStatus;
|
||||
|
||||
const gitSummary = () => {
|
||||
if (!s) return <span className="text-xs text-zinc-400">git · --</span>;
|
||||
if (!s) return <span className="text-xs text-ink-subtle">git · --</span>;
|
||||
return (
|
||||
<span className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<span className="text-xs text-ink-muted flex items-center gap-1">
|
||||
<Icon icon={GitBranch} className="size-3 text-ink-subtle" />
|
||||
<span className="font-mono">{s.branch}</span>
|
||||
{s.clean ? (
|
||||
<span className="text-emerald-600">· clean</span>
|
||||
<span className="text-teal-600">· clean</span>
|
||||
) : (
|
||||
<span className="text-amber-600">· {s.unstaged.length + s.staged.length} uncommitted</span>
|
||||
)}
|
||||
{s.ahead > 0 && <span className="text-emerald-600">· ↑{s.ahead}</span>}
|
||||
{s.behind > 0 && <span className="text-amber-600">· ↓{s.behind}</span>}
|
||||
{s.ahead > 0 && (
|
||||
<span className="text-teal-600 flex items-center gap-0.5">
|
||||
<Icon icon={ArrowUp} className="size-3" /> {s.ahead}
|
||||
</span>
|
||||
)}
|
||||
{s.behind > 0 && (
|
||||
<span className="text-amber-600 flex items-center gap-0.5">
|
||||
<Icon icon={ArrowDown} className="size-3" /> {s.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const lspStatus = () => {
|
||||
if (lsp.status === "ready") return <span className="text-xs text-emerald-600">LSP · ready</span>;
|
||||
if (lsp.status === "connecting") return <span className="text-xs text-amber-600">LSP · connecting</span>;
|
||||
if (lsp.status === "error") return <span className="text-xs text-rose-600">LSP · error</span>;
|
||||
return <span className="text-xs text-zinc-400">LSP · idle</span>;
|
||||
if (lsp.status === "ready") return <Chip tone="teal" dot>LSP</Chip>;
|
||||
if (lsp.status === "connecting") return <Chip tone="amber" dot dotPulse>LSP</Chip>;
|
||||
if (lsp.status === "error") return <Chip tone="rose" dot>LSP</Chip>;
|
||||
return <Chip tone="neutral">LSP · idle</Chip>;
|
||||
};
|
||||
|
||||
const previewStatus = () => {
|
||||
if (!state.openFile) return null;
|
||||
if (lsp.previewReady) return <span className="text-xs text-emerald-600">Preview · live</span>;
|
||||
if (lsp.status === "ready" && !lsp.previewReady) return <span className="text-xs text-zinc-400">Preview · starting</span>;
|
||||
if (lsp.previewReady) return <Chip tone="teal" dot>Preview</Chip>;
|
||||
if (lsp.status === "ready" && !lsp.previewReady) return <Chip tone="neutral">Preview · starting</Chip>;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="flex items-center gap-4 border-t border-zinc-200 bg-white px-4 h-7 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{gitSummary()}
|
||||
</div>
|
||||
<span className="text-zinc-200 select-none">|</span>
|
||||
<footer className="flex items-center gap-4 border-t border-line bg-canvas px-4 h-7 shrink-0">
|
||||
<div className="flex items-center gap-2">{gitSummary()}</div>
|
||||
<span className="text-line select-none">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{lspStatus()}
|
||||
{previewStatus()}
|
||||
{state.vimOn && <span className="text-xs text-zinc-400">vim</span>}
|
||||
{state.vimOn && <span className="text-xs text-ink-subtle font-mono">vim</span>}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-zinc-400">
|
||||
{state.saving && <span className="text-amber-500">saving…</span>}
|
||||
<span className="ml-auto text-xs text-ink-subtle">
|
||||
{state.saving && <span className="text-amber-600">saving…</span>}
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useApp } from "../app-state";
|
||||
import { StatusDot } from "./ui/StatusDot";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
export function ToastHost() {
|
||||
const { state, dispatch } = useApp();
|
||||
@@ -12,33 +15,32 @@ export function ToastHost() {
|
||||
|
||||
if (!state.notice) return null;
|
||||
|
||||
const colors = {
|
||||
success: "bg-emerald-50 text-emerald-800 border-emerald-200",
|
||||
const colors: Record<string, string> = {
|
||||
success: "bg-teal-50 text-teal-800 border-teal-200",
|
||||
error: "bg-rose-50 text-rose-800 border-rose-200",
|
||||
info: "bg-stone-50 text-zinc-700 border-zinc-200",
|
||||
info: "bg-surface text-ink border-line",
|
||||
};
|
||||
const dots = {
|
||||
success: "bg-emerald-500",
|
||||
error: "bg-rose-500",
|
||||
info: "bg-zinc-400",
|
||||
|
||||
const dotColor: Record<string, "teal" | "rose" | "neutral"> = {
|
||||
success: "teal",
|
||||
error: "rose",
|
||||
info: "neutral",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 right-4 z-50 flex flex-col gap-2">
|
||||
<div className="fixed bottom-8 right-4 z-50">
|
||||
<div
|
||||
className={`border rounded-md shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
|
||||
className={`border rounded-lg shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
|
||||
>
|
||||
<span className={`inline-block size-2 rounded-full shrink-0 ${dots[state.notice.kind]}`} />
|
||||
<StatusDot color={dotColor[state.notice.kind]} />
|
||||
{state.notice.message}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "dismissNotice" })}
|
||||
className="ml-2 text-zinc-400 hover:text-zinc-600"
|
||||
className="ml-2 text-ink-subtle hover:text-ink transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
<Icon icon={X} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Leaf, Settings } from "lucide-react";
|
||||
import { useApp } from "../app-state";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
export function TopBar() {
|
||||
const { state, dispatch, lsp } = useApp();
|
||||
@@ -6,99 +11,74 @@ export function TopBar() {
|
||||
const lspChip = () => {
|
||||
switch (lsp.status) {
|
||||
case "ready":
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 flex items-center gap-1">
|
||||
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
|
||||
LSP
|
||||
</span>
|
||||
);
|
||||
return <Chip tone="teal" dot>LSP</Chip>;
|
||||
case "connecting":
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 flex items-center gap-1">
|
||||
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
LSP
|
||||
</span>
|
||||
);
|
||||
return <Chip tone="amber" dot dotPulse>LSP</Chip>;
|
||||
case "error":
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 flex items-center gap-1">
|
||||
<span className="inline-block size-1.5 rounded-full bg-rose-500" />
|
||||
LSP
|
||||
</span>
|
||||
);
|
||||
return <Chip tone="rose" dot>LSP</Chip>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const previewChip = () => {
|
||||
// No preview without an open file (tinymist previews a document).
|
||||
if (!state.openFile) return null;
|
||||
if (lsp.previewReady) {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200 flex items-center gap-1">
|
||||
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
|
||||
Preview
|
||||
</span>
|
||||
);
|
||||
return <Chip tone="teal" dot>Preview</Chip>;
|
||||
}
|
||||
if (lsp.status === "ready") {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-400 border border-zinc-200 flex items-center gap-1">
|
||||
<span className="inline-block size-1.5 rounded-full bg-zinc-300" />
|
||||
Preview · starting
|
||||
</span>
|
||||
);
|
||||
return <Chip tone="neutral" dot={false}>Preview · starting</Chip>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11 shrink-0">
|
||||
<span className="font-semibold tracking-tight text-zinc-800">typst-leaf</span>
|
||||
<span className="text-zinc-300 select-none">·</span>
|
||||
<header className="flex items-center gap-2 border-b border-line bg-canvas px-4 h-12 shrink-0">
|
||||
<Icon icon={Leaf} className="text-teal-600 size-5" />
|
||||
<span className="font-semibold tracking-tight text-ink">typst-leaf</span>
|
||||
|
||||
{state.currentProject ? (
|
||||
<>
|
||||
<span className="text-sm text-zinc-800 font-medium truncate max-w-36">
|
||||
<span className="text-line select-none">·</span>
|
||||
<span className="text-sm text-ink-muted font-medium truncate max-w-36">
|
||||
{state.currentProject}
|
||||
</span>
|
||||
{state.openFile && (
|
||||
<>
|
||||
<span className="text-zinc-300 select-none">/</span>
|
||||
<span className="text-sm font-mono text-zinc-500 truncate max-w-48">
|
||||
<span className="text-line select-none">/</span>
|
||||
<span className="text-sm font-mono text-ink-subtle truncate max-w-48">
|
||||
{state.openFile}
|
||||
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{lspChip()}
|
||||
{previewChip()}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
|
||||
}}
|
||||
disabled={!state.openFile}
|
||||
className="px-3 py-1 text-sm rounded bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: state.activePanel === "settings" ? "files" : "settings" })}
|
||||
className={`p-1.5 rounded-md ${state.activePanel === "settings" ? "text-emerald-700 bg-emerald-50" : "text-zinc-500 hover:bg-stone-100"}`}
|
||||
title="Settings"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4">
|
||||
<path d="M10 3.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 6.25a2.25 2.25 0 0 0 4.312-.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 3.75zM10 13.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 16.25a2.25 2.25 0 0 0 4.312.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 13.75z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "setActivePanel",
|
||||
panel: state.activePanel === "settings" ? "files" : "settings",
|
||||
})
|
||||
}
|
||||
className={state.activePanel === "settings" ? "text-teal-600 bg-teal-50 hover:bg-teal-50" : ""}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">Select a project to begin</span>
|
||||
<span className="text-sm text-ink-subtle ml-auto">Select a project to begin</span>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors duration-150 active:scale-[0.98] disabled:opacity-40 disabled:pointer-events-none focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-2";
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
primary: "bg-teal-600 text-white hover:bg-teal-700",
|
||||
secondary:
|
||||
"bg-surface text-ink-muted border border-line-strong hover:bg-surface-hover",
|
||||
ghost: "text-ink-muted hover:bg-surface-hover",
|
||||
danger: "bg-rose-600 text-white hover:bg-rose-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StatusDot } from "./StatusDot";
|
||||
|
||||
const chipStyles: Record<string, string> = {
|
||||
teal: "bg-teal-50 text-teal-700 border-teal-200",
|
||||
amber: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
rose: "bg-rose-50 text-rose-700 border-rose-200",
|
||||
neutral: "bg-surface text-ink-muted border-line",
|
||||
};
|
||||
|
||||
interface ChipProps {
|
||||
tone?: "teal" | "amber" | "rose" | "neutral";
|
||||
dot?: boolean;
|
||||
dotPulse?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Chip({ tone = "neutral", dot = false, dotPulse = false, children }: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full border ${chipStyles[tone]}`}
|
||||
>
|
||||
{dot && <StatusDot color={tone === "neutral" ? "neutral" : tone} pulse={dotPulse} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type LucideIcon, type LucideProps } from "lucide-react";
|
||||
|
||||
interface IconProps extends LucideProps {
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function Icon({ icon: LucideIcon, className = "", ...props }: IconProps) {
|
||||
return <LucideIcon className={`size-4 stroke-[1.5] ${className}`} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function IconButton({ icon, label, className = "", ...props }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className={`inline-flex items-center justify-center size-7 rounded-md text-ink-muted hover:bg-surface-hover transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-teal-100 focus-visible:ring-offset-1 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon={icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface SectionHeaderProps {
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeader({ children, actions }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-ink-subtle">
|
||||
<span className="truncate">{children}</span>
|
||||
{actions && <span className="ml-auto flex items-center gap-0.5">{actions}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const dotColors: Record<string, string> = {
|
||||
teal: "bg-teal-500",
|
||||
amber: "bg-amber-500",
|
||||
rose: "bg-rose-500",
|
||||
neutral: "bg-ink-subtle",
|
||||
};
|
||||
|
||||
interface StatusDotProps {
|
||||
color?: "teal" | "amber" | "rose" | "neutral";
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function StatusDot({ color = "neutral", pulse = false }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block size-1.5 rounded-full ${dotColors[color]} ${pulse ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
// styled input
|
||||
}
|
||||
|
||||
export function TextInput({ className = "", ...props }: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={`w-full px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100 transition-colors duration-150 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter Variable", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", monospace;
|
||||
|
||||
--color-canvas: #faf8f4;
|
||||
--color-surface: #fdfdfb;
|
||||
--color-surface-hover: #f3f1ec;
|
||||
--color-ink: #1c1917;
|
||||
--color-ink-muted: #78716c;
|
||||
--color-ink-subtle: #a8a29e;
|
||||
--color-line: #e7e5e4;
|
||||
--color-line-strong: #d6d3d1;
|
||||
|
||||
--color-teal-50: #f0f9f8;
|
||||
--color-teal-100: #d5edea;
|
||||
--color-teal-500: #14b8a6;
|
||||
--color-teal-600: #0f766e;
|
||||
--color-teal-700: #115e59;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export function defineTypstLeafTheme(monaco: {
|
||||
editor: {
|
||||
defineTheme: (name: string, theme: object) => void;
|
||||
};
|
||||
}) {
|
||||
monaco.editor.defineTheme("typst-leaf-light", {
|
||||
base: "vs",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "115e59", fontStyle: "bold" },
|
||||
{ token: "tag", foreground: "115e59", fontStyle: "bold" },
|
||||
{ token: "string", foreground: "047857" },
|
||||
{ token: "string.quoted", foreground: "047857" },
|
||||
{ token: "comment", foreground: "a8a29e", fontStyle: "italic" },
|
||||
{ token: "number", foreground: "b45309" },
|
||||
{ token: "type", foreground: "1c1917" },
|
||||
{ token: "type.identifier", foreground: "1c1917" },
|
||||
{ token: "function", foreground: "1c1917" },
|
||||
{ token: "function.name", foreground: "1c1917" },
|
||||
{ token: "operator", foreground: "78716c" },
|
||||
{ token: "delimiter", foreground: "78716c" },
|
||||
{ token: "variable", foreground: "1c1917" },
|
||||
{ token: "parameter", foreground: "1c1917" },
|
||||
{ token: "attribute", foreground: "0f766e" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#fdfdfb",
|
||||
"editor.foreground": "#1c1917",
|
||||
"editorLineNumber.foreground": "#a8a29e",
|
||||
"editorLineNumber.activeForeground": "#78716c",
|
||||
"editor.selectionBackground": "#f0f9f8",
|
||||
"editor.selectionHighlightBackground": "#d5edea80",
|
||||
"editor.lineHighlightBackground": "#faf8f4",
|
||||
"editorCursor.foreground": "#0f766e",
|
||||
"editorWhitespace.foreground": "#e7e5e4",
|
||||
"editorBracketMatch.background": "#d5edea",
|
||||
"editorBracketMatch.border": "#0f766e40",
|
||||
"editorGutter.background": "#faf8f4",
|
||||
"editorRuler.foreground": "#e7e5e4",
|
||||
"editorOverviewRuler.border": "#e7e5e4",
|
||||
"editorWidget.background": "#fdfdfb",
|
||||
"editorWidget.border": "#e7e5e4",
|
||||
"editorSuggestWidget.background": "#fdfdfb",
|
||||
"editorSuggestWidget.border": "#e7e5e4",
|
||||
"editorSuggestWidget.selectedBackground": "#f0f9f8",
|
||||
"editorHoverWidget.background": "#fdfdfb",
|
||||
"editorHoverWidget.border": "#e7e5e4",
|
||||
"editorIndentGuide.background": "#e7e5e4",
|
||||
"editorIndentGuide.activeBackground": "#d6d3d1",
|
||||
"editorInlayHint.background": "#f3f1ec",
|
||||
"editorInlayHint.foreground": "#78716c",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user