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:
@@ -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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "files"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "git"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
Git
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: "settings" })}
|
||||
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
|
||||
state.activePanel === "settings"
|
||||
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
|
||||
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<div className="flex border-b border-line text-xs">
|
||||
{panelTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "setActivePanel", panel: tab.id })}
|
||||
className={`flex items-center justify-center gap-1.5 flex-1 py-1.5 transition-colors duration-150 ${
|
||||
state.activePanel === tab.id
|
||||
? "text-teal-600 border-b-2 border-teal-500 bg-surface"
|
||||
: "text-ink-muted hover:text-ink hover:bg-surface-hover"
|
||||
}`}
|
||||
>
|
||||
<Icon icon={tab.icon} className="size-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{state.activePanel === "files" && (
|
||||
<PanelSection title="Files">
|
||||
{state.currentProject ? <FileTree /> : (
|
||||
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
|
||||
<div className="px-3 py-2 text-xs text-ink-subtle">select a project</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
@@ -81,7 +71,7 @@ function Shell() {
|
||||
</aside>
|
||||
|
||||
{/* Main workspace */}
|
||||
<main className="flex-1 flex min-w-0 bg-white">
|
||||
<main className="flex-1 flex min-w-0 bg-surface">
|
||||
{state.currentProject && state.openFile ? (
|
||||
<ResizableSplit
|
||||
left={<EditorView />}
|
||||
@@ -103,7 +93,7 @@ function Shell() {
|
||||
<div className="flex-1">
|
||||
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
|
||||
</div>
|
||||
<div className="w-96 border-l border-zinc-200">
|
||||
<div className="w-96 border-l border-line">
|
||||
<Preview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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