diff --git a/src/App.tsx b/src/App.tsx index 4b498d9..a5856c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { useCallback, useState, useEffect, useRef } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { Sparkles } from "lucide-react"; +import { PencilLine, Sparkles } from "lucide-react"; import { Editor } from "./components/Editor"; +import { EmptyState as EmptyStateShell } from "./components/EmptyState"; import { PdfPreview } from "./components/PdfPreview"; import { StatusBar } from "./components/StatusBar"; import { Sidebar } from "./components/Sidebar"; @@ -195,15 +196,10 @@ function App() { {!sidebarCollapsed && (
-
- {resizingPanel === "sidebar" && ( -
- )} -
+ /> )} {/* Main content area */} @@ -224,15 +220,10 @@ function App() {
-
- {resizingPanel === "pdf" && ( -
- )} -
+ /> )} {/* PDF Preview panel */} @@ -281,34 +272,26 @@ function EmptyState() { }, [workspacePath, createDocumentFromTemplate, openingExample]); return ( -
-
- - {"\u270E"} - -
-
-

- {t("editor.noDocument")} -

-

- {t("editor.noDocumentHint")} -

-
- {workspacePath && ( - - )} -
+ } + title={t("editor.noDocument")} + description={t("editor.noDocumentHint")} + cta={ + workspacePath ? ( + + ) : undefined + } + /> ); } diff --git a/src/components/CoverPageDialog.tsx b/src/components/CoverPageDialog.tsx index 67fcb2f..2b2b34f 100644 --- a/src/components/CoverPageDialog.tsx +++ b/src/components/CoverPageDialog.tsx @@ -87,16 +87,15 @@ export function CoverPageDialog({ open, block, editor, onClose }: Props) { }} >
+ {/* Header — single line with the dialog name on the left and the + preview-toggle + close button on the right. The earlier + decorative L logo + tag + subtitle stripe was three pieces of + chrome conveying one fact ("you're in the cover-page editor"), + which is already obvious from the modal context. */}
-
- -
-
{t("cover.tag")}
-
- {t("cover.dialogSubtitle")} -
+
+
+ {t("cover.dialogSubtitle")}
@@ -112,11 +111,11 @@ export function CoverPageDialog({ open, block, editor, onClose }: Props) {
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 02e1352..44411b3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -667,7 +667,12 @@ export function Editor() { editor={editor} onInsertCitation={openCitationPicker} /> -
e.preventDefault()}> + {/* `relative` is required: flashCurrentLine appends an absolutely- + positioned overlay to this element with coordinates computed + relative to its scroll origin. Without a positioned ancestor + here, the overlay would resolve against the viewport (or some + ancestor) and land on the wrong row. */} +
e.preventDefault()}>
+ {/* Headings only — clicking an already-active heading button + demotes the block back to a paragraph (see setHeading below), + so a dedicated Pilcrow paragraph button is redundant. */} H2 - - -
); } -/** - * Eye toggle that flips between `chip` and `footnote` rendering of citation - * tags in the editor. Purely a display setting — the underlying document is - * untouched. - */ -function CitationDisplayToggle() { - const display = useReferenceStore((s) => s.citationDisplay); - const toggle = useReferenceStore((s) => s.toggleCitationDisplay); - const isFootnote = display === "footnote"; - const label = isFootnote - ? "Citation display: footnote — show as chip" - : "Citation display: chip — show as footnote"; - return ( - - ); -} interface BtnProps { label: string; @@ -272,6 +238,8 @@ function ToolbarTextBtn({ label, active, onClick, children }: BtnProps) { function StyleChip() { const citationStyle = useReferenceStore((s) => s.citationStyle); const setCitationStyle = useReferenceStore((s) => s.setCitationStyle); + const display = useReferenceStore((s) => s.citationDisplay); + const toggleDisplay = useReferenceStore((s) => s.toggleCitationDisplay); const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const anchorRef = useRef(null); @@ -279,6 +247,13 @@ function StyleChip() { const editionLabel = STYLE_EDITION[citationStyle.toLowerCase()] ?? ""; + const setDisplay = useCallback( + (mode: "chip" | "footnote") => { + if (display !== mode) toggleDisplay(); + }, + [display, toggleDisplay] + ); + useEffect(() => { if (!open) return; const onDown = (event: MouseEvent) => { @@ -294,7 +269,7 @@ function StyleChip() { useEffect(() => { if (!open || !anchorRef.current) return; const r = anchorRef.current.getBoundingClientRect(); - const menuWidth = 180; + const menuWidth = 200; setPos({ top: r.bottom + 6, left: Math.max(8, r.right - menuWidth), @@ -332,8 +307,43 @@ function StyleChip() { left: pos.left, zIndex: 9999, }} - className="menu-surface w-[180px] py-1 animate-fade-in" + className="menu-surface w-[200px] py-1 animate-fade-in" > + {/* Display group — flips between in-line @key chips and + superscript-numbered footnote markers in the editor. + The PDF output is unaffected; this is purely how citations + render on screen. */} +
+ Display +
+ + + +
+ +
+ Style +
{ordered.map((name, i) => { const active = name === citationStyle; return ( diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..1c2f067 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; + +/** + * Shared empty-state shell. Three places in the app show "nothing here + * yet" prompts (no workspace, no document, no PDF) and they previously + * each rolled their own dashed-border boxes at different sizes, fonts, + * and button treatments. This component standardises the visual scale + * so they read as the same kind of moment, even when the content + * differs. + * + * The icon prop receives the icon node (sized however the caller likes, + * but 22 px works best inside the 56 px container). The CTA is optional + * — leave it out for purely informative states. + */ +export function EmptyState({ + icon, + title, + description, + cta, + className = "", +}: { + icon: ReactNode; + title: ReactNode; + description?: ReactNode; + cta?: ReactNode; + className?: string; +}) { + return ( +
+
+ {icon} +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {cta &&
{cta}
} +
+ ); +} diff --git a/src/components/FloatingOutline.tsx b/src/components/FloatingOutline.tsx index b6b1461..f60b600 100644 --- a/src/components/FloatingOutline.tsx +++ b/src/components/FloatingOutline.tsx @@ -80,7 +80,7 @@ export function FloatingOutline({ editor }: FloatingOutlineProps) {
- + {t("editor.outline")}
@@ -103,7 +103,7 @@ export function FloatingOutline({ editor }: FloatingOutlineProps) { onMouseLeave={() => setHoveredIndex(null)} className={`block w-full text-left py-1.5 px-2 rounded-md transition-all duration-150 truncate ${ hoveredIndex === index - ? "bg-[var(--accent-light)] text-[var(--accent)]" + ? "bg-[var(--accent-light)] text-[var(--accent-dark)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" }`} style={{ diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index a4739c6..78e0285 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -17,6 +17,7 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import { useAppStore } from "../stores/app-store"; import type { SourceMapEntry } from "../stores/app-store"; import { useT } from "../lib/i18n"; +import { EmptyState } from "./EmptyState"; pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; @@ -55,6 +56,13 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing const [zoom, setZoom] = useState(1.0); const [renderedZoom, setRenderedZoom] = useState(1.0); const renderTimerRef = useRef | null>(null); + // Pointer-drag bookkeeping. We *don't* set `isDragging` or call + // `setPointerCapture` on pointerdown — that interferes with the second + // mousedown of a dblclick pair on some webviews and the dblclick handler + // never fires. Instead, capture is deferred until movement crosses a + // small threshold (the same pattern FileTreeItem already uses). + const dragArmedRef = useRef<{ pointerId: number; el: HTMLDivElement } | null>(null); + const pointerCapturedRef = useRef(false); const clampZoom = (z: number) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); const zoomPercent = Math.round(zoom * 100); @@ -84,12 +92,14 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing numPages > 0 ? numPages * pageHeight + (numPages - 1) * PAGE_GAP : 0; const visualTotalHeight = Math.round(totalPagesHeight * cssScale); - // Schedule a high-res re-render after zoom stops changing + // Schedule a high-res re-render after zoom stops changing. 80ms keeps the + // user from sitting on a CSS-bilinear-scaled bitmap for very long while + // still coalescing a wheel burst into one rasterization pass. const scheduleRender = useCallback((newZoom: number) => { if (renderTimerRef.current) clearTimeout(renderTimerRef.current); renderTimerRef.current = setTimeout(() => { setRenderedZoom(newZoom); - }, 200); + }, 80); }, []); // Cleanup timer @@ -136,34 +146,56 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return () => el.removeEventListener("wheel", handleWheel); }, [scheduleRender]); - // Drag panning — works whenever content overflows the container - const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Drag panning. `pointerdown` only records the origin and arms a pending + // drag — pointer capture and `isDragging=true` are deferred until the + // pointer actually moves past a 4 px threshold. A plain click/dblclick + // therefore never enters dragging state, so the mousedown/mouseup/dblclick + // sequence is left untouched and the dblclick handler fires reliably. + const handlePointerDown = useCallback((e: React.PointerEvent) => { const el = scrollRef.current; if (!el) return; if (el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight) return; - setIsDragging(true); + if (e.button !== 0) return; + dragArmedRef.current = { pointerId: e.pointerId, el }; + pointerCapturedRef.current = false; dragStart.current = { x: e.clientX, y: e.clientY, scrollLeft: el.scrollLeft, scrollTop: el.scrollTop, }; - el.setPointerCapture(e.pointerId); }, []); - const handlePointerMove = useCallback((e: React.PointerEvent) => { - if (!isDragging) return; - const el = scrollRef.current; - if (!el) return; - el.scrollLeft = dragStart.current.scrollLeft - (e.clientX - dragStart.current.x); - el.scrollTop = dragStart.current.scrollTop - (e.clientY - dragStart.current.y); - }, [isDragging]); + const handlePointerMove = useCallback((e: React.PointerEvent) => { + const armed = dragArmedRef.current; + if (!armed || armed.pointerId !== e.pointerId) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + if (!pointerCapturedRef.current) { + // Wait until the pointer has actually moved before promoting to a drag. + if (dx * dx + dy * dy < 16) return; + try { + armed.el.setPointerCapture(e.pointerId); + pointerCapturedRef.current = true; + } catch { + // Pointer capture can fail if the element is detached mid-event. + } + setIsDragging(true); + } + armed.el.scrollLeft = dragStart.current.scrollLeft - dx; + armed.el.scrollTop = dragStart.current.scrollTop - dy; + }, []); - const handlePointerUp = useCallback((e: React.PointerEvent) => { - if (!isDragging) return; - setIsDragging(false); - scrollRef.current?.releasePointerCapture(e.pointerId); - }, [isDragging]); + const handlePointerUp = useCallback((e: React.PointerEvent) => { + const armed = dragArmedRef.current; + if (!armed || armed.pointerId !== e.pointerId) return; + if (pointerCapturedRef.current) { + try { armed.el.releasePointerCapture(e.pointerId); } catch { /* noop */ } + pointerCapturedRef.current = false; + setIsDragging(false); + } + dragArmedRef.current = null; + }, []); const pdfData = useMemo(() => { if (!pdfBase64) return null; @@ -207,36 +239,38 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing } }, [pdfBase64]); - // Double-click on PDF → jump to the corresponding word in the editor + // Double-click on PDF → jump to the corresponding word in the editor. + // The source map is built by `query_source_map` after each compile and + // contains one entry per word (`__w`) plus one per block (`__track`). const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - if (!sourceMap.length) return; - - // Find the page element that was clicked (react-pdf sets data-page-number) - const pageEl = (e.target as HTMLElement).closest("[data-page-number]"); + const jumpFn = (window as any).__lextyp_jumpToBlock as + | ((id: string, off: number) => void) + | undefined; + if (!jumpFn) return; + + // react-pdf sets data-page-number on the inner
. + const pageEl = (e.target as HTMLElement | null)?.closest?.( + "[data-page-number]" + ) as HTMLElement | null; if (!pageEl) return; const pageNum = parseInt(pageEl.getAttribute("data-page-number") ?? "0", 10); if (!pageNum) return; - // Get click position relative to the page element - const pageRect = pageEl.getBoundingClientRect(); - const clickXInPage = e.clientX - pageRect.left; - const clickYInPage = e.clientY - pageRect.top; + if (!sourceMap.length) return; - // Convert pixel position to Typst points - // The rendered page width in pixels corresponds to A4_WIDTH_PT + // Click position in points. `getBoundingClientRect` returns the + // visually-transformed rect, which already includes any css zoom scale. + const pageRect = pageEl.getBoundingClientRect(); const pxPerPt = pageRect.width / A4_WIDTH_PT; - const clickYPt = clickYInPage / pxPerPt; - const clickXPt = clickXInPage / pxPerPt; + const clickXPt = (e.clientX - pageRect.left) / pxPerPt; + const clickYPt = (e.clientY - pageRect.top) / pxPerPt; - // Find the nearest source-map marker (word) to the click const target = findNearestEntry(sourceMap, pageNum, clickXPt, clickYPt); if (!target) return; - // Jump to (block, character offset) in editor - const jumpFn = (window as any).__lextyp_jumpToBlock; - if (jumpFn) jumpFn(target.id, target.off); + jumpFn(target.id, target.off); }, [sourceMap] ); @@ -247,14 +281,14 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing
- + {t("pdf.preview")}
@@ -267,33 +301,18 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return (
-
- {compiling ? ( -
-
- -
- - {t("pdf.compiling")} - -
- ) : ( - <> -
- - PDF -
-
- - {t("pdf.willAppear")} - - - {t("pdf.startTyping")} - -
- - )} -
+ {compiling ? ( + } + title={t("pdf.compiling")} + /> + ) : ( + } + title={t("pdf.willAppear")} + description={t("pdf.startTyping")} + /> + )}
); } @@ -325,7 +344,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return (
{/* Toolbar */} -
+
{t("pdf.preview")} @@ -338,18 +357,18 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing
{/* Zoom controls */} -
+
@@ -377,7 +396,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing {/* Collapse */}
diff --git a/src/components/ReferencesPanel.tsx b/src/components/ReferencesPanel.tsx index 22148c2..79d812c 100644 --- a/src/components/ReferencesPanel.tsx +++ b/src/components/ReferencesPanel.tsx @@ -1,10 +1,6 @@ import { useState, useMemo, useCallback } from "react"; -import { BookOpen, Search, ChevronDown, Plus, Pencil, Trash2, Quote, FileUp } from "lucide-react"; -import { - getOrderedStyleNames, - getStyleDescription, - PRIMARY_STYLES, -} from "../lib/citation/registry"; +import { BookOpen, Search, Plus, Pencil, Trash2, Quote, FileUp } from "lucide-react"; +import { EmptyState } from "./EmptyState"; import { formatCitationPreview, formatEntryMeta } from "../lib/citation-search"; import { useT } from "../lib/i18n"; import { useReferenceStore } from "../stores/reference-store"; @@ -18,10 +14,6 @@ interface ReferencesPanelProps { searchQuery: string; setSearchQuery: (q: string) => void; citationStyle: string; - setCitationStyle: (s: string) => void; - styleDropdownOpen: boolean; - setStyleDropdownOpen: (v: boolean) => void; - styleDropdownRef: React.RefObject; formatter: CitationFormatter; activeDocumentPath: string | null; onInsertCitation: (key: string) => void; @@ -65,10 +57,6 @@ export function ReferencesPanel({ searchQuery, setSearchQuery, citationStyle, - setCitationStyle, - styleDropdownOpen, - setStyleDropdownOpen, - styleDropdownRef, formatter, activeDocumentPath, onInsertCitation, @@ -138,15 +126,11 @@ export function ReferencesPanel({ if (!activeDocumentPath) { return ( -
- -

- {t("refs.needDocument")} -

-

- {t("refs.needDocumentHint")} -

-
+ } + title={t("refs.needDocument")} + description={t("refs.needDocumentHint")} + /> ); } @@ -163,9 +147,10 @@ export function ReferencesPanel({ return ( <> - {/* Toolbar: import + add → search → style card. The trio is the - information density at the top of the panel — every action a user - starts with happens here. */} + {/* Toolbar: import + add → search. The citation-style picker that + previously lived here as a big card has moved to its sole home + on the editor toolbar (StyleChip). The current style is shown + inline with the count above the cards below. */}
)} - - {entries.length > 0 && ( -
- - - {styleDropdownOpen && ( -
- {getOrderedStyleNames().map((s, i) => ( -
- {i === PRIMARY_STYLES.length && ( -
- )} - -
- ))} -
- )} -
- )}
{/* List body */}
{entries.length === 0 ? ( -
- -

- {t("refs.noRefs")} -

-

- {t("refs.noRefsHint")} -

-
+ } + title={t("refs.noRefs")} + description={t("refs.noRefsHint")} + className="py-6" + /> ) : ( <> {/* Type filter pills with counts */} @@ -291,13 +217,21 @@ export function ReferencesPanel({
)} - {/* Counter row */} + {/* Combined counter + active-style indicator. The style is + read-only here; users change it via the editor toolbar's + StyleChip (the dropdown there is the canonical picker). */}
{displayedEntries.length} {" / "} {entries.length} {t("refs.references")} + + + + {citationStyle.toUpperCase()} + +
{/* Citation cards */} @@ -371,7 +305,7 @@ export function ReferencesPanel({ className="flex items-center gap-1.5 flex-wrap" onClick={(ev) => ev.stopPropagation()} > - + {t("refs.deleteConfirm")} +
} + icon={} active={activeTab === "files"} title={t("sidebar.files")} onClick={() => { setActiveTab("files"); onToggle(); }} /> } + icon={} active={activeTab === "references"} title={t("sidebar.references")} onClick={() => { setActiveTab("references"); onToggle(); }} />
} + icon={} active={activeTab === "settings"} title={t("settings.title")} onClick={() => { setActiveTab("settings"); onToggle(); }} @@ -202,8 +207,11 @@ export function Sidebar({ style={{ width }} className="h-full bg-[var(--bg-secondary)] flex shrink-0 select-none overflow-hidden" > - {/* Activity bar */} -
+ {/* Activity bar — same surface as the panel content; the divider is + carried by the right-edge `border-r` in --border (one shade darker + than --border-light). 44 px matches the collapsed sidebar so + toggling collapse never widens the column. */} +
} active={activeTab === "files"} @@ -228,22 +236,47 @@ export function Sidebar({ {/* Panel content */}
- {/* Header */} -
-
-
- -
- - {workspaceName || "LexTyp"} - + {/* Header — 36 px tall to match the editor and PDF toolbars; the + workspace name doubles as the trigger for switch/close so the + close-workspace X button (and the standalone footer below) can + both go away. */} +
+
+ {workspacePath ? ( + + ) : ( + + LexTyp + + )} + {showWorkspaceMenu && workspacePath && ( + { + setShowWorkspaceMenu(false); + handleOpenWorkspace(); + }} + onClose={() => { + setShowWorkspaceMenu(false); + closeWorkspace().catch(console.error); + }} + /> + )}
{workspacePath && activeTab === "files" && (
)} - {workspacePath && ( - - )} -
+ } + title={t("sidebar.noWorkspace")} + description={t("sidebar.noWorkspaceHint")} + cta={ + + } + /> ) : activeTab === "files" ? ( - {/* Footer */} -
- {!workspacePath ? ( - - ) : activeTab === "files" ? ( - - ) : null} -
); @@ -374,11 +366,7 @@ function ActivityBarButton({ @@ -444,3 +432,57 @@ function NewMenuDropdown({ document.body ); } + +/* ─── Workspace dropdown — opened from clicking the workspace name in + the header. Houses the actions that previously lived in the sidebar + footer (Switch workspace) and the standalone X button (Close + workspace). ─── */ + +function WorkspaceMenuDropdown({ + anchorRef, + portalRef, + onSwitch, + onClose, +}: { + anchorRef: React.RefObject; + portalRef: React.RefObject; + onSwitch: () => void; + onClose: () => void; +}) { + const t = useT(); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left }); + }, [anchorRef]); + + if (!pos) return null; + + return createPortal( +
+ +
+ +
, + document.body + ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index f98a786..f9a847e 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -57,7 +57,7 @@ export function StatusBar() { }, [setUpdateInfo]); const statusConfig = compiling - ? { icon: , text: t("status.compiling"), color: "text-[var(--accent)]", bg: "bg-[var(--accent-light)]" } + ? { icon: , text: t("status.compiling"), color: "text-[var(--accent-dark)]", bg: "bg-[var(--accent-light)]" } : lastError ? { icon: , text: t("status.error"), color: "text-[var(--error)]", bg: "bg-[var(--error-light)]" } : lastDuration > 0 @@ -67,15 +67,14 @@ export function StatusBar() { const showUpdate = updateInfo?.has_update && !updateDismissed; return ( -
- {/* Left side - Status */} +
+ {/* Left side - Status + rotating tip */}
{statusConfig.icon} {statusConfig.text}
- {/* Tip */}
@@ -101,10 +100,10 @@ export function StatusBar() { target="_blank" rel="noopener noreferrer" title={t("update.available").replace("{version}", updateInfo.latest_version)} - className="flex items-center px-1.5 py-0.5 rounded-full bg-[var(--accent-light)] text-[var(--accent)] hover:opacity-80 transition-opacity" + className="flex items-center px-1.5 py-0.5 rounded-full bg-[var(--accent-light)] text-[var(--accent-dark)] hover:opacity-80 transition-opacity" > - + {t("update.available").replace("{version}", updateInfo.latest_version)} @@ -118,7 +117,7 @@ export function StatusBar() {
)} - + LexTyp v{__APP_VERSION__}
diff --git a/src/index.css b/src/index.css index 2b51baf..4cdf6d6 100644 --- a/src/index.css +++ b/src/index.css @@ -27,10 +27,14 @@ --bg-active: rgba(0, 0, 0, 0.06); --bg-sunken: #F0EEEA; - /* Text — strong hierarchy through opacity */ + /* Text — strong hierarchy through opacity. + Ladder is Stone-900 / 600 / 500 / ~350 — tertiary used to sit at Stone-400 + (#A8A29E) but read as washed-out at chrome icon sizes (~2.5:1 against + panel surfaces). Stone-500 lifts contrast to ~5:1 without crossing into + secondary territory. */ --text-primary: #1C1917; --text-secondary: #57534E; - --text-tertiary: #A8A29E; + --text-tertiary: #78716C; --text-muted: #C4BBB2; --text-inverse: #FAFAF8; @@ -86,10 +90,11 @@ --bg-active: rgba(255, 255, 255, 0.08); --bg-sunken: #131110; - /* Text */ + /* Text — mirrors the light-mode lift on tertiary (one Stone step brighter) + so chrome captions/icons stay legible against the dark panels. */ --text-primary: #F5F1ED; --text-secondary: #A8A29E; - --text-tertiary: #6B6560; + --text-tertiary: #7A736D; --text-muted: #4A453F; --text-inverse: #171412; @@ -127,9 +132,10 @@ --editor-bg: #1A1714; --editor-border: #2E2A26; - /* Scrollbar */ - --scrollbar-thumb: #3D3833; - --scrollbar-thumb-hover: #4A453F; + /* Scrollbar — must stay visibly distinct from --border-hover (#3D3833), + otherwise the thumb disappears whenever it overlaps a panel border. */ + --scrollbar-thumb: #5A544D; + --scrollbar-thumb-hover: #6F6A62; } /* ─── Shared tokens ─── */ @@ -143,8 +149,16 @@ --radius-xl: 12px; --radius-2xl: 16px; - /* Typography scale */ + /* Typography scale. + `--font-serif` is the editor body face. `Newsreader` / `Source Serif 4` + were declared previously but aren't bundled and most installs don't have + them — so users silently fell through to Times, which the PDF preamble + also targets but renders very differently on screen. The cascade below + prefers resident high-quality serifs (Charter on macOS, Cambria on + Windows, Liberation Serif on Linux) before falling back to Times. */ --font-ui: "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --font-serif: "Newsreader", "Iowan Old Style", "Charter", "Bitstream Charter", + Cambria, "Liberation Serif", "Times New Roman", Times, Georgia, serif; --font-editor: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; } @@ -172,6 +186,51 @@ textarea { color: inherit; } +/* Lucide icons ship with stroke-width: 2, which at 11–14 px renders as + visually heavy double-stroke smudges in chrome that uses tertiary-grey + colors. 1.5 is Lucide's recommended UI weight and stays sharp at small + sizes. The CSS property overrides the inline SVG attribute. */ +.lucide { + stroke-width: 1.5; +} + +/* ─── Pane resize handle ─────────────────────────────────────────────── + The visual handle is a 1 px center divider in `--border`, but the + element itself is 8 px wide so it gives a comfortable hit-target. + Hover/active reveal the accent. The handle reads as the panel border + it sits on, not as a separate stripe. */ +.pane-resize-handle { + flex-shrink: 0; + position: relative; + width: 8px; + cursor: col-resize; + background: transparent; + transition: background-color var(--transition-fast); +} + +.pane-resize-handle::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: var(--border); + transform: translateX(-0.5px); + transition: background-color var(--transition-fast), width var(--transition-fast); +} + +.pane-resize-handle:hover::before, +.pane-resize-handle.is-active::before { + background: var(--accent); + width: 2px; + transform: translateX(-1px); +} + +.pane-resize-handle.is-active { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + /* ═══════════════════════════════════════ BlockNote Editor Overrides ═══════════════════════════════════════ */ @@ -181,7 +240,7 @@ textarea { serif do the work — what the writer sees is what compiles, modulo the editor's max content width. */ .bn-editor { - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", "Times New Roman", Times, Georgia, serif; + font-family: var(--font-serif); font-size: 16px; line-height: 1.6; color: var(--text-primary); @@ -516,8 +575,10 @@ textarea { border-radius: var(--radius-md); } -/* Icon button — square, transparent, 28×28. Distinct from .btn family - because it carries no text and lives in toolbars/headers. */ +/* Icon button — square, transparent, 28×28 by default. Distinct from + the .btn family because it carries no text and lives in toolbars, + panel headers, and activity bars. Compose with `.icon-btn-sm` for + the dense 24 px header variant or `.is-active` for selected state. */ .icon-btn { display: inline-flex; align-items: center; @@ -525,10 +586,16 @@ textarea { width: 28px; height: 28px; border-radius: var(--radius-md); - color: var(--text-tertiary); + background: transparent; + /* Resting color is `--text-secondary`, not tertiary. Tertiary is the + "decoration" tier (status dot, tip lightbulb, file-tree chevrons); + icon-btn is the actionable tier and needs a stronger weight so the + row doesn't read as a wash of pale strokes. */ + color: var(--text-secondary); transition: background-color var(--transition-fast), color var(--transition-fast); cursor: pointer; + flex-shrink: 0; } .icon-btn:hover { @@ -540,6 +607,28 @@ textarea { background: var(--bg-active); } +.icon-btn:disabled, +.icon-btn[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +/* Selected variant — used by the activity bar's current tab and by any + other "this is on" affordance that lives in a chrome surface. */ +.icon-btn.is-active, +.icon-btn.is-active:hover { + background: var(--accent-light); + color: var(--accent-dark); +} + +/* Smaller variant for dense headers (sidebar header, PDF toolbar). 24 px + matches the Mac OS toolbar baseline and gives a tighter row. */ +.icon-btn-sm { + width: 24px; + height: 24px; + border-radius: var(--radius-sm); +} + /* ═══════════════════════════════════════ Cards & Inputs ═══════════════════════════════════════ */ @@ -581,10 +670,10 @@ textarea { } .panel-section-label { - font-size: 10px; + font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-secondary); } @@ -716,9 +805,9 @@ textarea { .slash-menu-group { padding: 8px 10px 4px; - font-size: 10px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); } @@ -767,10 +856,10 @@ textarea { display: inline-flex; align-items: center; padding: 2px 8px; - font-size: 10px; + font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; border-radius: var(--radius-sm); background: var(--bg-tertiary); color: var(--text-secondary); @@ -806,8 +895,8 @@ textarea { display: flex; align-items: center; gap: 8px; - height: 30px; - padding: 0 6px 0 9px; + height: 32px; + padding: 0 8px 0 10px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -846,85 +935,6 @@ textarea { color: var(--text-tertiary); } -/* Style card — the dominant control under the toolbar. Click expands the - dropdown of available styles in the same surface. */ -.ref-style-card { - width: 100%; - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - text-align: left; - cursor: pointer; - transition: border-color var(--transition-fast), - background-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.ref-style-card:hover { - border-color: var(--border-hover); - background: var(--bg-hover); -} - -.ref-style-card.is-open { - border-color: var(--accent); - background: var(--bg-elevated); - box-shadow: var(--shadow-focus); -} - -.ref-style-icon { - flex-shrink: 0; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - background: var(--accent-light); - color: var(--accent); -} - -.ref-style-meta { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.ref-style-name { - font-family: var(--font-ui); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.06em; - color: var(--text-primary); - line-height: 1.15; -} - -.ref-style-desc { - font-family: var(--font-ui); - font-size: 10.5px; - color: var(--text-tertiary); - line-height: 1.3; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ref-style-chevron { - flex-shrink: 0; - color: var(--text-tertiary); - transition: transform var(--transition-fast), color var(--transition-fast); -} - -.ref-style-card.is-open .ref-style-chevron { - transform: rotate(180deg); - color: var(--accent); -} - /* Type pills — segmented filter under the search. Each carries a count that shrinks slightly inside the active pill to keep the label dominant. */ .ref-pill { @@ -960,9 +970,10 @@ textarea { } .ref-pill-count { - font-size: 10px; + font-size: 11px; font-weight: 600; color: var(--text-tertiary); + font-variant-numeric: tabular-nums; } .ref-pill:hover .ref-pill-count { @@ -1014,15 +1025,15 @@ textarea { flex-shrink: 0; display: inline-flex; align-items: center; - height: 16px; + height: 18px; padding: 0 6px; border-radius: var(--radius-sm); background: var(--bg-tertiary); color: var(--text-secondary); font-family: var(--font-ui); - font-size: 9px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.04em; text-transform: uppercase; line-height: 1; } @@ -1036,7 +1047,7 @@ textarea { flex: 1; min-width: 0; font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; @@ -1044,8 +1055,7 @@ textarea { } .ref-card-title { - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", - "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 13px; font-weight: 500; line-height: 1.35; @@ -1060,7 +1070,7 @@ textarea { .ref-card-source { margin-top: 3px; font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; @@ -1074,7 +1084,7 @@ textarea { .ref-card-preview { margin-top: 8px; - font-family: "Newsreader", "Source Serif 4", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-style: italic; font-size: 11.5px; line-height: 1.55; @@ -1091,7 +1101,7 @@ textarea { .ref-card-field { display: flex; gap: 8px; - font-size: 10.5px; + font-size: 11px; line-height: 1.4; } @@ -1111,7 +1121,7 @@ textarea { .ref-card-field-value.is-mono { font-family: var(--font-mono); - font-size: 10px; + font-size: 10.5px; } .ref-card-actions { @@ -1132,7 +1142,7 @@ textarea { padding: 0 4px; margin-bottom: 6px; font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; color: var(--text-tertiary); } @@ -1356,13 +1366,13 @@ textarea { no matter the label length (BOOK / ARTICLE / STATUTE …). */ width: 64px; margin-top: 2px; - padding: 3px 0; + padding: 4px 0; border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); border-radius: var(--radius-sm); background: var(--accent-light); - font-size: 9px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.04em; line-height: 1; color: var(--accent-dark); text-transform: uppercase; @@ -1377,7 +1387,7 @@ textarea { .palette-title { display: block; - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 14px; line-height: 1.3; color: var(--text-primary); @@ -1407,7 +1417,7 @@ textarea { } .palette-empty-title { - font-family: "Newsreader", "Source Serif 4", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 14px; color: var(--text-secondary); margin-bottom: 4px; @@ -1421,7 +1431,7 @@ textarea { padding: 8px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border-light); - font-size: 10px; + font-size: 11px; color: var(--text-tertiary); } @@ -1435,15 +1445,15 @@ textarea { display: inline-flex; align-items: center; justify-content: center; - min-width: 16px; - height: 17px; + min-width: 18px; + height: 18px; padding: 0 5px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 3px; box-shadow: 0 1px 0 var(--border-hover); font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; line-height: 1; color: var(--text-secondary); } @@ -1458,7 +1468,7 @@ textarea { .editor-toolbar { flex-shrink: 0; - height: 44px; + height: 36px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-light); } @@ -1483,7 +1493,7 @@ textarea { .editor-toolbar-divider { width: 1px; height: 18px; - background: var(--border); + background: var(--border-hover); flex-shrink: 0; } @@ -1491,8 +1501,8 @@ textarea { display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 26px; + height: 26px; border-radius: var(--radius-md); color: var(--text-secondary); background: transparent; @@ -1507,7 +1517,7 @@ textarea { } .editor-toolbar-btn-text { - width: 28px; + width: 26px; padding: 0; font-family: var(--font-ui); font-size: 11px; @@ -1524,7 +1534,7 @@ textarea { display: inline-flex; align-items: center; gap: 6px; - height: 26px; + height: 24px; padding: 0 6px 0 9px; border-radius: var(--radius-md); background: transparent; @@ -1567,19 +1577,19 @@ textarea { .editor-toolbar-style-name { font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-primary); line-height: 1; } .editor-toolbar-style-edn { font-family: var(--font-ui); - font-size: 10px; + font-size: 11px; font-weight: 500; color: var(--text-tertiary); - letter-spacing: 0.01em; + letter-spacing: 0; line-height: 1; white-space: nowrap; } @@ -1629,7 +1639,7 @@ textarea { font-size: 11px; font-weight: 600; background-color: var(--accent-light); - color: var(--accent); + color: var(--accent-dark); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); transition: background-color var(--transition-fast), border-color var(--transition-fast); } @@ -1710,13 +1720,13 @@ textarea { font-family: var(--font-mono); font-size: 11px; font-weight: 600; - color: var(--accent); + color: var(--accent-dark); } .lextyp-citation-card-type { - font-size: 9.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); } @@ -1766,9 +1776,9 @@ textarea { } .lextyp-citation-card-field-name { - font-size: 10px; + font-size: 11px; font-weight: 600; - letter-spacing: 0.04em; + letter-spacing: 0.02em; text-transform: uppercase; color: var(--text-tertiary); align-self: start; @@ -1797,7 +1807,9 @@ textarea { container-type: inline-size; position: relative; width: 100%; - max-width: 640px; + /* Match the editor column max so the cover page never floats inside its + own container looking un-centered against the body text below. */ + max-width: 100%; margin: 12px auto; padding: clamp(20px, 5cqi, 36px) clamp(20px, 5cqi, 40px) clamp(24px, 6cqi, 44px); background: var(--bg-elevated); @@ -1828,9 +1840,9 @@ textarea { } .cover-page-tag { - font-size: 9.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-tertiary); text-transform: uppercase; } @@ -1841,7 +1853,7 @@ textarea { gap: 4px; padding: 3px 8px; border-radius: 999px; - font-size: 10.5px; + font-size: 11px; font-weight: 500; color: var(--text-tertiary); background: var(--bg-tertiary); @@ -1867,7 +1879,7 @@ textarea { } .cover-preview-title { - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(20px, 6.5cqi, 30px); font-weight: 700; letter-spacing: -0.01em; @@ -1876,7 +1888,7 @@ textarea { } .cover-preview-subtitle { - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(13px, 3.5cqi, 17px); font-style: italic; color: var(--text-secondary); @@ -1890,7 +1902,7 @@ textarea { justify-content: center; align-items: baseline; gap: 4px 8px; - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(12px, 2.8cqi, 14px); color: var(--text-secondary); } @@ -2005,7 +2017,7 @@ textarea { background: var(--accent); color: var(--text-inverse); border-radius: var(--radius-md); - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: 18px; font-weight: 700; letter-spacing: -0.02em; @@ -2019,9 +2031,9 @@ textarea { } .cover-dialog-tag { - font-size: 10px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-tertiary); text-transform: uppercase; } @@ -2106,13 +2118,13 @@ textarea { } .cover-form-hint { - font-size: 11px; + font-size: 11.5px; color: var(--text-tertiary); font-style: italic; } .cover-form-input { - height: 34px; + height: 32px; font-size: 13px; } @@ -2149,7 +2161,7 @@ textarea { align-items: center; gap: 6px; padding: 0 10px; - height: 34px; + height: 32px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -2212,7 +2224,7 @@ textarea { display: inline-flex; align-items: center; gap: 5px; - height: 30px; + height: 32px; padding: 0 11px; background: var(--bg-tertiary); border: 1px solid transparent; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index b6ed88d..465243a 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -110,7 +110,7 @@ const translations = { // Status bar tips "tip.slash": "Type / to open the slash menu and insert headings, lists, or citations", - "tip.citationStyle": "Switch citation styles anytime from the chip in the status bar", + "tip.citationStyle": "Switch citation styles anytime from the chip in the editor toolbar", "tip.pdfDoubleClick": "Double-click the PDF preview to jump to that paragraph in the editor", "tip.collapsePanel": "Collapse the sidebar or PDF panel for distraction-free writing", "tip.outline": "Use the outline button at the bottom-left to navigate long documents", @@ -282,7 +282,7 @@ const translations = { // Status bar tips "tip.slash": "输入 / 打开斜杠菜单,插入标题、列表或引用", - "tip.citationStyle": "可在状态栏的引注样式标签随时切换", + "tip.citationStyle": "可在编辑器工具栏的引注样式标签随时切换", "tip.pdfDoubleClick": "双击 PDF 预览可跳转到编辑器中的对应段落", "tip.collapsePanel": "收起侧边栏或 PDF 面板,获得无干扰的写作体验", "tip.outline": "使用左下角的大纲按钮浏览长文档",