diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..0f93456 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,38 @@ +name: Check + +on: + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-22.04 + - macos-latest + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + xdg-utils + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Stub frontendDist for cargo build + run: mkdir -p dist + + - name: Run cargo test + working-directory: src-tauri + run: cargo test \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index adcfba2..e2b7207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Native menu bar (File / Edit / View / Window / Help) with platform-gated accelerators +- Markdown file associations (`.md` / `.markdown` / `.mdx`) so Marky can be the default viewer +- PR check running `cargo test` on macOS and Linux - Adjustable text size — scales all text app-wide (sidebar, toolbar, command palette, markdown content) via Settings dropdown or Cmd+Plus/Cmd+Minus/Cmd+0 - Resizable sidebars — drag the edge of the folder sidebar or table of contents to resize; double-click to reset to default - Copy contents as markdown diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 244ce1b..1cf52c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod error; mod folder; mod fs; +mod menu; mod registry; mod search; mod settings; @@ -110,8 +111,14 @@ pub fn run() { app.manage(InitialTargetState(Mutex::new(initial_resolved))); app.manage(registry); app.manage(watchers); + + let (menu, handles) = menu::build(app.handle())?; + app.set_menu(menu)?; + app.manage(handles); + Ok(()) }) + .on_menu_event(menu::handle_event) .invoke_handler(tauri::generate_handler![ commands::get_initial_target, commands::set_initial_target, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs new file mode 100644 index 0000000..64b1a43 --- /dev/null +++ b/src-tauri/src/menu.rs @@ -0,0 +1,371 @@ +//! Native menu bar: static menu construction, id parsing, and event routing. +//! +//! Platform differences (see docs/plans/native-menu-bar.md "Platform gating") +//! live entirely in this module via `#[cfg(target_os = "macos")]` / +//! `#[cfg(not(target_os = "macos"))]`. `cfg!()` is only used for label text. + +use tauri::menu::{ + Menu, MenuBuilder, MenuEvent, MenuItem, MenuItemBuilder, Submenu, SubmenuBuilder, +}; +use tauri::{AppHandle, Emitter, Manager, Wry}; +use tauri_plugin_opener::OpenerExt; + +/// Menu items whose enabled state depends on app state (has-file / +/// has-folder). Stored in Tauri managed state so a later task (#004) can +/// toggle them without rebuilding the whole menu. The recent-files/folders +/// submenus are populated by #003. +pub struct MenuHandles { + #[allow(dead_code)] + pub file_items: Vec>, + #[allow(dead_code)] + pub folder_items: Vec>, + #[allow(dead_code)] + pub recent_files: Submenu, + #[allow(dead_code)] + pub recent_folders: Submenu, +} + +/// Build the full native menu bar (app, File, Edit, View, Window, Help). +pub fn build(app: &AppHandle) -> tauri::Result<(Menu, MenuHandles)> { + let mut builder = MenuBuilder::new(app); + + #[cfg(target_os = "macos")] + { + builder = builder.item(&app_menu(app)?); + } + + let (file_menu, file_items, folder_items, recent_files, recent_folders) = file_menu(app)?; + builder = builder.item(&file_menu).item(&edit_menu(app)?).item(&view_menu(app)?); + + #[cfg(target_os = "macos")] + { + builder = builder.item(&window_menu(app)?); + } + + builder = builder.item(&help_menu(app)?); + + let menu = builder.build()?; + + Ok(( + menu, + MenuHandles { + file_items, + folder_items, + recent_files, + recent_folders, + }, + )) +} + +#[cfg(target_os = "macos")] +fn app_menu(app: &AppHandle) -> tauri::Result> { + SubmenuBuilder::new(app, "Marky") + .about_with_text("About Marky", None) + .separator() + .services() + .separator() + .hide_with_text("Hide Marky") + .hide_others() + .show_all() + .separator() + .quit_with_text("Quit Marky") + .build() +} + +type FileMenuParts = ( + Submenu, + Vec>, + Vec>, + Submenu, + Submenu, +); + +fn file_menu(app: &AppHandle) -> tauri::Result { + let open = MenuItemBuilder::with_id("open", "Open…") + .accelerator("CmdOrCtrl+O") + .build(app)?; + let open_folder = MenuItemBuilder::with_id("open-folder", "Open Folder…") + .accelerator("CmdOrCtrl+Shift+O") + .build(app)?; + + // Populated by #003; empty and disabled for now. + let recent_files = SubmenuBuilder::with_id(app, "recent-files", "Open Recent") + .enabled(false) + .build()?; + let recent_folders = SubmenuBuilder::with_id(app, "recent-folders", "Open Recent Folder") + .enabled(false) + .build()?; + + let reload_file = MenuItemBuilder::with_id("reload-file", "Reload File") + .accelerator("CmdOrCtrl+R") + .build(app)?; + let rescan_folder = MenuItemBuilder::with_id("rescan-folder", "Rescan Folder") + .accelerator("CmdOrCtrl+Shift+R") + .build(app)?; + + let export_html = MenuItemBuilder::with_id("export-html", "HTML…") + .accelerator("Alt+CmdOrCtrl+E") + .build(app)?; + let export_markdown = MenuItemBuilder::with_id("export-markdown", "Markdown Copy…") + .accelerator("CmdOrCtrl+Shift+S") + .build(app)?; + let export_menu = SubmenuBuilder::new(app, "Export") + .item(&export_html) + .item(&export_markdown) + .build()?; + + let print = MenuItemBuilder::with_id("print", "Print…") + .accelerator("CmdOrCtrl+P") + .build(app)?; + + let reveal_label = if cfg!(target_os = "macos") { + "Reveal in Finder" + } else { + "Show in File Manager" + }; + let reveal = MenuItemBuilder::with_id("reveal", reveal_label) + .accelerator("Alt+CmdOrCtrl+R") + .build(app)?; + + let close_tab = MenuItemBuilder::with_id("close-tab", "Close Tab") + .accelerator("CmdOrCtrl+W") + .build(app)?; + let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder") + .accelerator("CmdOrCtrl+Shift+W") + .build(app)?; + + let builder = SubmenuBuilder::new(app, "File") + .item(&open) + .item(&open_folder) + .item(&recent_files) + .item(&recent_folders) + .separator() + .item(&reload_file) + .item(&rescan_folder) + .separator() + .item(&export_menu) + .item(&print) + .separator() + .item(&reveal); + + #[cfg(target_os = "macos")] + let open_with = MenuItemBuilder::with_id("open-with", "Open With…") + .accelerator("Alt+CmdOrCtrl+O") + .build(app)?; + #[cfg(target_os = "macos")] + let builder = builder.item(&open_with); + + let builder = builder.separator().item(&close_tab).item(&close_folder); + + #[cfg(not(target_os = "macos"))] + let quit = MenuItemBuilder::with_id("quit", "Quit") + .accelerator("Ctrl+Q") + .build(app)?; + #[cfg(not(target_os = "macos"))] + let builder = builder.separator().item(&quit); + + let file_menu = builder.build()?; + + let mut file_items = vec![ + reload_file, + export_html, + export_markdown, + print, + reveal, + close_tab, + ]; + #[cfg(target_os = "macos")] + file_items.push(open_with); + + let folder_items = vec![rescan_folder, close_folder]; + + Ok(( + file_menu, + file_items, + folder_items, + recent_files, + recent_folders, + )) +} + +fn edit_menu(app: &AppHandle) -> tauri::Result> { + let builder = SubmenuBuilder::new(app, "Edit"); + + #[cfg(target_os = "macos")] + let builder = builder.undo().redo().separator(); + + let find = MenuItemBuilder::with_id("find", "Find…") + .accelerator("CmdOrCtrl+F") + .build(app)?; + + builder + .cut() + .copy() + .paste() + .select_all() + .separator() + .item(&find) + .build() +} + +fn view_menu(app: &AppHandle) -> tauri::Result> { + let command_palette = MenuItemBuilder::with_id("command-palette", "Command Palette") + .accelerator("CmdOrCtrl+K") + .build(app)?; + let split_right = MenuItemBuilder::with_id("split-right", "Split Right") + .accelerator("CmdOrCtrl+\\") + .build(app)?; + let split_down = MenuItemBuilder::with_id("split-down", "Split Down") + .accelerator("CmdOrCtrl+Shift+\\") + .build(app)?; + let close_split = MenuItemBuilder::with_id("close-split", "Close Split").build(app)?; + let zoom_reset = MenuItemBuilder::with_id("zoom-reset", "Actual Size") + .accelerator("CmdOrCtrl+0") + .build(app)?; + let zoom_in = MenuItemBuilder::with_id("zoom-in", "Zoom In") + .accelerator("CmdOrCtrl+=") + .build(app)?; + let zoom_out = MenuItemBuilder::with_id("zoom-out", "Zoom Out") + .accelerator("CmdOrCtrl+-") + .build(app)?; + + let builder = SubmenuBuilder::new(app, "View") + .item(&command_palette) + .separator() + .item(&split_right) + .item(&split_down) + .item(&close_split) + .separator() + .item(&zoom_reset) + .item(&zoom_in) + .item(&zoom_out) + .separator(); + + #[cfg(target_os = "macos")] + let builder = builder.fullscreen_with_text("Enter Full Screen"); + + #[cfg(not(target_os = "macos"))] + let builder = { + let toggle_fullscreen = MenuItemBuilder::with_id("toggle-fullscreen", "Toggle Full Screen") + .accelerator("F11") + .build(app)?; + builder.item(&toggle_fullscreen) + }; + + builder.build() +} + +#[cfg(target_os = "macos")] +fn window_menu(app: &AppHandle) -> tauri::Result> { + let submenu = SubmenuBuilder::new(app, "Window") + .minimize() + .maximize_with_text("Zoom") + .build()?; + // Makes AppKit add "Bring All to Front" plus the window list for us. + submenu.set_as_windows_menu_for_nsapp()?; + Ok(submenu) +} + +fn help_menu(app: &AppHandle) -> tauri::Result> { + let help_github = MenuItemBuilder::with_id("help-github", "Marky on GitHub").build(app)?; + let help_issue = MenuItemBuilder::with_id("help-issue", "Report an Issue").build(app)?; + + let builder = SubmenuBuilder::new(app, "Help"); + + #[cfg(not(target_os = "macos"))] + let builder = builder.about_with_text("About Marky", None).separator(); + + let submenu = builder.item(&help_github).item(&help_issue).build()?; + + #[cfg(target_os = "macos")] + submenu.set_as_help_menu_for_nsapp()?; + + Ok(submenu) +} + +/// Parsed intent of a menu id. Most ids are simply forwarded to the frontend +/// as `menu://action`; a handful are handled entirely in Rust. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MenuCommand { + OpenUrl(&'static str), + Quit, + ToggleFullscreen, + Forward(String), +} + +/// Pure mapping from a menu item id to the command it represents. Kept free +/// of side effects so it's easy to unit test. +pub fn parse_menu_id(id: &str) -> MenuCommand { + match id { + "help-github" => MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky"), + "help-issue" => MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky/issues/new"), + "quit" => MenuCommand::Quit, + "toggle-fullscreen" => MenuCommand::ToggleFullscreen, + other => MenuCommand::Forward(other.to_string()), + } +} + +/// Route a native menu click. Ids not handled here are forwarded to the +/// frontend on `menu://action` for `App.tsx` to dispatch. +pub fn handle_event(app: &AppHandle, event: MenuEvent) { + match parse_menu_id(event.id().as_ref()) { + MenuCommand::OpenUrl(url) => { + if let Err(err) = app.opener().open_url(url, None::<&str>) { + eprintln!("menu: failed to open {url}: {err}"); + } + } + MenuCommand::Quit => app.exit(0), + MenuCommand::ToggleFullscreen => { + if let Some(window) = app.get_webview_window("main") { + if let Ok(is_fullscreen) = window.is_fullscreen() { + let _ = window.set_fullscreen(!is_fullscreen); + } + } + } + MenuCommand::Forward(id) => { + let _ = app.emit("menu://action", id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_help_github() { + assert_eq!( + parse_menu_id("help-github"), + MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky") + ); + } + + #[test] + fn parses_help_issue() { + assert_eq!( + parse_menu_id("help-issue"), + MenuCommand::OpenUrl("https://github.com/GRVYDEV/marky/issues/new") + ); + } + + #[test] + fn parses_quit() { + assert_eq!(parse_menu_id("quit"), MenuCommand::Quit); + } + + #[test] + fn parses_toggle_fullscreen() { + assert_eq!( + parse_menu_id("toggle-fullscreen"), + MenuCommand::ToggleFullscreen + ); + } + + #[test] + fn forwards_unrecognized_ids() { + assert_eq!( + parse_menu_id("close-tab"), + MenuCommand::Forward("close-tab".to_string()) + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ff5d4e8..12ae127 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,6 +34,15 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" + ], + "fileAssociations": [ + { + "ext": ["md", "markdown", "mdx"], + "name": "Markdown Document", + "role": "Viewer", + "mimeType": "text/markdown", + "contentTypes": ["net.daringfireball.markdown"] + } ] } } diff --git a/src/App.tsx b/src/App.tsx index 6f4160c..249cf9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,16 @@ import { Pane } from "@/components/Pane"; import { TableOfContents } from "@/components/TableOfContents"; import { Toolbar } from "@/components/Toolbar"; import { CommandPalette } from "@/components/CommandPalette"; -import { tauri, onCliTarget, onFolderChanged, onFileChanged, type AnnotatedFolder } from "@/lib/tauri"; +import { + tauri, + onCliTarget, + onFolderChanged, + onFileChanged, + onMenuAction, + type AnnotatedFolder, + type MenuAction, +} from "@/lib/tauri"; +import { pickAndAddFolder } from "@/lib/folders"; import { createInitialState, reduce, @@ -140,42 +149,6 @@ function AppShell() { return pathToFolderId[p] ?? null; } - // Global keyboard shortcuts. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - const meta = e.metaKey || e.ctrlKey; - const k = e.key.toLowerCase(); - if (meta && k === "k") { - e.preventDefault(); - setPaletteOpen((v) => !v); - } else if (meta && k === "o") { - e.preventDefault(); - handlePickFile(); - } else if (meta && k === "f") { - e.preventDefault(); - setSearchPaneId(state.activePaneId); - } else if (meta && e.key === "\\") { - e.preventDefault(); - dispatch({ type: "SPLIT", direction: e.shiftKey ? "horizontal" : "vertical" }); - } else if (meta && k === "w") { - e.preventDefault(); - const tabId = activePane.activeTabId; - if (tabId) dispatch({ type: "CLOSE_TAB", tabId, paneId: activePane.id }); - } else if (meta && (e.key === "=" || e.key === "+")) { - e.preventDefault(); - zoomIn(); - } else if (meta && e.key === "-") { - e.preventDefault(); - zoomOut(); - } else if (meta && k === "0") { - e.preventDefault(); - zoomReset(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [state.activePaneId, activePane.activeTabId, activePane.id, zoomIn, zoomOut, zoomReset]); - // File drop opens as new tab. useEffect(() => { let unlisten: (() => void) | undefined; @@ -206,6 +179,82 @@ function AppShell() { ?.scrollIntoView({ block: "start" }); }; + // Route native menu actions. Kept in a ref (updated every render) so the + // listener below is registered exactly once and never sees stale state. + const menuHandlerRef = useRef<(action: MenuAction) => void>(() => {}); + menuHandlerRef.current = async (action: MenuAction) => { + switch (action) { + case "open": + handlePickFile(); + break; + case "open-folder": { + const folder = await pickAndAddFolder(); + if (folder) refreshFolders(); + break; + } + case "reload-file": { + if (activeTab?.filePath) { + try { + const text = await tauri.readFile(activeTab.filePath); + dispatch({ type: "UPDATE_TAB_SOURCE", tabId: activeTab.id, source: text }); + } catch (err) { + console.error("failed to reload file", err); + } + } + break; + } + case "close-tab": { + const tabId = activePane.activeTabId; + if (tabId) dispatch({ type: "CLOSE_TAB", tabId, paneId: activePane.id }); + break; + } + case "find": + setSearchPaneId(state.activePaneId); + break; + case "command-palette": + setPaletteOpen((v) => !v); + break; + case "split-right": + dispatch({ type: "SPLIT", direction: "vertical" }); + break; + case "split-down": + dispatch({ type: "SPLIT", direction: "horizontal" }); + break; + case "close-split": + handleCloseSplit(); + break; + case "zoom-in": + zoomIn(); + break; + case "zoom-out": + zoomOut(); + break; + case "zoom-reset": + zoomReset(); + break; + case "rescan-folder": + case "close-folder": + // TODO(#002) + break; + case "print": + case "reveal": + case "open-with": + // TODO(#005) + break; + case "export-html": + case "export-markdown": + // TODO(#006) + break; + } + }; + + useEffect(() => { + const off = onMenuAction((action) => menuHandlerRef.current(action)); + return () => { + off.then((fn) => fn()); + }; + }, []); + const renderPane = (paneId: string) => { const pane = state.panes.find((p) => p.id === paneId)!; return ( diff --git a/src/components/FolderSidebar.tsx b/src/components/FolderSidebar.tsx index ee2a500..1785005 100644 --- a/src/components/FolderSidebar.tsx +++ b/src/components/FolderSidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from "react"; -import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { tauri, onFolderChanged, type AnnotatedFolder, type TreeNode } from "@/lib/tauri"; +import { pickAndAddFolder } from "@/lib/folders"; import { usePreferences } from "@/lib/preferences"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -92,11 +92,8 @@ export function FolderSidebar({ activePath, onOpenFile, onOpenPalette, refreshNo }, []); const handleAdd = async () => { - const picked = await openDialog({ directory: true, multiple: false }); - if (typeof picked === "string") { - await tauri.addFolder(picked); - await loadAll(); - } + const folder = await pickAndAddFolder(); + if (folder) await loadAll(); }; const handleRemove = async (id: string) => { diff --git a/src/lib/folders.ts b/src/lib/folders.ts new file mode 100644 index 0000000..a3f7a63 --- /dev/null +++ b/src/lib/folders.ts @@ -0,0 +1,13 @@ +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { tauri, type Folder } from "@/lib/tauri"; + +/** + * Open the native "choose a folder" dialog and, if the user picks one, + * register it with the backend. Shared by the sidebar's + button and the + * File ▸ Open Folder… menu action so both go through one code path. + */ +export async function pickAndAddFolder(): Promise { + const picked = await openDialog({ directory: true, multiple: false }); + if (typeof picked !== "string") return null; + return tauri.addFolder(picked); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c5993c7..05cde9f 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -73,3 +73,28 @@ export function onFileChanged(cb: (paths: string[]) => void): Promise void): Promise { return listen("cli://target", (e) => cb(e.payload)); } + +export type MenuAction = + | "open" + | "open-folder" + | "reload-file" + | "rescan-folder" + | "export-html" + | "export-markdown" + | "print" + | "reveal" + | "open-with" + | "close-tab" + | "close-folder" + | "find" + | "command-palette" + | "split-right" + | "split-down" + | "close-split" + | "zoom-in" + | "zoom-out" + | "zoom-reset"; + +export function onMenuAction(cb: (action: MenuAction) => void): Promise { + return listen("menu://action", (e) => cb(e.payload)); +} diff --git a/src/styles/markdown.css b/src/styles/markdown.css index 807bbdb..8273c94 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -3,13 +3,19 @@ .markdown-body { @apply text-foreground leading-7; font-size: 0.9375rem; - max-width: 860px; + width: 100%; + max-width: none; + box-sizing: border-box; margin: 0 auto; padding: 2rem 2.5rem 6rem; } -.markdown-body > *:first-child { margin-top: 0; } -.markdown-body > *:last-child { margin-bottom: 0; } +.markdown-body > *:first-child { + margin-top: 0; +} +.markdown-body > *:last-child { + margin-bottom: 0; +} .markdown-body h1, .markdown-body h2, @@ -22,32 +28,76 @@ margin-bottom: 0.75rem; scroll-margin-top: 4rem; } -.markdown-body h1 { font-size: 2rem; line-height: 1.2; border-bottom: 1px solid var(--border); padding-bottom: 0.4em; } -.markdown-body h2 { font-size: 1.5rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } -.markdown-body h3 { font-size: 1.25rem; } -.markdown-body h4 { font-size: 1.05rem; } -.markdown-body h5 { font-size: 0.95rem; @apply text-muted-foreground; } -.markdown-body h6 { font-size: 0.85rem; @apply text-muted-foreground uppercase tracking-wider; } +.markdown-body h1 { + font-size: 2rem; + line-height: 1.2; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4em; +} +.markdown-body h2 { + font-size: 1.5rem; + line-height: 1.3; + border-bottom: 1px solid var(--border); + padding-bottom: 0.3em; +} +.markdown-body h3 { + font-size: 1.25rem; +} +.markdown-body h4 { + font-size: 1.05rem; +} +.markdown-body h5 { + font-size: 0.95rem; + @apply text-muted-foreground; +} +.markdown-body h6 { + font-size: 0.85rem; + @apply text-muted-foreground uppercase tracking-wider; +} -.markdown-body p { margin: 0.75rem 0; } -.markdown-body a { @apply text-primary underline underline-offset-2; } -.markdown-body a:hover { @apply opacity-80; } +.markdown-body p { + margin: 0.75rem 0; +} +.markdown-body a { + @apply text-primary underline underline-offset-2; +} +.markdown-body a:hover { + @apply opacity-80; +} -.markdown-body strong { @apply font-semibold; } -.markdown-body em { font-style: italic; } -.markdown-body del { @apply text-muted-foreground line-through; } +.markdown-body strong { + @apply font-semibold; +} +.markdown-body em { + font-style: italic; +} +.markdown-body del { + @apply text-muted-foreground line-through; +} .markdown-body ul, .markdown-body ol { margin: 0.75rem 0; padding-left: 1.5rem; } -.markdown-body ul { list-style: disc; } -.markdown-body ol { list-style: decimal; } -.markdown-body li { margin: 0.25rem 0; } -.markdown-body li > ul, .markdown-body li > ol { margin: 0.25rem 0; } +.markdown-body ul { + list-style: disc; +} +.markdown-body ol { + list-style: decimal; +} +.markdown-body li { + margin: 0.25rem 0; +} +.markdown-body li > ul, +.markdown-body li > ol { + margin: 0.25rem 0; +} -.markdown-body .task-list-item { list-style: none; margin-left: -1.25rem; } +.markdown-body .task-list-item { + list-style: none; + margin-left: -1.25rem; +} .markdown-body .task-list-item input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; @@ -75,17 +125,23 @@ display: block; overflow-x: auto; } -.markdown-body th, .markdown-body td { +.markdown-body th, +.markdown-body td { border: 1px solid var(--border); padding: 0.5rem 0.75rem; text-align: left; } -.markdown-body th { @apply bg-muted font-semibold; } -.markdown-body tr:nth-child(2n) td { background: color-mix(in oklch, var(--muted) 40%, transparent); } +.markdown-body th { + @apply bg-muted font-semibold; +} +.markdown-body tr:nth-child(2n) td { + background: color-mix(in oklch, var(--muted) 40%, transparent); +} .markdown-body code:not(pre code) { @apply bg-muted text-foreground rounded; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em; padding: 0.12em 0.35em; } @@ -102,7 +158,8 @@ .markdown-body pre.shiki { display: block; padding: 1rem 1.1rem; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.86em; line-height: 1.55; overflow-x: auto; @@ -111,8 +168,13 @@ /* Shiki paints inline color/background on the
 and on every token
     based on the theme passed at render time, so no extra mapping
    needed here. We just keep padding consistent with our own pre rule. */
-.markdown-body pre.shiki { padding: 1rem 1.1rem; }
-.markdown-body pre.shiki code { background: transparent; padding: 0; }
+.markdown-body pre.shiki {
+  padding: 1rem 1.1rem;
+}
+.markdown-body pre.shiki code {
+  background: transparent;
+  padding: 0;
+}
 
 .markdown-body .copy-code-btn {
   position: absolute;
@@ -128,10 +190,17 @@
   opacity: 0;
   transition: opacity 120ms ease;
 }
-.markdown-body pre:hover .copy-code-btn { opacity: 1; }
-.markdown-body .copy-code-btn:hover { color: var(--foreground); }
+.markdown-body pre:hover .copy-code-btn {
+  opacity: 1;
+}
+.markdown-body .copy-code-btn:hover {
+  color: var(--foreground);
+}
 
-.markdown-body img { max-width: 100%; border-radius: 0.4rem; }
+.markdown-body img {
+  max-width: 100%;
+  border-radius: 0.4rem;
+}
 
 .markdown-body .mermaid-block {
   background: var(--muted);
@@ -142,7 +211,10 @@
   text-align: center;
   overflow-x: auto;
 }
-.markdown-body .mermaid-block svg { max-width: 100%; height: auto; }
+.markdown-body .mermaid-block svg {
+  max-width: 100%;
+  height: auto;
+}
 
 .markdown-body mark.doc-search-match {
   background: color-mix(in oklch, var(--muted-foreground) 35%, transparent);