diff --git a/AGENTS.md b/AGENTS.md index 16cb796..2d9f7f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ instead of duplicating it. - `src/sections/editor/` — the editable on-page resume (sections, entries, bullets, skills, drag-reorder via `@dnd-kit`). - `src/sections/` — read-only document + off-screen print layer. - - `src/components/` — reusable `Modal` shell, `ImportModal`, and `SectionNav`. + - `src/components/` — reusable `Modal` shell and `ImportModal`. - `src/styles/` — `tokens.css`/`base.css` (design tokens), `resume-*.css` (document + editor), `index.css` (imports + app shell). - `server/latex/` — Jake's template renderer, plain-text/LaTeX parser, Tectonic diff --git a/README.md b/README.md index 5d561ec..66c737e 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,16 @@ tailoring, job tracker, and applications stripped out. - **Faithful Jake's styling** — serif document, ruled section headings, bold titles with right-aligned dates, italic subtitles, automatic multi-page pagination that keeps headings with their first entry. -- **Layout & spacing controls** — page zoom (also ⌘/Ctrl +/- / 0), - Compact/Normal/Relaxed presets, and fine sliders for line height and - header/section/entry/list gaps tucked behind a "Fine-tune spacing" disclosure. - Save the current spacing as a reusable **Custom** preset. Settings persist to +- **Layout & spacing controls** — page zoom (also ⌘/Ctrl +/- / 0) and + Compact/Normal/Relaxed presets. A **Fine-tune** button opens a spacing flyout + with sliders for line height and header/section/entry/list gaps; save the + current spacing as a reusable **Custom** preset. Settings persist to `localStorage`. -- **Typography controls** — grouped by element (Headings / Entries / Skills / - Contact): section-heading **case** (small caps / uppercase / normal) plus bold - and underline; bold titles, italic subtitles; bold skill labels; and a - configurable contact divider (quick-pick glyphs or custom 1-2 char input). - One-click reset to Jake's defaults. +- **Typography controls** — collapsible **Typography** / **Entries** / **Skills** + cards: section-heading **case** (small caps / uppercase / normal) plus bold and + underline, and a configurable contact divider (quick-pick glyphs or custom + 1-2 char input) under Typography; bold titles and italic subtitles under + Entries; bold skill labels under Skills. One-click reset to Jake's defaults. - **Exports** (each download opens a rename dialog pre-filled with a resume-derived file name) - **PDF - LaTeX** — the resume rendered through the Jake's template and compiled @@ -161,7 +161,7 @@ setup above). The workflow uses plain `docker` when available, or passwordless src/ lib/ resume data model + parse/serialize/LaTeX-extract helpers hooks/ useResumeEditor · useDocStyle · useTemplates · useResumeExport - components/ reusable Modal shell, ImportModal, and SectionNav + components/ reusable Modal shell and ImportModal sections/ editor/ the editable on-page resume (sections, entries, bullets, skills) Resume*.tsx read-only document + off-screen print layer diff --git a/src/App.tsx b/src/App.tsx index bb3ca73..88d3916 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react"; -import { ChevronRight, Eye, FileCode2, FileDown, RotateCcw, Upload } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react"; +import { ChevronRight, Eye, FileCode2, FileDown, RotateCcw, SlidersHorizontal, Upload, X } from "lucide-react"; import { useResumeEditor } from "./hooks/useResumeEditor"; import { useDocStyle, JAKE_STYLE_DEFAULTS, DOC_ZOOM_OPTIONS, DOC_SPACING_PRESETS, DOC_SPACING_KEYS, type DocSpacingKey, type DocSpacingPreset, type HeadingCase } from "./hooks/useDocStyle"; @@ -8,14 +8,19 @@ import { useResumeExport } from "./hooks/useResumeExport"; import { ResumeEditor } from "./sections/editor/ResumeEditor"; import { ResumePrintLayer } from "./sections/ResumePrintLayer"; import { Modal } from "./components/Modal"; -import { SectionNav } from "./components/SectionNav"; import { buildStarterResume, reidResume } from "./sampleResume"; import { fileToText } from "./lib/importResume"; import type { ResumeData } from "./lib/resumeData"; const STORAGE_KEY = "jakeforge.resume.v1"; -// Remembers whether the spacing-sliders disclosure is expanded. -const FINE_TUNE_KEY = "jakeforge.ui.fineTune.v1"; +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" +].join(","); function loadSavedResume(): ResumeData | null { try { @@ -27,6 +32,12 @@ function loadSavedResume(): ResumeData | null { } } +function visibleFocusableElements(container: HTMLElement) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0 + ); +} + // Fine-grained layout/spacing controls, grouped by where on the page they act so // the (otherwise long) list stays scannable. Every spacing field in DocStyle is // exposed here; ranges mirror role-fit-ai's Format menu. Page font size and @@ -196,33 +207,49 @@ function ToggleChip({ ); } +// Sidebar accordion card — an uppercase title row that toggles its body open. +// Groups the typography controls so the sidebar reads as a short list of +// sections a user expands one at a time. +function AccordionCard({ + title, + isOpen, + onToggle, + children +}: { + title: string; + isOpen: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {isOpen ?
{children}
: null} +
+ ); +} + export default function App() { const editor = useResumeEditor(); const docStyle = useDocStyle(); const templates = useTemplates(); const [texStatus, setTexStatus] = useState(""); - const canvasRef = useRef(null); + const fineTuneTriggerRef = useRef(null); + const fineTuneDialogRef = useRef(null); - // The 11 fine-grained spacing sliders live behind a disclosure — most people - // pick a preset and never open them. Closed by default; choice is remembered. - const [showFineTune, setShowFineTune] = useState(() => { - try { - return window.localStorage.getItem(FINE_TUNE_KEY) === "1"; - } catch { - return false; - } - }); - function toggleFineTune() { - setShowFineTune((open) => { - const next = !open; - try { - window.localStorage.setItem(FINE_TUNE_KEY, next ? "1" : "0"); - } catch { - // Storage unavailable (private mode); the toggle still works this session. - } - return next; - }); - } + // The 11 fine-grained spacing sliders live in a floating flyout — most people + // pick a preset and never open it. Session-only (a flyout that reopened over + // the sidebar on every reload would be more nuisance than convenience). + const [showFineTune, setShowFineTune] = useState(false); + + // Typography controls are grouped into collapsible cards, each independently + // openable; all closed by default so the sidebar stays short. + const [openCards, setOpenCards] = useState({ typography: false, entries: false, skills: false }); + const toggleCard = (key: keyof typeof openCards) => + setOpenCards((cards) => ({ ...cards, [key]: !cards[key] })); const selectedTemplate = templates.templates.find((t) => t.id === templates.selectedTemplateId) ?? null; @@ -306,6 +333,61 @@ export default function App() { }, [exporter]); + // Keep keyboard focus inside the spacing flyout while it is open. It is modal + // for pointer users too: click outside closes it and focus returns to the trigger. + useEffect(() => { + if (!showFineTune) return; + const dialog = fineTuneDialogRef.current; + if (!dialog) return; + const dialogEl = dialog; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusTimer = window.setTimeout(() => { + const first = visibleFocusableElements(dialogEl)[0]; + (first ?? dialogEl).focus(); + }, 0); + + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + setShowFineTune(false); + return; + } + if (event.key !== "Tab") return; + + const focusable = visibleFocusableElements(dialogEl); + if (!focusable.length) { + event.preventDefault(); + dialogEl.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (!dialogEl.contains(active)) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", onKey); + return () => { + window.clearTimeout(focusTimer); + document.removeEventListener("keydown", onKey); + if (previouslyFocused && document.contains(previouslyFocused)) { + previouslyFocused.focus(); + } else { + fineTuneTriggerRef.current?.focus(); + } + }; + }, [showFineTune]); + function seedFromText(text: string) { editor.seed(text); setTexStatus(""); @@ -402,8 +484,6 @@ export default function App() { - -
@@ -439,7 +519,7 @@ export default function App() { title={tectonicOff ? "Install Tectonic (brew install tectonic) for LaTeX PDF" : "Download PDF (Tectonic)"} >