From c417aa003b5da924243a3f1fd5a30d8f51816bc5 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 21 Mar 2026 03:29:43 -0500 Subject: [PATCH] refactor: redesign to darker colors --- docs/specs/archives-trash.md | 215 ++++++++++++++++++ docs/specs/image-handling.md | 123 ++++++++++ docs/tasks/archives-trash.md | 90 ++++++++ docs/tasks/image-handling.md | 66 ++++++ docs/tasks/parking-lot.md | 6 + package.json | 3 + pnpm-lock.yaml | 24 ++ src/App.css | 148 ++++++------ src/__tests__/AppHeaderBar.test.tsx | 52 +++-- src/__tests__/Toolbar.test.tsx | 48 +++- src/__tests__/WorkspacePanel.test.tsx | 48 +++- src/components/AppLayout/AppHeaderBar.tsx | 238 ++++---------------- src/components/AppLayout/SearchOverlay.tsx | 1 - src/components/AppLayout/WorkspacePanel.tsx | 80 ++++++- src/components/Button.tsx | 18 +- src/components/Preview.tsx | 2 +- src/components/SearchPanel/Buttons.tsx | 22 +- src/components/SearchPanel/SearchPanel.tsx | 75 +++--- src/components/Sheet/Sheet.tsx | 2 +- src/components/Sidebar/Sidebar.tsx | 51 ++--- src/components/Sidebar/Title.tsx | 6 +- src/components/Sidebar/TreeItem.tsx | 2 +- src/components/Toolbar/Toolbar.tsx | 52 +++-- src/styles/fonts.css | 4 + 24 files changed, 983 insertions(+), 393 deletions(-) create mode 100644 docs/specs/archives-trash.md create mode 100644 docs/specs/image-handling.md create mode 100644 docs/tasks/archives-trash.md create mode 100644 docs/tasks/image-handling.md diff --git a/docs/specs/archives-trash.md b/docs/specs/archives-trash.md new file mode 100644 index 0000000..987168c --- /dev/null +++ b/docs/specs/archives-trash.md @@ -0,0 +1,215 @@ +--- +title: Archives & Trash Spec +updated: 2026-03-21 +--- + + +> Goal: Replace permanent deletion with a recoverable trash flow and add an archive mechanism for decluttering without destroying. + +## Problem + +All deletions are permanent and immediate — `std::fs::remove_file` / `remove_dir_all` with no confirmation, no undo, no recovery. One misclick loses work forever. There is also no way to "shelve" documents without deleting them. + +## Design + +### Two Concepts + +1. **Archive** — User-initiated. Moves a document out of the active sidebar into a separate "Archive" section. The file stays on disk in its original location. Reversible. +2. **Trash** — Triggered by "Delete". Soft-deletes the document. File moves to a `.writer-trash/` directory. Auto-purged after 30 days. Reversible within the retention window. + +### Database Schema Changes + +Extend the `documents` table: + +```sql +ALTER TABLE documents ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; +-- status: 'active' | 'archived' | 'trashed' + +ALTER TABLE documents ADD COLUMN status_changed_at TEXT; +-- ISO 8601 timestamp of last status change +``` + +No new tables. The `status` column filters what appears in the sidebar. + +### Rust Model Changes + +In `crates/core/src/lib.rs`: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DocStatus { + Active, + Archived, + Trashed, +} + +pub struct DocMeta { + // ... existing fields ... + pub status: DocStatus, + pub status_changed_at: Option>, +} +``` + +### Trash Storage + +```sh +/ + .writer-trash/ + __.md +``` + +- When trashed, the file is **moved** from its original path into `.writer-trash/`. +- The filename encodes the original relative path (URL-encoded to flatten subdirs) and a timestamp for uniqueness. +- The SQLite index retains the row with `status = 'trashed'` and the original `rel_path` for restore. + +### Tauri Commands + +#### `doc_archive` + +```rust +#[tauri::command] +pub fn doc_archive(location_id: LocationId, rel_path: PathBuf) -> Result +``` + +- Sets `status = 'archived'`, `status_changed_at = now()` in the index. +- File stays on disk, untouched. + +#### `doc_unarchive` + +```rust +#[tauri::command] +pub fn doc_unarchive(location_id: LocationId, rel_path: PathBuf) -> Result +``` + +- Sets `status = 'active'`, clears `status_changed_at`. + +#### `doc_trash` + +```rust +#[tauri::command] +pub fn doc_trash(location_id: LocationId, rel_path: PathBuf) -> Result +``` + +- Moves file from original path → `.writer-trash/`. +- Updates index: `status = 'trashed'`, `status_changed_at = now()`. + +#### `doc_restore` + +```rust +#[tauri::command] +pub fn doc_restore(location_id: LocationId, rel_path: PathBuf) -> Result +``` + +- Moves file from `.writer-trash/` back to original path. +- If original path is occupied (name collision), appends `(restored)` before the extension. +- Updates index: `status = 'active'`, clears `status_changed_at`. + +#### `doc_delete` (modified) + +Existing `doc_delete` becomes **permanent delete from trash only**: + +```rust +#[tauri::command] +pub fn doc_delete(location_id: LocationId, rel_path: PathBuf) -> Result +``` + +- Only operates on documents with `status = 'trashed'`. +- Removes file from `.writer-trash/` and deletes the index row. +- Refuses to permanently delete `active` or `archived` documents — they must be trashed first. + +#### `trash_empty` + +```rust +#[tauri::command] +pub fn trash_empty(location_id: LocationId) -> Result +``` + +- Permanently deletes all trashed documents in the location. +- Returns count of deleted documents. + +#### `trash_auto_purge` (internal) + +- Called on app startup and periodically (e.g., every hour via a Tauri async task). +- Permanently deletes any trashed document where `status_changed_at` is older than 30 days. +- Not exposed as a user-facing command. + +### Directory Handling + +For `dir_delete`: + +- Trash all documents inside the directory individually (so each can be restored). +- Remove the now-empty directory from disk. +- If the directory contains subdirectories, recurse. + +For `dir_archive`: + +- Archive all documents inside the directory. +- Directory remains on disk. + +### Frontend + +#### Sidebar Sections + +The sidebar file tree filters by `status`: + +- **Active documents** — the default tree (current behavior, unchanged). +- **Archive section** — collapsed by default, shows archived documents. Click to view, right-click to unarchive. +- **Trash section** — collapsed by default, shows trashed documents with "Restore" and "Delete Permanently" actions. Shows time remaining before auto-purge. + +#### Context Menu Updates + +| Action | Current Behavior | New Behavior | +| ------ | ---------------- | --------------------------------------- | +| Delete | Permanent delete | Move to trash | +| — | — | Archive (new) | +| — | — | Restore (in trash/archive views) | +| — | — | Delete Permanently (in trash view only) | +| — | — | Empty Trash (trash section header) | + +#### State + +Add to the app store: + +```typescript +// Selectors filter by status +useActiveDocuments(); // status === 'active' +useArchivedDocuments(); // status === 'archived' +useTrashedDocuments(); // status === 'trashed' +``` + +The existing document list selectors must filter to `active` only, so archived/trashed documents don't appear in the main tree. + +#### Confirmation Dialog + +- **Trash**: No confirmation needed (recoverable). +- **Archive**: No confirmation needed (reversible). +- **Empty Trash / Permanent Delete**: Confirmation dialog required ("This cannot be undone"). + +### Indexing & Sync + +When the file watcher detects changes: + +- If a file reappears at a previously trashed path (e.g., user manually moved it back), update status to `active`. +- If a trashed file disappears from `.writer-trash/` (e.g., user manually deleted it), remove the index row. + +--- + +## Scope Boundaries + +**In scope:** + +- Soft delete (trash) with 30-day auto-purge +- Archive/unarchive +- Restore from trash +- Permanent delete from trash only +- Sidebar sections for archive and trash +- Context menu actions +- Auto-purge on startup + +**Out of scope (future):** + +- Undo toast ("Document trashed — Undo") with immediate restore +- Trash/archive for directories as first-class entities +- Trash across locations (each location has its own `.writer-trash/`) +- Version history / snapshots +- Cloud sync of trash state diff --git a/docs/specs/image-handling.md b/docs/specs/image-handling.md new file mode 100644 index 0000000..459f8d0 --- /dev/null +++ b/docs/specs/image-handling.md @@ -0,0 +1,123 @@ +--- +title: Image Handling Spec +updated: 2026-03-21 +--- + +> Goal: Support local image embedding in markdown documents with storage, preview, and lifecycle management. + +## Problem + +Images in documents are currently only supported through AT Protocol Leaflet blob references (`at://blob/CID`). There is no local image support — no upload, no storage, no preview, no drag-and-drop. Users cannot embed images in their markdown files. + +## Design + +### Storage Model + +Each location gets an asset directory at its root: + +```sh +/ + .writer-assets/ + .png + .jpg + ... + my-document.md + drafts/ + another-doc.md +``` + +- **Directory**: `.writer-assets/` — hidden by convention, one per location. +- **Naming**: content-addressed (`blake3` hash of file bytes + original extension). Prevents duplicates and naming collisions. +- **Formats**: PNG, JPEG, GIF, WebP, SVG. Reject anything else at the command boundary. +- **Size limit**: 10 MB per image. Enforced in the Tauri command. + +### Markdown Reference Format + +Standard markdown image syntax with a relative path: + +```markdown +![Alt text](.writer-assets/abc123def.png) +``` + +- Relative to the document's location root, not the document's own directory. +- When a document is in a subdirectory (`drafts/doc.md`), the path is still relative to location root: `![img](../.writer-assets/abc123.png)` — or the editor resolves it at render time. + +### Tauri Commands (Rust) + +#### `image_import` + +```rust +#[tauri::command] +pub fn image_import(location_id: LocationId, source_path: PathBuf) -> Result +``` + +- Copies source file into `.writer-assets/`. +- Hashes contents, derives filename. +- Returns the relative asset path string (e.g., `.writer-assets/abc123.png`). +- If hash already exists, returns existing path (dedup). +- Validates format and size before copying. + +#### `image_delete` + +```rust +#[tauri::command] +pub fn image_delete(location_id: LocationId, asset_path: String) -> Result +``` + +- Removes the file from `.writer-assets/`. +- Does **not** scan documents for dangling references (user's responsibility, or future cleanup pass). + +#### `image_list` + +```rust +#[tauri::command] +pub fn image_list(location_id: LocationId) -> Result, Error> +``` + +- Returns all images in `.writer-assets/` with metadata (filename, size, dimensions if cheaply available). + +### Frontend + +#### Editor Integration + +- **Paste**: intercept clipboard paste events containing image data. Call `image_import` with a temp file, insert markdown reference at cursor. +- **Drag-and-drop**: intercept file drop on the editor area. Same flow as paste. +- **Toolbar button**: "Insert Image" opens a file picker dialog (Tauri `dialog::open`), imports, inserts reference. + +#### Preview Rendering + +- The markdown preview must resolve `.writer-assets/` paths to `asset:` protocol URLs (Tauri asset protocol) or `convertFileSrc()` for display. +- Images render inline with `max-width: 100%` and optional click-to-zoom. + +#### State + +No dedicated image store slice needed. Images are embedded in document text as markdown. The `image_list` command is called on-demand when needed (e.g., an asset manager UI, if ever built). + +### Indexing + +The document index (`documents` table in SQLite) does **not** track images. Images are filesystem-only artifacts referenced by markdown text. This keeps the model simple — no foreign keys, no orphan tracking. + +### Cleanup + +Orphaned images (not referenced by any document) accumulate over time. A future `image_cleanup` command can scan all documents in a location and remove unreferenced assets. This is **out of scope** for the initial implementation. + +--- + +## Scope Boundaries + +**In scope:** + +- Import from file picker, paste, drag-and-drop +- Content-addressed storage in `.writer-assets/` +- Markdown reference insertion +- Preview rendering via Tauri asset protocol +- Single image delete command + +**Out of scope (future):** + +- Image resizing / thumbnails +- Orphan cleanup +- Image editing / cropping +- Gallery / asset manager UI +- AT Protocol image sync (Leaflet blob ↔ local asset) +- PDF export with embedded images (depends on PDF pipeline) diff --git a/docs/tasks/archives-trash.md b/docs/tasks/archives-trash.md new file mode 100644 index 0000000..2e837aa --- /dev/null +++ b/docs/tasks/archives-trash.md @@ -0,0 +1,90 @@ +--- +title: Archives & Trash +updated: 2026-03-21 +--- + +## Phase 1: Database & Model + +- [ ] Add `status` and `status_changed_at` columns to `documents` table + - Migration: `ALTER TABLE documents ADD COLUMN status TEXT NOT NULL DEFAULT 'active'` + - Migration: `ALTER TABLE documents ADD COLUMN status_changed_at TEXT` +- [ ] Add `DocStatus` enum to `crates/core/src/lib.rs` + - `Active`, `Archived`, `Trashed` +- [ ] Extend `DocMeta` struct with `status: DocStatus` and `status_changed_at: Option>` +- [ ] Update all queries that list/fetch documents to include `status` field + - Default sidebar queries filter to `status = 'active'` +- [ ] Add `.writer-trash/` directory creation on location init + +## Phase 2: Tauri Commands (Archive) + +- [ ] Implement `doc_archive` command + - Set `status = 'archived'`, `status_changed_at = now()` in index + - File stays on disk +- [ ] Implement `doc_unarchive` command + - Set `status = 'active'`, clear `status_changed_at` +- [ ] Implement `dir_archive` command + - Archive all documents inside directory recursively + +## Phase 3: Tauri Commands (Trash) + +- [ ] Implement `doc_trash` command + - Move file to `.writer-trash/__.md` + - Update index: `status = 'trashed'`, `status_changed_at = now()` +- [ ] Implement `doc_restore` command + - Move file from `.writer-trash/` back to original path + - Handle name collisions (append `(restored)`) + - Update index: `status = 'active'` +- [ ] Modify existing `doc_delete` to only operate on trashed documents + - Refuse to permanently delete `active` or `archived` docs + - Remove file from `.writer-trash/` and delete index row +- [ ] Implement `trash_empty` command + - Permanently delete all trashed documents in a location + - Return count +- [ ] Implement auto-purge on startup + - Delete trashed documents older than 30 days + - Run as async Tauri task, also periodically (hourly) +- [ ] Update `dir_delete` to trash all child documents individually, then remove empty directory + +## Frontend Ports & State + +- [ ] Add command builders in `src/ports/commands.ts` + - `docArchive`, `docUnarchive`, `docTrash`, `docRestore`, `trashEmpty` +- [ ] Add selectors in `src/state/selectors.ts` + - `useActiveDocuments()` — filters `status === 'active'` + - `useArchivedDocuments()` — filters `status === 'archived'` + - `useTrashedDocuments()` — filters `status === 'trashed'` +- [ ] Update existing document list selectors to filter `active` only +- [ ] Extend workspace controller with archive/trash actions + +## Sidebar UI + +- [ ] Add "Archive" section to sidebar + - Collapsed by default, shows archived documents + - Right-click → Unarchive +- [ ] Add "Trash" section to sidebar + - Collapsed by default, shows trashed documents + - Shows days remaining before auto-purge per item + - Right-click → Restore, Delete Permanently + - Section header → Empty Trash action +- [ ] Update context menu on active documents + - Replace "Delete" with "Move to Trash" + - Add "Archive" option +- [ ] Add confirmation dialog for permanent delete and empty trash + - "This cannot be undone" messaging + +## File Watcher Sync + +- [ ] If a file reappears at a previously trashed path, update status to `active` +- [ ] If a trashed file disappears from `.writer-trash/`, remove index row +- [ ] Exclude `.writer-trash/` from normal document indexing + +## Test Plan + +- [ ] Test archive → unarchive round-trip +- [ ] Test trash → restore round-trip +- [ ] Test trash → permanent delete +- [ ] Test empty trash +- [ ] Test auto-purge (mock time or short retention for test) +- [ ] Test name collision on restore +- [ ] Test directory trash (all children trashed individually) +- [ ] Test file watcher sync scenarios diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md new file mode 100644 index 0000000..0f2c0f7 --- /dev/null +++ b/docs/tasks/image-handling.md @@ -0,0 +1,66 @@ +--- +title: Image Handling +updated: 2026-03-21 +--- + +## Backend (Tauri + Rust) + +- [ ] Add `.writer-assets/` directory creation on location init + - Create directory if missing when a location is opened + - Add to `.gitignore`-style ignore list for file watcher (don't index asset files as documents) +- [ ] Implement `image_import` command + - Validate format (PNG, JPEG, GIF, WebP, SVG) and size (≤10 MB) + - Hash file contents with blake3, derive filename + - Copy to `.writer-assets/.` + - Dedup: if hash exists, return existing path + - Return relative asset path string +- [ ] Implement `image_delete` command + - Remove file from `.writer-assets/` + - No dangling reference scan +- [ ] Implement `image_list` command + - List all files in `.writer-assets/` + - Return filename, size, extension +- [ ] Register commands in `lib.rs` and expose via Tauri + +## Frontend Ports & State + +- [ ] Add command builders in `src/ports/commands.ts` + - `imageImport(locationId, sourcePath, onOk, onErr)` + - `imageDelete(locationId, assetPath, onOk, onErr)` + - `imageList(locationId, onOk, onErr)` +- [ ] Add controller hook `useImageController` or extend workspace controller + - `importImage(file)` — write temp file, call `image_import`, insert markdown at cursor + - `deleteImage(assetPath)` — call `image_delete` + +## Editor Integration + +- [ ] Paste handler + - Intercept clipboard paste with image data + - Write to temp file, call `image_import`, insert `![image](.writer-assets/hash.ext)` at cursor +- [ ] Drag-and-drop handler + - Intercept file drop on editor area + - Filter to supported image formats + - Same import + insert flow as paste +- [ ] **3.3** Toolbar "Insert Image" button + - Open Tauri file picker dialog filtered to image types + - Import selected file, insert reference + +## Preview Rendering + +- [ ] Resolve `.writer-assets/` paths in markdown preview + - Use `convertFileSrc()` or Tauri asset protocol to create displayable URLs + - Handle relative path resolution for documents in subdirectories +- [ ] Image display styling + - `max-width: 100%`, responsive within content column + - Maintain aspect ratio +- [ ] Click-to-zoom (optional polish) + - Click image in preview to open full-size overlay + +## Test Plan + +- [ ] Test import with each supported format +- [ ] Test dedup (import same image twice) +- [ ] Test paste and drag-and-drop flows +- [ ] Test preview rendering with nested document paths +- [ ] `pnpm test:run` + `cargo test` passing +- [ ] `pnpm lint` + `pnpm check` clean diff --git a/docs/tasks/parking-lot.md b/docs/tasks/parking-lot.md index c7a71da..263d874 100644 --- a/docs/tasks/parking-lot.md +++ b/docs/tasks/parking-lot.md @@ -13,3 +13,9 @@ updated: 2026-03-19 3. **Recovery** - Corrupt settings/workspace → app resets safely - Missing location root → UI prompts to relink/remove + +--- + +- We should let users pick editor and PDF font in settings and export +- The new/empty (when nothing is open) document/buffer isn't good UI. We should show a welcome screen with some options like create new, open existing, import, etc. +- Toggling off word wrap should be reflected in the editor by removing horizontal padding/margins diff --git a/package.json b/package.json index 629f1c2..7cbd042 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.14", "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/libre-franklin": "^5.2.8", + "@fontsource-variable/space-grotesk": "^5.2.10", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.6.3", "@react-pdf/renderer": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b13fab5..bf51796 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,15 @@ importers: '@fontsource-variable/ibm-plex-sans': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/libre-franklin': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/space-grotesk': + specifier: ^5.2.10 + version: 5.2.10 '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 @@ -642,6 +651,15 @@ packages: '@fontsource-variable/ibm-plex-sans@5.2.8': resolution: {integrity: sha512-n5PF2iFa0CZT0QYTPzxvZ39opC9LnU0zdoRccoADbs+Dtsd+lbXOZF7RNuIPHcQX1dKjF63sxnRImQIB5eD0Ag==} + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@fontsource-variable/libre-franklin@5.2.8': + resolution: {integrity: sha512-Ffl+p4aO3jcZmxzDmRafi5e6bI3qzMN3UxG2QOSvpeKgXMr93jtHOmlLHMsBOYccnpDmL55PBq65ayQJmfC0Rw==} + + '@fontsource-variable/space-grotesk@5.2.10': + resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==} + '@iconify-json/bi@1.2.7': resolution: {integrity: sha512-IPz8WNxmLkH1I9msl+0Q4OnmjjvP4uU0Z61a4i4sqonB6vKSbMGUWuGn8/YuuszlReVj8rf+3gNv5JU8Xoljyg==} @@ -3553,6 +3571,12 @@ snapshots: '@fontsource-variable/ibm-plex-sans@5.2.8': {} + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@fontsource-variable/libre-franklin@5.2.8': {} + + '@fontsource-variable/space-grotesk@5.2.10': {} + '@iconify-json/bi@1.2.7': dependencies: '@iconify/types': 2.0.0 diff --git a/src/App.css b/src/App.css index f373499..b664761 100644 --- a/src/App.css +++ b/src/App.css @@ -5,51 +5,61 @@ @theme { --font-sans: - "IBM Plex Sans Variable", "IBM Plex Sans", -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + "Libre Franklin Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, sans-serif; + --font-headline: "Space Grotesk Variable", var(--font-sans); --font-serif: "Writer IBM Plex Serif", "IBM Plex Serif", Georgia, "Times New Roman", serif; --font-mono: - "Writer IBM Plex Mono", "IBM Plex Mono", "SF Mono", Monaco, - "Cascadia Code", monospace; - - --color-surface-primary: #161616; - --color-surface-hover: #262626; - --color-surface-active: #393939; - - --color-layer-01: #262626; - --color-layer-02: #393939; - --color-layer-03: #525252; - --color-layer-hover-01: #393939; - --color-layer-hover-02: #525252; - --color-layer-accent-01: #393939; - --color-layer-accent-02: #525252; - --color-field-01: #161616; - --color-field-02: #262626; - --color-field-hover-01: #262626; - --color-field-hover-02: #393939; - - --color-stroke-subtle: #393939; - --color-stroke-strong: #525252; + "JetBrains Mono Variable", "Writer IBM Plex Mono", "IBM Plex Mono", + "SF Mono", Monaco, "Cascadia Code", monospace; + + --radius: 0.125rem; + --radius-lg: 0.25rem; + --radius-xl: 0.5rem; + --radius-full: 0.75rem; + + --color-surface-primary: #0d0e10; + --color-surface-hover: #1d2024; + --color-surface-active: #23262b; + --color-surface-lowest: #000000; + --color-surface-bright: #282c33; + + --color-layer-01: #121316; + --color-layer-02: #181a1d; + --color-layer-03: #1d2024; + --color-layer-hover-01: #1d2024; + --color-layer-hover-02: #23262b; + --color-layer-accent-01: #23262b; + --color-layer-accent-02: #282c33; + --color-field-01: #0d0e10; + --color-field-02: #121316; + --color-field-hover-01: #121316; + --color-field-hover-02: #1d2024; + + --color-stroke-subtle: #45484e; + --color-stroke-strong: #73757c; --color-stroke-inverse: #ffffff; --color-stroke-interactive: #33b1ff; - --color-text-primary: #f2f4f8; - --color-text-secondary: #dde1e6; - --color-text-placeholder: #525252; - --color-text-disabled: #525252; + --color-text-primary: #e3e5ed; + --color-text-secondary: #a9abb2; + --color-text-placeholder: #94979e; + --color-text-disabled: #45484e; --color-link-primary: #33b1ff; - --color-link-hover: #82cfff; - --color-icon-primary: #f2f4f8; - --color-icon-secondary: #dde1e6; - --color-icon-disabled: #525252; - --color-support-error: #ee5396; + --color-link-hover: #303f9f; + --color-icon-primary: #e3e5ed; + --color-icon-secondary: #a9abb2; + --color-icon-disabled: #45484e; + --color-support-error: #ec7c8a; --color-support-success: #42be65; --color-support-warning: #ff6f00; --color-support-info: #33b1ff; --color-accent-blue: #33b1ff; - --color-accent-cyan: #3ddbd9; + --color-accent-cyan: #33b1ff; + --color-primary-dim: #303f9f; + --color-primary-container: #303f9f; --color-accent-green: #42be65; --color-accent-magenta: #ff7eb6; --color-accent-orange: #ff6f00; @@ -60,9 +70,9 @@ --spacing-sidebar: 280px; --spacing-sidebar-collapsed: 48px; - --spacing-header: 48px; + --spacing-header: 56px; --spacing-tab: 40px; - --spacing-preview-content-max-width: 46rem; + --spacing-preview-content-max-width: 42rem; --spacing-preview-block-gap: 1em; --spacing-preview-heading-top: 1.5em; --spacing-preview-heading-bottom: 0.5em; @@ -74,12 +84,12 @@ --spacing-preview-code-block: 1em; --spacing-preview-table-cell: 0.5em; --spacing-preview-checkbox-gap: 0.5em; - --font-size-preview-body: 1rem; - --font-size-preview-h1: 2em; - --font-size-preview-h2: 1.5em; - --font-size-preview-h3: 1.25em; + --font-size-preview-body: 1.125rem; + --font-size-preview-h1: 3rem; + --font-size-preview-h2: 1.5rem; + --font-size-preview-h3: 1.25rem; --font-size-preview-inline-code: 0.9em; - --line-height-preview-body: 1.7; + --line-height-preview-body: 1.625; --line-height-preview-heading: 1.25; --border-width-preview-heading: 1px; --border-width-preview-blockquote: 4px; @@ -105,47 +115,43 @@ body, * { scrollbar-width: thin; - scrollbar-color: #525252 #161616; + scrollbar-color: #23262b transparent; } *::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 4px; + height: 4px; } *::-webkit-scrollbar-track { - background: #161616; + background: transparent; } *::-webkit-scrollbar-thumb { - background: #525252; - border-radius: 9999px; -} - -*::-webkit-scrollbar-thumb:hover { - background: #dde1e6; + background: #23262b; + border-radius: 10px; } /* Focus visible */ :focus-visible { - outline: 2px solid #33b1ff; + outline: 2px solid rgba(63, 81, 181, 0.3); outline-offset: 2px; } ::selection { - background: rgba(51, 177, 255, 0.3); - color: #f2f4f8; + background: rgba(48, 63, 159, 0.2); + color: #e3e5ed; } @keyframes sidebar-drop-pulse { 0% { - box-shadow: 0 0 0 0 rgba(51, 177, 255, 0.45); + box-shadow: 0 0 0 0 rgba(63, 81, 181, 0.45); } 65% { - box-shadow: 0 0 0 7px rgba(51, 177, 255, 0); + box-shadow: 0 0 0 7px rgba(63, 81, 181, 0); } 100% { - box-shadow: 0 0 0 1px rgba(51, 177, 255, 0.42); + box-shadow: 0 0 0 1px rgba(63, 81, 181, 0.42); } } @@ -163,10 +169,10 @@ body, @keyframes sidebar-spring-folder-pulse { 0% { - box-shadow: 0 0 0 0 rgba(51, 177, 255, 0.22); + box-shadow: 0 0 0 0 rgba(63, 81, 181, 0.22); } 100% { - box-shadow: 0 0 0 6px rgba(51, 177, 255, 0); + box-shadow: 0 0 0 6px rgba(63, 81, 181, 0); } } @@ -310,6 +316,13 @@ body, color: #272d35; } +/* Editor caret glow */ +.editor-caret, +.editor-container .cm-cursor { + border-left-color: #33b1ff !important; + box-shadow: 0 0 8px rgba(63, 81, 181, 0.6); +} + /* Editor layout */ .editor-container .cm-editor, .editor-container .cm-scroller { @@ -342,11 +355,13 @@ body, } .preview-content--github { + font-family: var(--font-sans); line-height: var(--line-height-preview-body); font-size: var(--font-size-preview-body); } .preview-content--github :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-headline); color: var(--color-text-primary); margin-top: var(--spacing-preview-heading-top); margin-bottom: var(--spacing-preview-heading-bottom); @@ -355,15 +370,18 @@ body, } .preview-content--github h1 { + font-family: var(--font-headline); font-size: var(--font-size-preview-h1); - border-bottom: var(--border-width-preview-heading) solid var(--color-stroke-subtle); - padding-bottom: 0.3em; + font-weight: 900; + letter-spacing: -0.05em; + line-height: 1.1; } .preview-content--github h2 { font-size: var(--font-size-preview-h2); - border-bottom: var(--border-width-preview-heading) solid var(--color-stroke-subtle); - padding-bottom: 0.3em; + font-weight: 600; + border-bottom: 1px solid rgba(69, 72, 78, 0.1); + padding-bottom: 0.5rem; } .preview-content--github h3 { @@ -405,9 +423,11 @@ body, } .preview-content--github blockquote { - border-left: var(--border-width-preview-blockquote) solid var(--color-stroke-subtle); + border-left: 4px solid rgba(63, 81, 181, 0.2); + border-radius: var(--radius-xl); + background: rgba(40, 44, 51, 0.2); margin: 0 0 var(--spacing-preview-block-gap) 0; - padding: 0 1em; + padding: 0.75em 1em; color: var(--color-text-secondary); } diff --git a/src/__tests__/AppHeaderBar.test.tsx b/src/__tests__/AppHeaderBar.test.tsx index 7f4dae5..8b6a31c 100644 --- a/src/__tests__/AppHeaderBar.test.tsx +++ b/src/__tests__/AppHeaderBar.test.tsx @@ -1,19 +1,21 @@ import { AppHeaderBar } from "$components/AppLayout/AppHeaderBar"; -import { useRoutedSheet } from "$hooks/useRoutedSheet"; -import { useViewportTier } from "$hooks/useViewportTier"; -import { useAppHeaderBarState, useHelpSheetState } from "$state/selectors"; +import { useAppHeaderBarState, useHelpSheetState, useLayoutSettingsUiState } from "$state/selectors"; import { formatShortcut } from "$utils/shortcuts"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("$hooks/useViewportTier", () => ({ useViewportTier: vi.fn() })); -vi.mock("$hooks/useRoutedSheet", () => ({ useRoutedSheet: vi.fn() })); -vi.mock("$state/selectors", () => ({ useAppHeaderBarState: vi.fn(), useHelpSheetState: vi.fn() })); +vi.mock( + "$state/selectors", + () => ({ + useAppHeaderBarState: vi.fn(), + useHelpSheetState: vi.fn(), + useLayoutSettingsUiState: vi.fn(), + }), +); describe("AppHeaderBar", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useAppHeaderBarState).mockReturnValue({ sidebarCollapsed: false, tabBarCollapsed: false, @@ -24,15 +26,7 @@ describe("AppHeaderBar", () => { setShowSearch: vi.fn(), }); vi.mocked(useHelpSheetState).mockReturnValue({ isOpen: false, setOpen: vi.fn(), toggle: vi.fn() }); - vi.mocked(useRoutedSheet).mockReturnValue({ isOpen: false, open: vi.fn(), close: vi.fn() }); - vi.mocked(useViewportTier).mockReturnValue({ - viewportWidth: 1280, - tier: "standard", - isCompact: false, - isNarrow: false, - isStandardUp: true, - isWide: false, - }); + vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); }); it("opens the help sheet from the header action", () => { @@ -45,13 +39,29 @@ describe("AppHeaderBar", () => { expect(setHelpSheetOpen).toHaveBeenCalledWith(true); }); - it("toggles style diagnostics from the header action", () => { - const openStyleDiagnostics = vi.fn(); - vi.mocked(useRoutedSheet).mockReturnValue({ isOpen: false, open: openStyleDiagnostics, close: vi.fn() }); + it("opens the search palette from the header search trigger", () => { + const setShowSearch = vi.fn(); + vi.mocked(useAppHeaderBarState).mockReturnValue({ + sidebarCollapsed: false, + tabBarCollapsed: false, + statusBarCollapsed: false, + toggleSidebarCollapsed: vi.fn(), + toggleTabBarCollapsed: vi.fn(), + toggleStatusBarCollapsed: vi.fn(), + setShowSearch, + }); + + render(); + fireEvent.click(screen.getByTitle(`Search (${formatShortcut("Cmd+Shift+F")})`)); + + expect(setShowSearch).toHaveBeenCalledWith(true); + }); + it("does not render text menus in the header", () => { render(); - fireEvent.click(screen.getByTitle("Show style diagnostics")); - expect(openStyleDiagnostics).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "File" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Format" })).not.toBeInTheDocument(); }); }); diff --git a/src/__tests__/Toolbar.test.tsx b/src/__tests__/Toolbar.test.tsx index f4114c6..e2d675d 100644 --- a/src/__tests__/Toolbar.test.tsx +++ b/src/__tests__/Toolbar.test.tsx @@ -1,10 +1,13 @@ import { Toolbar } from "$components/Toolbar"; import { useViewportTier } from "$hooks/useViewportTier"; -import { useLayoutSettingsUiState, useToolbarState } from "$state/selectors"; +import { useLayoutChromeActions, useLayoutSettingsUiState, useToolbarState } from "$state/selectors"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("$state/selectors", () => ({ useToolbarState: vi.fn(), useLayoutSettingsUiState: vi.fn() })); +vi.mock( + "$state/selectors", + () => ({ useToolbarState: vi.fn(), useLayoutSettingsUiState: vi.fn(), useLayoutChromeActions: vi.fn() }), +); vi.mock("$hooks/useViewportTier", () => ({ useViewportTier: vi.fn() })); describe("Toolbar", () => { @@ -19,6 +22,18 @@ describe("Toolbar", () => { toggleFocusMode: vi.fn(), togglePreviewVisible: vi.fn(), }); + vi.mocked(useLayoutChromeActions).mockReturnValue({ + setSidebarCollapsed: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + setTopBarsCollapsed: vi.fn(), + toggleTabBarCollapsed: vi.fn(), + setStatusBarCollapsed: vi.fn(), + toggleStatusBarCollapsed: vi.fn(), + setShowSearch: vi.fn(), + toggleShowSearch: vi.fn(), + setFilenameVisibility: vi.fn(), + toggleFilenameVisibility: vi.fn(), + }); vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); vi.mocked(useViewportTier).mockReturnValue({ viewportWidth: 1280, @@ -61,6 +76,27 @@ describe("Toolbar", () => { expect(setEditorOnlyMode).toHaveBeenCalledOnce(); }); + it("toggles the sidebar from the toolbar next to save", () => { + const toggleSidebarCollapsed = vi.fn(); + vi.mocked(useLayoutChromeActions).mockReturnValue({ + setSidebarCollapsed: vi.fn(), + toggleSidebarCollapsed, + setTopBarsCollapsed: vi.fn(), + toggleTabBarCollapsed: vi.fn(), + setStatusBarCollapsed: vi.fn(), + toggleStatusBarCollapsed: vi.fn(), + setShowSearch: vi.fn(), + toggleShowSearch: vi.fn(), + setFilenameVisibility: vi.fn(), + toggleFilenameVisibility: vi.fn(), + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle Sidebar" })); + expect(toggleSidebarCollapsed).toHaveBeenCalledOnce(); + }); + it("opens the AT Protocol auth entry from the toolbar", () => { const onAtProtoAuth = vi.fn(); @@ -82,4 +118,12 @@ describe("Toolbar", () => { fireEvent.click(trigger); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); }); + + it("does not render non-functional formatting buttons", () => { + render(); + + expect(screen.queryByRole("button", { name: "Bold" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Italic" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Link" })).not.toBeInTheDocument(); + }); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 2231a81..1fb3b2d 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -7,7 +7,9 @@ import { StatusBarProps } from "$components/StatusBar"; import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { + useEditorPresentationActions, useEditorPresentationState, + useLayoutChromeActions, useLayoutSettingsUiState, useSidebarState, useToolbarState, @@ -25,6 +27,7 @@ import type { WorkspacePanelModeStateReturn, WorkspacePanelSidebarStateReturn, } from "$state/selectors"; +import type { MarkdownPreviewStyle } from "$types"; import { formatShortcut } from "$utils/shortcuts"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -34,6 +37,8 @@ vi.mock( () => ({ useSidebarState: vi.fn(), useToolbarState: vi.fn(), + useEditorPresentationActions: vi.fn(), + useLayoutChromeActions: vi.fn(), useLayoutSettingsUiState: vi.fn(), useEditorPresentationState: vi.fn(), useWorkspacePanelSidebarState: vi.fn(), @@ -49,6 +54,7 @@ type SelectorOverrides = { sidebarState?: Partial; toolbarState?: Partial; editorPresentationState?: Partial; + setMarkdownPreviewStyle?: (value: MarkdownPreviewStyle) => void; workspacePanelSidebarState?: Partial; workspacePanelModeState?: Partial; topBarsCollapsed?: TopBarsCollapsedReturn; @@ -133,6 +139,29 @@ const createWorkspacePanelModeState = ( const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { vi.mocked(useSidebarState).mockReturnValue(createSidebarState(overrides.sidebarState)); vi.mocked(useToolbarState).mockReturnValue(createToolbarState(overrides.toolbarState)); + vi.mocked(useEditorPresentationActions).mockReturnValue({ + setLineNumbersVisible: vi.fn(), + toggleLineNumbersVisible: vi.fn(), + setTextWrappingEnabled: vi.fn(), + toggleTextWrappingEnabled: vi.fn(), + setSyntaxHighlightingEnabled: vi.fn(), + toggleSyntaxHighlightingEnabled: vi.fn(), + setEditorFontSize: vi.fn(), + setEditorFontFamily: vi.fn(), + setMarkdownPreviewStyle: overrides.setMarkdownPreviewStyle ?? vi.fn(), + }); + vi.mocked(useLayoutChromeActions).mockReturnValue({ + setSidebarCollapsed: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + setTopBarsCollapsed: vi.fn(), + toggleTabBarCollapsed: vi.fn(), + setStatusBarCollapsed: vi.fn(), + toggleStatusBarCollapsed: vi.fn(), + setShowSearch: vi.fn(), + toggleShowSearch: vi.fn(), + setFilenameVisibility: vi.fn(), + toggleFilenameVisibility: vi.fn(), + }); vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); vi.mocked(useEditorPresentationState).mockReturnValue( createEditorPresentationState(overrides.editorPresentationState), @@ -249,6 +278,21 @@ describe("WorkspacePanel", () => { expect(container.querySelector("[data-testid='editor-container']")).not.toBeInTheDocument(); }); + it("switches preview chrome between Reading and Web modes", () => { + const setMarkdownPreviewStyle = vi.fn(); + renderWorkspacePanel({ editor: { initialText: "# Visible" } }, { + setMarkdownPreviewStyle, + toolbarState: { isPreviewVisible: true }, + workspacePanelModeState: { isPreviewVisible: true }, + }); + + fireEvent.click(screen.getByRole("button", { name: /web/i })); + fireEvent.click(screen.getByRole("button", { name: /reading/i })); + + expect(setMarkdownPreviewStyle).toHaveBeenNthCalledWith(1, "pdf"); + expect(setMarkdownPreviewStyle).toHaveBeenNthCalledWith(2, "github"); + }); + it("renders sidebar controls and supports resizing", () => { const onToggleSidebar = vi.fn(); @@ -263,9 +307,9 @@ describe("WorkspacePanel", () => { const separator = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebarContainer = separator.parentElement as HTMLElement; const appWindow = globalThis as unknown as Window; - expect(sidebarContainer).toHaveStyle({ width: "280px" }); + expect(sidebarContainer).toHaveStyle({ width: "256px" }); - fireEvent.pointerDown(separator, { clientX: 280 }); + fireEvent.pointerDown(separator, { clientX: 256 }); fireEvent.pointerMove(appWindow, { clientX: 360 }); fireEvent.pointerUp(appWindow); diff --git a/src/components/AppLayout/AppHeaderBar.tsx b/src/components/AppLayout/AppHeaderBar.tsx index 7095754..6be8924 100644 --- a/src/components/AppLayout/AppHeaderBar.tsx +++ b/src/components/AppLayout/AppHeaderBar.tsx @@ -2,177 +2,65 @@ import { Button } from "$components/Button"; import { Dialog } from "$components/Dialog"; import { Support } from "$components/Support"; import { Version } from "$components/Version"; -import { useRoutedSheet } from "$hooks/useRoutedSheet"; -import { useViewportTier } from "$hooks/useViewportTier"; -import { CheckIcon, ChevronDownIcon, HeartIcon, PenIcon, QuestionIcon, SearchIcon, XIcon } from "$icons"; +import { HeartIcon, PenIcon, QuestionIcon, SearchIcon, SettingsIcon, XIcon } from "$icons"; import { appVersionGet, runCmd } from "$ports"; -import { useAppHeaderBarState, useHelpSheetState } from "$state/selectors"; +import { useAppHeaderBarState, useHelpSheetState, useLayoutSettingsUiState } from "$state/selectors"; import { formatShortcut } from "$utils/shortcuts"; -import { cn } from "$utils/tw"; import { useCallback, useEffect, useMemo, useState } from "react"; -const AppTitle = ({ hideTitle, version }: { hideTitle: boolean; version: string }) => ( -
-
- -
- {hideTitle ? null : ( -
-

Writer

- -
- )} +const AppTitle = ({ version }: { version: string }) => ( +
+ +

Writer

+
); -type SearchRowProps = { - onOpenSearch: () => void; - onOpenHelp: () => void; - onOpenSupport: () => void; - onToggleSidebar: () => void; - onToggleTabBar: () => void; - onToggleStatusBar: () => void; - onToggleStyleDiagnostics: () => void; - sidebarCollapsed: boolean; - tabBarCollapsed: boolean; - statusBarCollapsed: boolean; - styleDiagnosticsOpen: boolean; - iconOnly: boolean; - showSearchShortcut: boolean; - showHelpShortcut: boolean; - compactTabLabel: boolean; -}; - -function SearchRow( - { - onOpenSearch, - onOpenHelp, - onOpenSupport, - onToggleSidebar, - onToggleTabBar, - onToggleStatusBar, - onToggleStyleDiagnostics, - sidebarCollapsed, - tabBarCollapsed, - statusBarCollapsed, - styleDiagnosticsOpen, - iconOnly, - showSearchShortcut, - showHelpShortcut, - compactTabLabel, - }: SearchRowProps, -) { +function SearchTrigger({ onOpenSearch }: { onOpenSearch: () => void }) { const searchShortcut = useMemo(() => formatShortcut("Cmd+Shift+F"), []); - const helpShortcut = useMemo(() => formatShortcut("Cmd+/"), []); - const toggleTabBarShortcut = useMemo(() => formatShortcut("Cmd+Shift+B"), []); - const toggleSidebarShortcut = useMemo(() => formatShortcut("Cmd+B"), []); - const statusbarId = useMemo(() => { - if (compactTabLabel) { - return { - label: statusBarCollapsed ? "Show Status" : "Hide Status", - title: statusBarCollapsed ? "Show Status" : "Hide Status", - }; - } - return { - label: statusBarCollapsed ? "Show Status Bar" : "Hide Status Bar", - title: statusBarCollapsed ? "Show Status Bar" : "Hide Status Bar", - }; - }, [compactTabLabel, statusBarCollapsed]); + return ( + + ); +} - const tabbarId = useMemo(() => { - const title = `${tabBarCollapsed ? "Show" : "Hide"} tab bar (${toggleTabBarShortcut})`; - if (compactTabLabel) { - return { label: tabBarCollapsed ? "Show Tabs" : "Hide Tabs", title }; - } - return { label: tabBarCollapsed ? "Show Tab Bar" : "Hide Tab Bar", title }; - }, [compactTabLabel, tabBarCollapsed, toggleTabBarShortcut]); +type HeaderActionsProps = { onOpenHelp: () => void; onOpenSupport: () => void; onOpenSettings: () => void }; - const sidebarId = useMemo(() => { - const title = `${sidebarCollapsed ? "Show" : "Hide"} sidebar (${toggleSidebarShortcut})`; - const label = sidebarCollapsed ? "Show Sidebar" : "Hide Sidebar"; - return { label, title }; - }, [sidebarCollapsed, toggleSidebarShortcut]); +function HeaderActions({ onOpenHelp, onOpenSupport, onOpenSettings }: HeaderActionsProps) { + const helpShortcut = useMemo(() => formatShortcut("Cmd+/"), []); return ( -
- +
- - - - - - - - -
); @@ -185,11 +73,11 @@ function SupportModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => voi onClose={onClose} ariaLabel="Support Writer" containerClassName="flex items-center justify-center" - panelClassName="w-full max-w-md bg-layer-01 rounded-xl shadow-xl border border-stroke-subtle overflow-hidden" + panelClassName="w-full max-w-md bg-layer-01 rounded-xl shadow-xl border border-stroke-subtle/10 overflow-hidden" motionPreset="scale"> -
+
- + Support Writer
+ ); +} + +function PreviewHeader( + { previewStyle, onSelectMode }: { previewStyle: WorkspacePreviewProps["previewStyle"]; onSelectMode: (mode: PreviewMode) => void }, +) { + const activeMode = useMemo(() => previewStyleToMode(previewStyle), [previewStyle]); + + return ( +
+
+ + Live Preview +
+
+ + +
+
+ ); +} + type MainPanelProps = { panelMode: PanelMode; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; + onSelectPreviewMode: (mode: PreviewMode) => void; splitEditorWidth: number; isSplitResizing: boolean; onSplitResizeStart: PointerEventHandler; @@ -94,11 +144,14 @@ function getPanelMode(isSplitView: boolean, isPreviewVisible: boolean): PanelMod } function MainPanel( - { panelMode, editor, preview, splitEditorWidth, isSplitResizing, onSplitResizeStart }: MainPanelProps, + { panelMode, editor, preview, onSelectPreviewMode, splitEditorWidth, isSplitResizing, onSplitResizeStart }: MainPanelProps, ) { const container = useMemo(() => { if (panelMode === "split") { - return { className: "flex min-h-0 min-w-0 shrink-0 flex-col", style: { width: `${splitEditorWidth}px` } }; + return { + className: "flex min-h-0 min-w-0 shrink-0 flex-col border-r border-stroke-subtle/10", + style: { width: `${splitEditorWidth}px` }, + }; } return { className: "flex min-h-0 min-w-0 flex-col w-full" }; }, [panelMode, splitEditorWidth]); @@ -113,10 +166,12 @@ function MainPanel( aria-orientation="vertical" onPointerDown={onSplitResizeStart} className={`relative z-10 w-1 shrink-0 cursor-col-resize transition-colors ${ - isSplitResizing ? "bg-stroke-interactive" : "bg-stroke-subtle hover:bg-stroke-strong" + isSplitResizing ? "bg-stroke-interactive" : "bg-stroke-subtle/10 hover:bg-stroke-strong" }`} /> - - +
+ + +
); } @@ -124,7 +179,12 @@ function MainPanel( return (
{panelMode === "editor" && } - {panelMode === "preview" && } + {panelMode === "preview" && ( +
+ + +
+ )}
); } @@ -164,6 +224,7 @@ export function WorkspacePanel( ) { const skipAnimation = useSkipAnimation(); const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH); + const { setMarkdownPreviewStyle } = useEditorPresentationActions(); const { sidebarCollapsed } = useWorkspacePanelSidebarState(); const { isSplitView, isPreviewVisible } = useWorkspacePanelModeState(); const topBarsCollapsed = useWorkspacePanelTopBarsCollapsed(); @@ -176,7 +237,7 @@ export function WorkspacePanel( const effectiveStatusBarVisible = !statusBarCollapsed; const { size: sidebarWidth, isResizing, startResizing, setSize: setSidebarWidth } = useResizable({ - initialSize: 280, + initialSize: 256, minSize: SIDEBAR_MIN_WIDTH, maxSize: sidebarMaxWidth, axis: "x", @@ -230,6 +291,10 @@ export function WorkspacePanel( startSplitResizing(event.clientX); }, [startSplitResizing]); + const handleSelectPreviewMode = useCallback((mode: PreviewMode) => { + setMarkdownPreviewStyle(previewModeToStyle(mode)); + }, [setMarkdownPreviewStyle]); + const sidebarStyle = useMemo(() => ({ width: `${sidebarWidth}px` }), [sidebarWidth]); const sectionTransition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : CHROME_SECTION.TRANSITION, [ @@ -301,6 +366,7 @@ export function WorkspacePanel( panelMode={effectivePanelMode} editor={editor} preview={preview} + onSelectPreviewMode={handleSelectPreviewMode} splitEditorWidth={splitEditorWidth} isSplitResizing={isSplitResizing} onSplitResizeStart={handleSplitResizeStart} /> diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4f40b90..02ab296 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -19,23 +19,23 @@ export type ButtonSize = "none" | "xs" | "sm" | "md" | "lg" | "iconXs" | "iconSm const BUTTON_VARIANT_CLASSES: Record = { unstyled: "", iconGhost: - "inline-flex items-center justify-center rounded bg-transparent border-none text-icon-secondary cursor-pointer", + "inline-flex items-center justify-center rounded-sm bg-transparent border-none text-[#94979e] hover:bg-surface-bright/40 cursor-pointer transition-colors duration-200", iconSubtle: - "inline-flex items-center justify-center rounded border border-stroke-subtle bg-transparent text-icon-secondary hover:text-icon-primary cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-stroke-subtle bg-transparent text-icon-secondary hover:text-icon-primary cursor-pointer transition-colors duration-200", outline: - "inline-flex items-center justify-center rounded border border-stroke-subtle bg-transparent text-text-secondary hover:text-text-primary cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-accent-blue/20 bg-transparent text-accent-blue hover:bg-layer-hover-01 cursor-pointer transition-colors duration-200", surface: - "inline-flex items-center justify-center rounded border border-stroke-subtle bg-layer-01 text-text-secondary hover:bg-surface-active hover:text-text-primary cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-stroke-subtle/20 bg-layer-accent-01 text-text-secondary hover:bg-layer-accent-02 hover:text-text-primary cursor-pointer transition-colors duration-200", secondary: - "inline-flex items-center justify-center rounded border border-stroke-subtle bg-layer-02 text-text-primary hover:bg-layer-03 cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-stroke-subtle bg-layer-02 text-text-primary hover:bg-layer-03 cursor-pointer transition-colors duration-200", primary: - "inline-flex items-center justify-center rounded border border-accent-cyan bg-accent-cyan text-white hover:opacity-90 cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-accent-blue bg-accent-blue text-white hover:bg-primary-dim active:scale-95 cursor-pointer transition-all duration-200", primaryBlue: - "inline-flex items-center justify-center rounded border border-accent-blue bg-accent-blue text-white hover:bg-link-hover cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center rounded border border-accent-blue bg-accent-blue text-white hover:bg-link-hover cursor-pointer transition-colors duration-200", link: - "inline-flex items-center justify-center bg-transparent border-none text-link-primary underline underline-offset-2 cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center bg-transparent border-none text-link-primary underline underline-offset-2 cursor-pointer transition-colors duration-200", dangerGhost: - "inline-flex items-center justify-center bg-transparent border-none text-support-error cursor-pointer transition-colors duration-300", + "inline-flex items-center justify-center bg-transparent border-none text-support-error cursor-pointer transition-colors duration-200", }; const BUTTON_SIZE_CLASSES: Record = { diff --git a/src/components/Preview.tsx b/src/components/Preview.tsx index 06f19ac..8e9f9f7 100644 --- a/src/components/Preview.tsx +++ b/src/components/Preview.tsx @@ -140,7 +140,7 @@ export function Preview( ref={containerRef} onScroll={handleScroll} data-theme={theme} - className={`flex-1 overflow-auto p-6 bg-surface-primary text-text-primary ${className}`}> + className={`flex-1 overflow-auto p-16 bg-surface-lowest text-text-primary ${className}`}>
); diff --git a/src/components/SearchPanel/Buttons.tsx b/src/components/SearchPanel/Buttons.tsx index 3bdc2f0..0085b5c 100644 --- a/src/components/SearchPanel/Buttons.tsx +++ b/src/components/SearchPanel/Buttons.tsx @@ -17,18 +17,18 @@ type FilterLocationProps = { handleToggleLocation: (locationId: number) => void; }; -export function ToggleButton({ toggleFilters, showFilters, activeFilterCount, compact = false }: ToggleButtonProps) { +export function ToggleButton({ toggleFilters, showFilters, activeFilterCount }: ToggleButtonProps) { const classes = useMemo(() => { const base = [ - compact ? "px-3 py-2" : "px-4 py-2.5", - "border border-stroke-subtle rounded-md", + "px-3 py-2", + "border border-stroke-subtle/10 rounded-lg", "text-sm cursor-pointer flex items-center gap-1.5 transition-colors duration-150", ]; if (showFilters) { - base.push("bg-layer-accent-01"); + base.push("bg-surface-bright/40"); } else { - base.push("bg-layer-01"); + base.push("bg-surface-bright/20 hover:bg-surface-bright/40"); } if (activeFilterCount > 0) { @@ -38,7 +38,7 @@ export function ToggleButton({ toggleFilters, showFilters, activeFilterCount, co } return base.join(" "); - }, [showFilters, activeFilterCount, compact]); + }, [showFilters, activeFilterCount]); return ( ); @@ -78,14 +76,14 @@ export function FilterLocationButton({ location, filters, handleToggleLocation } const classes = useMemo(() => { const base = [ "px-3 py-1.5", - "border border-stroke-subtle rounded", + "border border-stroke-subtle/10 rounded-lg", "text-[0.8125rem] cursor-pointer transition-colors duration-150", ]; if (filters.locations?.includes(location.id)) { base.push("bg-accent-blue text-white"); } else { - base.push("bg-layer-02 text-text-primary"); + base.push("bg-surface-bright/20 hover:bg-surface-bright/40 text-text-primary"); } return base.join(" "); diff --git a/src/components/SearchPanel/SearchPanel.tsx b/src/components/SearchPanel/SearchPanel.tsx index b92d72d..38fccdb 100644 --- a/src/components/SearchPanel/SearchPanel.tsx +++ b/src/components/SearchPanel/SearchPanel.tsx @@ -6,6 +6,7 @@ import { useViewportTier } from "$hooks/useViewportTier"; import { FileTextIcon, SearchIcon, XIcon } from "$icons"; import type { SearchFilters } from "$state/types"; import type { SearchHit } from "$types"; +import { formatShortcut } from "$utils/shortcuts"; import { cn } from "$utils/tw"; import { AnimatePresence, motion } from "motion/react"; import type { ChangeEventHandler, MouseEventHandler } from "react"; @@ -65,7 +66,6 @@ type SearchPanelProps = { query: string; results: SearchHit[]; isSearching: boolean; - topOffset: number; locations: Array<{ id: number; name: string }>; filters: SearchFilters; onQueryChange: (query: string) => void; @@ -122,7 +122,7 @@ function SearchResult({ hit, onSelectResult }: SearchResultProps) { return ( @@ -181,6 +181,8 @@ function Results({ isSearching, results, query, onSelectResult }: ResultsProps) } function SearchInput({ query, handleQueryChange, clearQuery, compact = false }: SearchInputProps) { + const searchShortcut = useMemo(() => formatShortcut("Cmd+Shift+F"), []); + return (
- {query && ( - - )} + className={cn( + "w-full pl-10 text-base rounded-lg text-text-primary outline-none border border-stroke-subtle/10", + "bg-surface-bright/40 transition-[border-color,box-shadow] duration-150", + "focus:border-accent-blue/30 focus:ring-1 focus:ring-accent-blue/30", + query ? "pr-9" : "pr-16", + compact ? "py-2" : "py-2.5", + )} /> + {query + ? ( + + ) + : ( + + {searchShortcut} + + )}
); } @@ -225,7 +237,7 @@ function VisibleFilters( {showFilters && ( + className="p-4 bg-surface-bright/20 rounded-lg border border-stroke-subtle/10 flex flex-col gap-4"> {activeFilterCount > 0 && } @@ -254,7 +266,7 @@ function SearchResultsHeader( }: SearchResultsHeaderProps, ) { return ( -
+
Math.max(0, topOffset), [topOffset]); - const containerStyle = useMemo(() => ({ top: panelTopOffset, left: 0, right: 0, bottom: 0 }), [panelTopOffset]); + const compact = isCompact || viewportWidth < 640; + const panelClassName = useMemo( () => cn( - "flex h-full flex-col overflow-hidden bg-surface-primary border border-stroke-subtle", - isCompact ? "w-full rounded-none" : "mx-auto w-full max-w-5xl rounded-lg shadow-2xl", + "flex flex-col overflow-hidden border border-stroke-subtle/10 shadow-2xl", + compact ? "w-full rounded-t-xl max-h-[80vh]" : "w-full max-w-lg rounded-xl max-h-[70vh]", ), - [isCompact], + [compact], ); + const panelBodyClassName = useMemo( - () => (isCompact ? "flex-1 overflow-y-auto px-3 py-3" : "flex-1 overflow-y-auto px-6 py-4"), - [isCompact], + () => cn("overflow-y-auto px-3 py-3", compact ? "flex-1" : "max-h-[50vh]"), + [compact], ); - const backdropClassName = useMemo(() => (isCompact ? "bg-black/35" : "bg-black/20"), [isCompact]); + const containerClassName = useMemo( () => cn( - "z-[var(--z-modal)] flex", - isCompact ? "items-stretch justify-stretch" : "items-end justify-center px-3 pb-3", - "pointer-events-none", + "z-[var(--z-modal)] flex pointer-events-none", + compact ? "items-end justify-stretch" : "items-center justify-center px-4 py-8", ), - [isCompact], + [compact], ); return ( @@ -370,10 +383,10 @@ export function SearchPanel( showBackdrop closeOnBackdrop motionPreset="slideUp" - backdropClassName={backdropClassName} + backdropClassName="bg-black/45" containerClassName={containerClassName} - containerStyle={containerStyle} - panelClassName={panelClassName}> + panelClassName={panelClassName} + panelStyle={GLASS_STYLE}>
diff --git a/src/components/Sheet/Sheet.tsx b/src/components/Sheet/Sheet.tsx index 5c80986..d816cce 100644 --- a/src/components/Sheet/Sheet.tsx +++ b/src/components/Sheet/Sheet.tsx @@ -317,7 +317,7 @@ export function Sheet( aria-modal={showBackdrop} tabIndex={-1} className={cn( - "pointer-events-auto bg-layer-01 border-stroke-subtle shadow-2xl min-h-0 overflow-hidden flex flex-col", + "pointer-events-auto bg-layer-01 border-stroke-subtle/10 shadow-2xl min-h-0 overflow-hidden flex flex-col", positionClassName, sizeClassName, className, diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 59527dd..4476316 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,7 +1,6 @@ import { Button } from "$components/Button"; import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; import { - CollapseIcon, FileAddIcon, FileTextIcon, FolderAddIcon, @@ -13,7 +12,6 @@ import { } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import { formatShortcut } from "$utils/shortcuts"; import { useCallback, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; import { @@ -41,35 +39,19 @@ type SidebarActionsProps = { onAddLocation: () => void; onAddDocument: () => void; onRefresh: () => void; - onToggleCollapse: () => void; addDocumentDisabled: boolean; refreshDisabled: boolean; }; type CountPillProps = { count: number; kind: "location" | "document" | "directory" }; -const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void }) => ( - -); - const SidebarActions = ( - { onAddLocation, onAddDocument, onRefresh, onToggleCollapse, addDocumentDisabled, refreshDisabled }: - SidebarActionsProps, + { onAddLocation, onAddDocument, onRefresh, addDocumentDisabled, refreshDisabled }: SidebarActionsProps, ) => (
-
); @@ -137,7 +119,6 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm setActiveDropTarget, reorderFolderSortOrder, selectLocation, - toggleSidebarCollapsed, filenameVisibility, } = useSidebarState(); @@ -299,16 +280,24 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm ]); return ( -