From 595df7484f738188b8cb068f637b26f6e42a64c8 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:10:08 -0500 Subject: [PATCH 01/50] =?UTF-8?q?fix(ui):=20review-surface=20polish=20?= =?UTF-8?q?=E2=80=94=20chrome,=20review-exit=20guard,=20theming,=20code=20?= =?UTF-8?q?blocks=20(attn-rd3j)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine reported defects in the native app. Three of them shared one root cause and two were misdiagnosed until measured; the notes below record what was actually wrong, since the fixes only make sense against it. Document typography was global, not scoped (attn-rd3j.3/.4/.5). Bare p/h1/ul/li selectors in base.css typed the whole page, so app chrome inherited reading-surface margins and the document's custom checkbox rule drew a checkmark pinned at left:0 inside the Share dialog — the "weird modal checkmark" was never an element in ShareDialog. Typography is now scoped to .attn-doc (carried by the editor mount and the viewer article) and the .attn-chrome opt-out class, which existed purely to undo the leak, is gone. Bullets were a faked absolutely-positioned ::before dot with no list-style fallback, so they vanished whenever the positioning context shifted; they are real ::marker glyphs now, which cannot detach from their line. The table "rail" was 13px of stolen layout (attn-rd3j.8). base.css styles ::-webkit-scrollbar globally, and in WKWebView styling it at all downgrades that element from macOS overlay scrollbars to classic space-reserving ones. Measured alternatives: scrollbar-width:auto = 17px, webkit revert = 17px, hidden = 0px — nothing restores overlay behavior. Prose blocks now hide the bar and scroll by trackpad, the treatment PathBreadcrumb already used. The rules must sit outside @layer components, because base.css's scrollbar rules are deliberately unlayered and unlayered beats layered. Review-exit membership was answered from the wrong set (attn-rd3j.2). ownerRoomForPath resolves a file to a room through the share ROOT, which for a multi-file share is the whole project — so every file "belonged" to the review. Added roomPublishesPath, which answers from published snapshots and reconciles relative snapshot paths against absolute nav paths. The same confusion is fixed in the owner auto-follow effect, which was re-selecting the room right after an explicit exit and putting review chrome on files that were never shared; it now does what its docstring already claimed. BEHAVIOR CHANGE: opening an unshared file in a shared project turns collaboration chrome off instead of leaving the chip and rail on. Syntax highlighting was never a Rust concern (attn-rd3j.10). src/markdown.rs renders no HTML and comrak's syntect feature is off by design — client-side shiki is the intended architecture. The gaps were a hardcoded 20-language allowlist that silently dropped everything else, and untagged fences getting zero decorations. Languages now resolve against shiki's full bundle with on-demand loading, and untagged fences get conservative content-based detection (confident-match-or-nothing; JSON verified by parsing it). Also: a zoom_window IPC so double-clicking the hidden titlebar zooms and restores, attached to every existing drag surface (attn-rd3j.1); the code copy button and language label moved onto a non-scrolling frame so they stay pinned over wide blocks (attn-rd3j.9); dialog bodies, the project switcher and other chrome scroll through the shared ScrollArea (attn-rd3j.5); and a Settings dialog with three-state appearance (Paper/Ink/System, durable and stamped before first paint so there is no flash of the wrong theme) plus shadcn-style typeset presets (attn-rd3j.6/.7). Verified by driving the running app, not just the tests: dblclick zoomed 960x720 -> 1512x887 -> back with buttons excluded; the copy button moved 0px while content scrolled 106px; reserved scrollbar space went 13px -> 0 on every overflowing block; the exit prompt fires, cancel preserves, confirm tears down cleanly and re-entering re-activates; system->dark resolved on a dark-mode Mac and a manual Paper choice survived a daemon restart. That pass also caught a defect of its own — the selected Appearance segment used bg-background over bg-muted/30 and was invisible in dark mode. 97 web test files and 1207 Rust tests pass (17 new unit tests). test-e2e and test-review-e2e are byte-identical to the pre-change baseline, confirmed by stashing and re-running — their failures are pre-existing. Release binary 32.09/40 MiB. Co-Authored-By: Claude Fable 5 --- .beads/.gitignore | 4 + .beads/interactions.jsonl | 18 ++ DESIGN.md | 15 +- src/ipc.rs | 23 ++- src/main.rs | 32 +++- src/prefs.rs | 149 ++++++++++++++++ src/projects.rs | 4 +- src/watcher.rs | 2 + web/index.html | 29 ++- web/src/App.svelte | 166 +++++++++++++++++- .../app/HostedDesktopWorkspaceFrame.svelte | 2 +- web/src/hosted/app/app-shell.css | 5 - web/src/lib/CodeBlockScrollArea.svelte | 51 ------ web/src/lib/Editor.svelte | 2 +- web/src/lib/PathBreadcrumb.svelte | 5 +- web/src/lib/ReviewExitConfirm.svelte | 43 +++++ web/src/lib/ReviewFileSidebar.svelte | 2 +- web/src/lib/ReviewFileTree.svelte | 2 +- web/src/lib/ReviewMargin.svelte | 2 +- web/src/lib/ReviewerStatusChip.svelte | 2 +- web/src/lib/SettingsDialog.svelte | 135 ++++++++++++++ web/src/lib/ShareChip.svelte | 2 +- web/src/lib/ShareDialog.svelte | 9 +- web/src/lib/Sidebar.svelte | 9 +- web/src/lib/Viewer.svelte | 2 +- .../ui/dialog/dialog-content.svelte | 12 +- .../ui/scroll-area/scroll-area.svelte | 9 +- web/src/lib/ipc.ts | 12 ++ .../lib/prosemirror/code-block-nodeview.ts | 29 ++- web/src/lib/prosemirror/code-highlight.ts | 108 +++++++++--- .../lib/prosemirror/detect-language.test.ts | 121 +++++++++++++ web/src/lib/prosemirror/detect-language.ts | 124 +++++++++++++ .../lib/review/room-publishes-path.test.ts | 158 +++++++++++++++++ web/src/lib/review/room-ui.ts | 38 ++++ web/src/lib/theme.ts | 121 +++++++++---- web/src/lib/types.ts | 20 ++- web/src/lib/typeset.ts | 70 ++++++++ web/src/main.ts | 2 + web/styles/base.css | 127 ++++++-------- web/styles/prosemirror.css | 105 ++++++++--- web/styles/typeset.css | 63 +++++++ 41 files changed, 1574 insertions(+), 260 deletions(-) create mode 100644 src/prefs.rs delete mode 100644 web/src/lib/CodeBlockScrollArea.svelte create mode 100644 web/src/lib/ReviewExitConfirm.svelte create mode 100644 web/src/lib/SettingsDialog.svelte create mode 100644 web/src/lib/prosemirror/detect-language.test.ts create mode 100644 web/src/lib/prosemirror/detect-language.ts create mode 100644 web/src/lib/review/room-publishes-path.test.ts create mode 100644 web/src/lib/typeset.ts create mode 100644 web/styles/typeset.css diff --git a/.beads/.gitignore b/.beads/.gitignore index 92031504..549fa02e 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,7 +1,11 @@ # Dolt database (managed by Dolt, not git) dolt/ +embeddeddolt/ dolt-access.lock +# Transient auto-import staging file +.auto-import-issues.jsonl + # Runtime files bd.sock sync-state.json diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index afed5e17..f93e4d51 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -491,3 +491,21 @@ {"id":"int-3e09a1fe","kind":"field_change","created_at":"2026-07-23T05:15:28.991323Z","actor":"James Lal","issue_id":"attn-ij9y","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-a0a3dce8","kind":"field_change","created_at":"2026-07-23T14:49:31.783506Z","actor":"James Lal","issue_id":"attn-9ek7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved by the core projection (commit 50dbe9e). The sidebar 'shared' badges + unread counts derive from reviewStore.snapshots + room role, which the projection now populates and stamps role='owner' in EVERY tab (adoptRoom) — same store the leader uses, so follower tabs light up identically (proven by the byte-identical two-tab convergence). The old watchReviewLog seam is fully replaced by openReviewProjection. Residual ownerState-fallback chains (reviewRoomActive etc.) now read correctly because the projection feeds reviewStoreRef.currentRoomId in every tab; simplifying them further is cosmetic, not a bug."}} {"id":"int-9d31b50a","kind":"field_change","created_at":"2026-07-23T16:18:27.305147Z","actor":"James Lal","issue_id":"attn-qs03","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-7c31cef3f23130fd71736c4ffada1cfd","kind":"field_change","created_at":"2026-08-04T15:18:06.354763Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.1","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-5d6ff32c7e7af957c205c1c4d0202644","kind":"field_change","created_at":"2026-08-04T15:18:06.763206Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.3","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-ad5c4373d5775957fdcf66b8176389e0","kind":"field_change","created_at":"2026-08-04T15:18:07.191229Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.4","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-40bbfb828ce72f090c1219912087354e","kind":"field_change","created_at":"2026-08-04T15:18:07.596199Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.5","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-da0f173585ef89f4350fa1d7b78a1782","kind":"field_change","created_at":"2026-08-04T15:18:08.020149Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.8","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-713983276f6f9d6c5c773be9f89f958b","kind":"field_change","created_at":"2026-08-04T15:18:08.411475Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.9","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-d42027c73c1ba2cbe4cff9926a2ea2bd","kind":"field_change","created_at":"2026-08-04T15:18:08.839244Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.10","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} +{"id":"int-b61153fbaaea61b11f28463a91a5502e","kind":"field_change","created_at":"2026-08-04T16:51:13.023907Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.1","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8b80edb3e576e3008cdefb9d7d385920","kind":"field_change","created_at":"2026-08-04T16:51:13.323592Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-6cff787806ec84e467cada6c6f39887e","kind":"field_change","created_at":"2026-08-04T16:51:13.646987Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-1b612cbb9cede96afaf928a4ab74acb6","kind":"field_change","created_at":"2026-08-04T16:51:13.946214Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-a408e511ab0ac9919e67f47febaa63b2","kind":"field_change","created_at":"2026-08-04T16:51:14.257088Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-e9a31d2c1a54bdc022f68bb85e0e25f3","kind":"field_change","created_at":"2026-08-04T16:51:14.795318Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-e2e1fc1e468630fa0803b0bf02145ef2","kind":"field_change","created_at":"2026-08-04T16:51:15.098947Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8c6d28289f661ceabc541baaac238215","kind":"field_change","created_at":"2026-08-04T16:51:15.390843Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-b83f7a1de3e22c97e5c8cf5fa01675f6","kind":"field_change","created_at":"2026-08-04T16:51:15.687939Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-8c2182ea592a223c088e0ea84070a515","kind":"field_change","created_at":"2026-08-04T16:51:15.990012Z","actor":"Angus Bezzina","issue_id":"attn-rd3j.10","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}} +{"id":"int-068dead36c807216511ec15792a25693","kind":"field_change","created_at":"2026-08-04T16:51:28.922784Z","actor":"Angus Bezzina","issue_id":"attn-rd3j","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All 10 child issues implemented and verified live in the app. Full regression pass: 97 web test files green, 1207 Rust tests green, E2E and review-E2E identical to the pre-change baseline (their failures are pre-existing), release binary 32.09/40 MiB."}} diff --git a/DESIGN.md b/DESIGN.md index c62ef9d3..8331565a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -130,6 +130,8 @@ This system explicitly rejects three neighbors. It is **not a cloud-SaaS review A warm parchment field carrying near-black ink and a single terracotta accent; cool review hues (green, amber, blue, violet) are quarantined to the collaboration layer so they never dilute the editorial ground. All values are canonical **OKLCH** — attn is OKLCH-native and the frontmatter carries OKLCH directly. +**Paper / Ink / System.** Appearance is a three-state preference (Settings → Appearance), defaulting to **System** — the app follows the OS appearance and tracks changes to it live. The preference is durable (`prefs.json`, next to the project registry) and is stamped into the page before the bundle loads, so launching never shows a frame of the wrong theme. `light`/`dark` are explicit overrides that ignore the OS. + ### Primary - **Terracotta Pencil** (`oklch(0.48 0.14 28)`, INK theme `oklch(0.72 0.10 220)` steel blue): the one accent. Primary buttons, current selection, checked checkboxes, focus rings, the "shared for review" marker. Warm red-clay on paper; it becomes a cool steel blue in dark mode because a saturated red-clay glows unpleasantly against a near-black ground. @@ -179,8 +181,19 @@ Role is no longer a color channel for humans — shape carries it (round = human - **Label** (600, `0.7rem`, `0.06em`, UPPERCASE): table headers, meta chips, sidebar section markers. Sans. - **Mono** (400, `0.85rem`, 1.55): code blocks and inline code. +### Typeset presets +The three cuts above are the **Editorial** preset — the default, and the shape every rule in this section describes. Settings offers two alternates (shadcn's typeset model: a preset is a complete reading system, never a pile of independent font knobs): + +- **Editorial** — the default described above. Declares no overrides, so it cannot drift from the canonical tokens. +- **Modern** — sans for reading as well as chrome, with display sizes pulled in and tracking tightened (serif display scale reads oversized in sans). For readers who want a code-review tool rather than a manuscript. +- **Compact** — Editorial's fonts at a denser scale and leading, on a narrower measure. For dense ops docs. + +Presets live in `web/styles/typeset.css`, keyed off `data-typeset` on ``, and only ever redefine existing tokens. They are orthogonal to light/dark (which owns color) and to the ⌘+/⌘- font scale (which multiplies `--attn-base-font-size`) — all three compose. + ### Named Rules -**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. +**The Read/Do Rule.** If the user is reading it, it's serif. If the user is operating it, it's sans. There is no third case; a button never uses the serif, a heading in the document never uses the sans. (A preset may change *which* face reads as the serif — Modern makes it a sans — but never which role gets the reading face.) + +**The Scoped-Document Rule** (2026-08-04). Document typography is scoped to `.attn-doc` — the class the editor mount and the viewer article carry. Bare `p` / `h1` / `ul` / `li` selectors are never global: chrome rendered in the same tree used to inherit 2rem heading gaps and absolutely-positioned list bullets that escaped their card, and each leak got patched individually until an opt-out class existed purely to undo the defaults. Type the document, not the page. **The Fixed-Scale Rule.** Product register: headings are fixed rem, not `clamp()`. Users view at consistent DPI inside panes and windows; a fluid h1 that shrinks in a sidebar looks worse, not better. diff --git a/src/ipc.rs b/src/ipc.rs index 5a572d00..45a6ae88 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -38,12 +38,18 @@ pub enum IpcMessage { #[serde(rename = "theme_change")] ThemeChange { theme: String }, + #[serde(rename = "typeset_change")] + TypesetChange { typeset: String }, + #[serde(rename = "open_external")] OpenExternal { path: String }, #[serde(rename = "drag_window")] DragWindow, + #[serde(rename = "zoom_window")] + ZoomWindow, + #[serde(rename = "open_devtools")] OpenDevtools, @@ -350,7 +356,19 @@ pub fn handle_message(body: &str, state: &Arc>, proxy: &EventLoo } } IpcMessage::ThemeChange { theme } => { - tracing::info!("theme change: {}", theme); + // Persist the PREFERENCE (light/dark/system), not the resolved + // appearance — so a `system` user keeps following the OS across + // restarts instead of freezing at whatever it was that night. + tracing::info!("theme preference: {}", theme); + if let Err(err) = crate::prefs::set_theme(&theme) { + tracing::warn!("could not persist theme preference: {}", err); + } + } + IpcMessage::TypesetChange { typeset } => { + tracing::info!("typeset preference: {}", typeset); + if let Err(err) = crate::prefs::set_typeset(&typeset) { + tracing::warn!("could not persist typeset preference: {}", err); + } } IpcMessage::OpenExternal { path } => { if !path.is_empty() @@ -362,6 +380,9 @@ pub fn handle_message(body: &str, state: &Arc>, proxy: &EventLoo IpcMessage::DragWindow => { let _ = proxy.send_event(UserEvent::DragWindow); } + IpcMessage::ZoomWindow => { + let _ = proxy.send_event(UserEvent::ZoomWindow); + } IpcMessage::OpenDevtools => { let _ = proxy.send_event(UserEvent::OpenDevtools); } diff --git a/src/main.rs b/src/main.rs index 2dedea49..4a32daa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod ipc; mod logging; mod markdown; mod platform; +mod prefs; mod projects; mod resident; mod review; @@ -363,8 +364,16 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { let initial_structure = markdown::PlanStructure::default(); let (initial_mtime_ms, initial_bytes) = content_metadata_for_path(&initial_ui_path); - // Determine theme - let theme = if cli.dark { "dark" } else { "light" }; + // Appearance: the durable preference wins unless `--dark` explicitly + // overrides it for this launch. `system` is resolved in the page (the + // webview tracks the OS appearance live), so it is stamped through as-is. + let stored_prefs = prefs::load(); + let theme = if cli.dark { + prefs::THEME_DARK.to_string() + } else { + stored_prefs.theme.clone() + }; + let typeset = stored_prefs.typeset.clone(); let diag_mode = diag_mode_from_env(); // Review profile (onboarding): the user's chosen display name (if any), the @@ -410,6 +419,7 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { "knownProjects": &project_registry.known_projects, "activeProjectPath": project_registry.active_project, "theme": theme, + "typeset": typeset, "diagMode": diag_mode, "version": env!("CARGO_PKG_VERSION"), "contentMtimeMs": initial_mtime_ms, @@ -438,7 +448,7 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { }, }) .to_string(); - let page_html = build_page_html(&init_payload_json, theme); + let page_html = build_page_html(&init_payload_json, &theme, &typeset); let page_html_bytes = page_html.clone().into_bytes(); tracing::info!("startup page_html_bytes={}", page_html.len()); let dev_server_url = dev_server_url_from_env(); @@ -1176,6 +1186,12 @@ fn run_daemon(cli: Cli, path: PathBuf, resident_mode: bool) -> Result<()> { Event::UserEvent(UserEvent::DragWindow) => { let _ = window.drag_window(); } + Event::UserEvent(UserEvent::ZoomWindow) => { + // Native titlebar double-click gesture. On macOS tao routes + // set_maximized through NSWindow zoom:, matching the system + // titlebar behavior for the hidden bar. + window.set_maximized(!window.is_maximized()); + } Event::UserEvent(UserEvent::ResidentLaunchAtLogin { enabled }) => { // launchctl may terminate a daemon that is itself owned by the // LaunchAgent. Run the transaction in a separate attn helper @@ -2061,17 +2077,23 @@ fn mime_from_extension(path: &std::path::Path) -> &'static str { /// Embedded at compile time from build output in OUT_DIR. const APP_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/attn-index.html")); -fn build_page_html(init_payload_json: &str, theme: &str) -> String { +fn build_page_html(init_payload_json: &str, theme: &str, typeset: &str) -> String { let init_script = format!( r#""#, init_payload_json = init_payload_json, ); - // Inject into the template + // Inject into the template. The theme written here is the stored + // PREFERENCE (which may be `system`); the template's inline resolver + // script turns it into an effective light/dark before first paint. APP_HTML .replace("", &init_script) .replace("data-theme=\"system\"", &format!("data-theme=\"{theme}\"")) .replace("data-theme=\"light\"", &format!("data-theme=\"{theme}\"")) + .replace( + "data-typeset=\"editorial\"", + &format!("data-typeset=\"{typeset}\""), + ) } #[cfg(test)] diff --git a/src/prefs.rs b/src/prefs.rs new file mode 100644 index 00000000..c6edd18d --- /dev/null +++ b/src/prefs.rs @@ -0,0 +1,149 @@ +//! Durable UI preferences (appearance, typeset). +//! +//! Kept next to the project registry in the daemon's runtime namespace so a +//! preference survives daemon restarts AND can be read before the window +//! exists. That ordering is the point: the theme is stamped into the page HTML +//! at build time, so the first frame already carries the right appearance and +//! the user never sees a flash of the wrong theme. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// `system` defers to the OS appearance, resolved in the webview (which +/// tracks macOS light/dark live via `prefers-color-scheme`). +pub const THEME_LIGHT: &str = "light"; +pub const THEME_DARK: &str = "dark"; +pub const THEME_SYSTEM: &str = "system"; + +pub const TYPESET_DEFAULT: &str = "editorial"; +const TYPESETS: [&str; 3] = [TYPESET_DEFAULT, "modern", "compact"]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preferences { + /// `light` | `dark` | `system` + #[serde(default = "default_theme")] + pub theme: String, + /// Typeset preset id — see `web/styles/typeset.css`. + #[serde(default = "default_typeset")] + pub typeset: String, +} + +fn default_theme() -> String { + THEME_SYSTEM.to_string() +} + +fn default_typeset() -> String { + TYPESET_DEFAULT.to_string() +} + +impl Default for Preferences { + fn default() -> Self { + Self { + theme: default_theme(), + typeset: default_typeset(), + } + } +} + +/// Normalize an untrusted theme string; anything unrecognized falls back to +/// `system` rather than stamping a bogus attribute into the page. +pub fn normalize_theme(value: &str) -> String { + match value.trim() { + THEME_LIGHT => THEME_LIGHT.to_string(), + THEME_DARK => THEME_DARK.to_string(), + _ => THEME_SYSTEM.to_string(), + } +} + +pub fn normalize_typeset(value: &str) -> String { + let trimmed = value.trim(); + if TYPESETS.contains(&trimmed) { + trimmed.to_string() + } else { + TYPESET_DEFAULT.to_string() + } +} + +pub fn load() -> Preferences { + let path = prefs_path(); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Preferences::default(); + }; + match serde_json::from_str::(&raw) { + Ok(prefs) => Preferences { + theme: normalize_theme(&prefs.theme), + typeset: normalize_typeset(&prefs.typeset), + }, + Err(e) => { + tracing::warn!("could not parse preferences {}: {}", path.display(), e); + Preferences::default() + } + } +} + +pub fn set_theme(theme: &str) -> Result { + let mut prefs = load(); + prefs.theme = normalize_theme(theme); + save(&prefs)?; + Ok(prefs) +} + +pub fn set_typeset(typeset: &str) -> Result { + let mut prefs = load(); + prefs.typeset = normalize_typeset(typeset); + save(&prefs)?; + Ok(prefs) +} + +fn save(prefs: &Preferences) -> Result<()> { + let dir = crate::projects::storage_dir(); + std::fs::create_dir_all(&dir).with_context(|| format!("could not create {}", dir.display()))?; + let path = prefs_path(); + let payload = serde_json::to_string_pretty(prefs).context("could not serialize preferences")?; + std::fs::write(&path, payload).with_context(|| format!("could not write {}", path.display())) +} + +fn prefs_path() -> PathBuf { + crate::projects::storage_dir().join("prefs.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_theme_falls_back_to_system() { + assert_eq!(normalize_theme("dark"), THEME_DARK); + assert_eq!(normalize_theme("light"), THEME_LIGHT); + assert_eq!(normalize_theme("system"), THEME_SYSTEM); + assert_eq!(normalize_theme("chartreuse"), THEME_SYSTEM); + assert_eq!(normalize_theme(""), THEME_SYSTEM); + } + + #[test] + fn unknown_typeset_falls_back_to_default() { + assert_eq!(normalize_typeset("modern"), "modern"); + assert_eq!(normalize_typeset("compact"), "compact"); + assert_eq!(normalize_typeset("wingdings"), TYPESET_DEFAULT); + } + + #[test] + fn preferences_round_trip_through_json() { + let prefs = Preferences { + theme: THEME_DARK.to_string(), + typeset: "compact".to_string(), + }; + let raw = serde_json::to_string(&prefs).expect("serialize"); + let parsed: Preferences = serde_json::from_str(&raw).expect("deserialize"); + assert_eq!(parsed.theme, THEME_DARK); + assert_eq!(parsed.typeset, "compact"); + } + + #[test] + fn missing_fields_use_defaults() { + let parsed: Preferences = serde_json::from_str("{}").expect("deserialize empty"); + assert_eq!(parsed.theme, THEME_SYSTEM); + assert_eq!(parsed.typeset, TYPESET_DEFAULT); + } +} diff --git a/src/projects.rs b/src/projects.rs index 0e9cd83e..3ab714fe 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -69,7 +69,9 @@ fn registry_path() -> PathBuf { storage_dir().join("projects.json") } -fn storage_dir() -> PathBuf { +/// The daemon's runtime namespace — shared by the project registry and +/// durable UI preferences (`src/prefs.rs`). +pub fn storage_dir() -> PathBuf { // ATTN_HOME wins over XDG_STATE_HOME so the project registry shares the // daemon's runtime namespace (see src/daemon.rs::runtime_dir). if let Ok(value) = std::env::var("ATTN_HOME") { diff --git a/src/watcher.rs b/src/watcher.rs index a407700d..5ace9afc 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -69,6 +69,8 @@ pub enum UserEvent { OpenDevtools, /// The user started dragging a custom title bar region. DragWindow, + /// The user double-clicked a title bar region — toggle native zoom. + ZoomWindow, /// Explicitly toggle the macOS resident daemon LaunchAgent. ResidentLaunchAtLogin { enabled: bool }, /// Show and focus the main window. diff --git a/web/index.html b/web/index.html index 0642b073..b3b0c9a4 100644 --- a/web/index.html +++ b/web/index.html @@ -1,9 +1,36 @@ - + attn + diff --git a/web/src/App.svelte b/web/src/App.svelte index 02ac464e..fb30bae5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,5 @@ - -
- - -
-
-
diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index b4d734c4..0cb04153 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -1024,5 +1024,5 @@ {/if} -
+
diff --git a/web/src/lib/PathBreadcrumb.svelte b/web/src/lib/PathBreadcrumb.svelte index ef621fbd..eab2a4ca 100644 --- a/web/src/lib/PathBreadcrumb.svelte +++ b/web/src/lib/PathBreadcrumb.svelte @@ -8,7 +8,7 @@ BreadcrumbPage, BreadcrumbSeparator, } from '$lib/components/ui/breadcrumb'; - import { dragWindow } from './ipc'; + import { dragWindow, zoomWindow } from './ipc'; import Share2 from '@lucide/svelte/icons/share-2'; import ExternalLink from '@lucide/svelte/icons/external-link'; @@ -98,6 +98,9 @@ onmousedown={(event) => { if (event.target === event.currentTarget) dragWindow(event); }} + ondblclick={(event) => { + if (event.target === event.currentTarget) zoomWindow(event); + }} > {#if segments.length > 1} diff --git a/web/src/lib/ReviewExitConfirm.svelte b/web/src/lib/ReviewExitConfirm.svelte new file mode 100644 index 00000000..2e15ad53 --- /dev/null +++ b/web/src/lib/ReviewExitConfirm.svelte @@ -0,0 +1,43 @@ + + + + { if (!next) onCancel(); }}> + + + Exit review? + + You're reviewing + {documentName}. + Switching files closes the review panel for this document — the share + and its comments stay intact. + + + + + + + + + diff --git a/web/src/lib/ReviewFileSidebar.svelte b/web/src/lib/ReviewFileSidebar.svelte index d9c56854..718a34b8 100644 --- a/web/src/lib/ReviewFileSidebar.svelte +++ b/web/src/lib/ReviewFileSidebar.svelte @@ -32,7 +32,7 @@ {#if files.length >= 2}