diff --git a/public/fonts/LMRoman10-Bold.woff2 b/public/fonts/LMRoman10-Bold.woff2 new file mode 100644 index 0000000..0bb2c6c Binary files /dev/null and b/public/fonts/LMRoman10-Bold.woff2 differ diff --git a/public/fonts/LMRoman10-BoldItalic.woff2 b/public/fonts/LMRoman10-BoldItalic.woff2 new file mode 100644 index 0000000..54c956b Binary files /dev/null and b/public/fonts/LMRoman10-BoldItalic.woff2 differ diff --git a/public/fonts/LMRoman10-Italic.woff2 b/public/fonts/LMRoman10-Italic.woff2 new file mode 100644 index 0000000..8b3332e Binary files /dev/null and b/public/fonts/LMRoman10-Italic.woff2 differ diff --git a/public/fonts/LMRoman10-Regular.woff2 b/public/fonts/LMRoman10-Regular.woff2 new file mode 100644 index 0000000..ab8fed7 Binary files /dev/null and b/public/fonts/LMRoman10-Regular.woff2 differ diff --git a/server/latex/templates/jakes.mjs b/server/latex/templates/jakes.mjs index 751210a..749d158 100644 --- a/server/latex/templates/jakes.mjs +++ b/server/latex/templates/jakes.mjs @@ -4,25 +4,49 @@ import { escapeTex, escapeTexUrl, titleCase, linkify, splitLinkSegments, isSummarySection } from "../util.mjs"; -// Map the editor's DocStyle (em values, 11pt base font) → LaTeX pt overrides -// for the four template knobs. Mirrors the CSS variables consumed by the HTML -// editor so the on-screen page and the compiled PDF share rhythm. +// Map the editor's DocStyle (em values, 11pt base font) → LaTeX pt overrides. +// Mirrors the CSS variables consumed by the HTML editor so the on-screen page +// and the compiled PDF share rhythm. // -// Jake's original hardcoded values, restored as the FALLBACK so a request -// without docStyle compiles identically to the upstream template. +// SPACING MODEL — every gap is a pure SEPARATOR between siblings, never a +// trailing margin on the last one. Sections, entries, bullets, and skill rows +// each own exactly one gap that sits *between* consecutive items, so the last +// bullet/entry adds nothing on top of the enclosing entry/section gap: +// - sectionGap → \titlespacing "before" (gap above each section heading) +// - sectionEntryGap → \titlespacing "after" (rule → first entry/row) +// - entryGap → \resumeEntryGap, inserted only BETWEEN entries +// - headBulletGap → inner bullet list \topsep (entry head → first bullet) +// - bulletGap → inner bullet list \itemsep (between bullets only) +// - skillsRowGap → skills list \itemsep (between skill rows only) +// The lists set topsep/parsep/partopsep to 0 (except the bullet list's topsep, +// which IS headBulletGap) so no implicit list glue leaks into a parent gap. +// NB: \\[len] is unreliable here — the template's global \raggedright drops the +// optional length — so row spacing uses \itemsep, which survives \raggedright. +// +// Jake's original look, restored as the FALLBACK so a request without docStyle +// compiles close to the upstream template. const DEFAULTS = { - // \vspace BEFORE the section's titlerule (negative pulls section up) - sectionPreVSpacePt: -4, - // \vspace AFTER the titlerule (pulls first content up) - sectionPostVSpacePt: -5, - // \vspace after each \resumeSubheading row - subheadingTrailVSpacePt: -7, - // \vspace after each \resumeItem bullet - itemTrailVSpacePt: -2, - // \vspace after \resumeItemListEnd - itemListEndVSpacePt: -5, - // Extra space on the `\\` break between skill lines (0 = upstream — plain `\\`). - skillLineGapPt: 0, + usesDocStyle: false, + // \vspace between the name row and contact row + nameContactVSpacePt: 1, + // Header contact separator, preserving the previous no-docStyle template text. + contactSeparatorTex: " $|$ ", + // \vspace after the header, before the first section + headerSectionVSpacePt: null, + // \titlespacing before/after the section heading + sectionGapPt: 9.5, + sectionEntryGapPt: 4.6, + // \resumeEntryGap between entries; null preserves the previous no-docStyle output + entryGapPt: null, + // \vspace between the title row and subtitle row (clean tight-lines baseline; + // see styleToLatex — the old -7 collided the two rows) + titleSubVSpacePt: -4.5, + // inner bullet list \topsep (entry head → first bullet) + headBulletPt: 4.6, + // skills list \itemsep (between skill rows) + skillsRowGapPt: 0, + // inner bullet list \itemsep (between bullets) + bulletGapPt: 2.2, // \baselinestretch (Jake's default is implicit 1.0 ≈ 1.2x leading) baselineStretch: 1.0, // Bold/italic toggles — Jake's upstream defaults @@ -31,54 +55,89 @@ const DEFAULTS = { boldSkillLabels: true, italicSubtitles: true, italicDates: true, - // Section-heading look — Jake's upstream is small-caps (\scshape) + a titlerule. - uppercaseHeadings: true, - sectionRule: true, - // LaTeX-ready contact separator (upstream is $|$). - contactDivider: "$|$" + // Section-heading look — small-caps (\scshape) is the default; the Uppercase + // toggle forces ALL CAPS, and the rule under the heading is the titlerule. + uppercaseHeadings: false, + sectionRule: true +}; + +const DOC_EM = { + nameContactGap: 0.04, + contactGap: 1.82, + headerSectionGap: 1.19, + sectionGap: 0.85, + sectionEntryGap: 0.42, + entryGap: 0.42, + titleSubGap: 0.06, + headBulletGap: 0.42, + skillsRowGap: 0, + bulletGap: 0.2 }; // Map a user-typed 1–2 char divider to safe LaTeX. Known glyphs that need math -// mode or commands are mapped; everything else is plain-escaped. +// mode or commands are mapped; everything else is plain-escaped. A blank divider +// collapses to a thin space so contact items still separate. function latexDivider(div) { const d = (typeof div === "string" ? div : "|").trim(); - if (!d) return "\\;"; // blank → just a thin space between items + if (!d) return "\\;"; const map = { "|": "$|$", "•": "$\\bullet$", "·": "$\\cdot$", "∙": "$\\cdot$", "–": "--", "—": "---" }; return map[d] ?? escapeTex(d); } -// 11pt base font: 1em = 11pt. The HTML editor's gap values are in ems, so we -// convert via *11 and then subtract Jake's natural rhythm to land at the same -// visual cadence. We keep the math simple — the user-tunable Format menu only -// has 4 knobs that affect the PDF; the rest stay at Jake's defaults. +// 11pt base font: 1em = 11pt for the main flow. Contact and skills rows are +// \small (10pt), so their own row-local gaps convert via 10pt/em. Each gap maps +// directly and monotonically to one LaTeX length, with no negative-glue hacks. function styleToLatex(docStyle) { if (!docStyle || typeof docStyle !== "object") return DEFAULTS; const num = (v, fallback) => (typeof v === "number" && Number.isFinite(v) ? v : fallback); - const sectionGapEm = num(docStyle.sectionGap, 0.85); - const entryGapEm = num(docStyle.entryGap, 0.42); - const bulletGapEm = num(docStyle.bulletGap, 0.2); + const nameContactGapEm = num(docStyle.nameContactGap, DOC_EM.nameContactGap); + const contactGapEm = num(docStyle.contactGap, DOC_EM.contactGap); + const headerSectionGapEm = num(docStyle.headerSectionGap, DOC_EM.headerSectionGap); + const sectionGapEm = num(docStyle.sectionGap, DOC_EM.sectionGap); + const sectionEntryGapEm = num(docStyle.sectionEntryGap, DOC_EM.sectionEntryGap); + const entryGapEm = num(docStyle.entryGap, DOC_EM.entryGap); + const titleSubGapEm = num(docStyle.titleSubGap, DOC_EM.titleSubGap); + const headBulletGapEm = num(docStyle.headBulletGap, DOC_EM.headBulletGap); + const skillsRowGapEm = num(docStyle.skillsRowGap, DOC_EM.skillsRowGap); + const bulletGapEm = num(docStyle.bulletGap, DOC_EM.bulletGap); const lineHeight = num(docStyle.lineHeight, 1.18); - // \section already has a natural ~1.2em parskip; subtract that so sectionGap - // measures total gap, not extra-on-top. - const sectionPrePt = Math.round((sectionGapEm - 1.2) * 11); - // Entry trail: tighter (negative) when entryGap < 0.65em (Jake's effective). - const subheadingTrailPt = Math.round((entryGapEm - 0.65) * 11); - // Bullet trail: itemize's natural item separation is ~0.15em — anything below - // that goes negative. - const itemTrailPt = Math.round((bulletGapEm - 0.18) * 11); - // Skill lines break with `\\` (no natural item separation), so the full - // bulletGap becomes the extra space — mirrors the editor's per-skill-row gap. - const skillLineGapPt = Math.max(0, Math.round(bulletGapEm * 11)); + // Section gap is the WHOLE gap above the heading; \titlespacing "before" + // measures exactly that (predecessor adds no trailing glue in this model). + const sectionGapPt = sectionGapEm * 11; + // Rule → first entry/row = \titlespacing "after". + const sectionEntryGapPt = sectionEntryGapEm * 11; + // Between-entry separator. + const entryGapPt = entryGapEm * 11; + // Entry head → first bullet = inner list \topsep. + const headBulletPt = headBulletGapEm * 11; + // Between-bullet separator = inner list \itemsep. + const bulletGapPt = bulletGapEm * 11; + // The header → first section vspace must absorb the first section's own + // \titlespacing "before" (= sectionGap) plus the center block's natural + // bottom (~0.34em), so the visible first gap equals headerSectionGap. + const headerSectionVSpacePt = (headerSectionGapEm - sectionGapEm - 0.34) * 11; + // The two subheading rows are \par-stacked, so a -4.5pt baseline pulls them to + // clean tight consecutive lines (below ~-5pt they collide); each em then adds + // 11pt of air on top, matching the editor's title→subtitle margin. Measured + // against compiled PDFs. + const titleSubPt = DEFAULTS.titleSubVSpacePt + titleSubGapEm * 11; // CSS line-height 1.0 ≈ LaTeX \baselinestretch 0.83; CSS 1.2 ≈ stretch 1.0. const baselineStretch = lineHeight / 1.2; const bool = (v, fallback) => (typeof v === "boolean" ? v : fallback); return { - sectionPreVSpacePt: sectionPrePt, - sectionPostVSpacePt: DEFAULTS.sectionPostVSpacePt, - subheadingTrailVSpacePt: subheadingTrailPt, - itemTrailVSpacePt: itemTrailPt, - itemListEndVSpacePt: DEFAULTS.itemListEndVSpacePt, - skillLineGapPt, + usesDocStyle: true, + nameContactVSpacePt: DEFAULTS.nameContactVSpacePt + (nameContactGapEm - DOC_EM.nameContactGap) * 10, + // The contact separator is a fixed-width box (from contactGap) holding the + // user's chosen divider glyph — mirrors the editor's --doc-contact-gap. + contactSeparatorTex: `\\makebox[${fmtPt(contactGapEm * 10)}][c]{${latexDivider(docStyle.contactDivider)}}`, + headerSectionVSpacePt, + sectionGapPt, + sectionEntryGapPt, + entryGapPt, + titleSubVSpacePt: titleSubPt, + headBulletPt, + skillsRowGapPt: skillsRowGapEm * 10, + bulletGapPt, baselineStretch, boldHeadings: bool(docStyle.boldHeadings, DEFAULTS.boldHeadings), boldTitles: bool(docStyle.boldTitles, DEFAULTS.boldTitles), @@ -86,8 +145,7 @@ function styleToLatex(docStyle) { italicSubtitles: bool(docStyle.italicSubtitles, DEFAULTS.italicSubtitles), italicDates: bool(docStyle.italicDates, DEFAULTS.italicDates), uppercaseHeadings: bool(docStyle.uppercaseHeadings, DEFAULTS.uppercaseHeadings), - sectionRule: bool(docStyle.sectionRule, DEFAULTS.sectionRule), - contactDivider: latexDivider(docStyle.contactDivider) + sectionRule: bool(docStyle.sectionRule, DEFAULTS.sectionRule) }; } @@ -102,11 +160,31 @@ function it(arg, on) { } function fmtPt(value) { - // LaTeX accepts negative pt directly. Round to integer pts for stable output. - return `${value}pt`; + // LaTeX accepts negative and decimal pt values directly. Keep one decimal for + // the small-granularity header/contact controls, but emit integers when exact. + const rounded = Math.round(value * 10) / 10; + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}pt`; } function buildPreamble(style) { + const entryGapMacro = + style.entryGapPt === null + ? "" + : `\n\\newcommand{\\resumeEntryGap}{\\vspace{${fmtPt(style.entryGapPt)}}}`; + + // Outer (entry) list: in docStyle mode all implicit list glue is zeroed so the + // ONLY inter-entry gap is \resumeEntryGap; the fallback keeps Jake's natural + // list spacing. + const subListOpts = style.usesDocStyle + ? "[leftmargin=0.15in, label={}, topsep=0pt, partopsep=0pt, parsep=0pt, itemsep=0pt]" + : "[leftmargin=0.15in, label={}]"; + + // Inner (bullet) list: \itemsep is the sole between-bullet gap; topsep/parsep + // are zeroed so the last bullet adds nothing and the head→bullet gap (an + // explicit \vspace emitted by the renderer) and the entry gap fully own the + // spacing around the list. + const itemListOpts = `[topsep=0pt, partopsep=0pt, parsep=0pt, itemsep=${fmtPt(style.bulletGapPt)}]`; + return String.raw`\documentclass[letterpaper,11pt]{article} \usepackage[T1]{fontenc} @@ -146,11 +224,17 @@ function buildPreamble(style) { \raggedright \setlength{\tabcolsep}{0in} -\titleformat{\section}{\vspace{${fmtPt(style.sectionPreVSpacePt)}}${style.uppercaseHeadings ? "\\scshape" : ""}\raggedright\large${style.boldHeadings ? "\\bfseries" : ""}}{}{0em}{}[\color{black}${style.sectionRule ? "\\titlerule " : ""}\vspace{${fmtPt(style.sectionPostVSpacePt)}}] +% Section gaps are pure separators: \titlespacing "before" is the WHOLE gap +% above the heading (= sectionGap), "after" is the rule→first-row gap +% (= sectionEntryGap). titlesec drops the before-glue at the top of a page, so +% the first section's gap is set by the header \vspace instead. \scshape gives +% small-caps; the Uppercase toggle uppercases the heading text in renderSection. +\titleformat{\section}{\scshape\raggedright\large${style.boldHeadings ? "\\bfseries" : ""}}{}{0em}{}[\color{black}${style.sectionRule ? "\\titlerule" : ""}] +\titlespacing*{\section}{0pt}{${fmtPt(style.sectionGapPt)}}{${fmtPt(style.sectionEntryGapPt)}} \ifdefined\pdfgentounicode\pdfgentounicode=1\fi -\newcommand{\resumeItem}[1]{\item\small{{#1 \vspace{${fmtPt(style.itemTrailVSpacePt)}}}}} +\newcommand{\resumeItem}[1]{\item\small{#1}} % The right column is a right-aligned tabularx X cell (not a single-line r): it % claims the space left of the title and WRAPS, so a long link — or two links @@ -166,32 +250,34 @@ function buildPreamble(style) { \newcommand{\resumeRow}[2]{% \begin{tabularx}{0.97\textwidth}{@{}l R@{}}#1 & #2 \\\end{tabularx}\par} +% Entry heads carry no trailing glue: the head→bullet gap is an explicit +% \vspace the renderer inserts only when bullets follow, and the inter-entry gap +% is \resumeEntryGap. A bullet-less entry (e.g. Education) therefore adds nothing +% after itself, so the next entry/section gap stands alone. \newcommand{\resumeSubheading}[4]{% - \vspace{-2pt}\item + \item \resumeRow{${bf("#1", style.boldTitles)}}{#2}% - \vspace{-7pt}% + \vspace{${fmtPt(style.titleSubVSpacePt)}}% \resumeRow{${it("\\small#3", style.italicSubtitles)}}{${it("\\small #4", style.italicDates)}}% - \vspace{${fmtPt(style.subheadingTrailVSpacePt)}} } \newcommand{\resumeSubSubheading}[2]{% \item \resumeRow{${it("\\small#1", style.italicSubtitles)}}{${it("\\small #2", style.italicDates)}}% - \vspace{${fmtPt(style.subheadingTrailVSpacePt)}} } \newcommand{\resumeProjectHeading}[2]{% \item \resumeRow{\small#1}{#2}% - \vspace{${fmtPt(style.subheadingTrailVSpacePt)}} } -\newcommand{\resumeSubItem}[1]{\resumeItem{#1}\vspace{-4pt}} +\newcommand{\resumeSubItem}[1]{\resumeItem{#1}} \renewcommand\labelitemii{$\vcenter{\hbox{\tiny$\bullet$}}$} -\newcommand{\resumeSubHeadingListStart}{\begin{itemize}[leftmargin=0.15in, label={}]} +\newcommand{\resumeSubHeadingListStart}{\begin{itemize}${subListOpts}} \newcommand{\resumeSubHeadingListEnd}{\end{itemize}} -\newcommand{\resumeItemListStart}{\begin{itemize}} -\newcommand{\resumeItemListEnd}{\end{itemize}\vspace{${fmtPt(style.itemListEndVSpacePt)}}} +\newcommand{\resumeItemListStart}{\begin{itemize}${itemListOpts}} +\newcommand{\resumeItemListEnd}{\end{itemize}} +${entryGapMacro} `; } @@ -205,9 +291,9 @@ function renderHeader(resume, style) { }); return `\\begin{center} - \\textbf{\\Huge \\scshape ${escapeTex(resume.name)}} \\\\ \\vspace{1pt} - \\small ${contactPieces.join(` ${style.contactDivider} `)} -\\end{center}`; + \\textbf{\\Huge \\scshape ${escapeTex(resume.name)}} \\\\ \\vspace{${fmtPt(style.nameContactVSpacePt)}} + \\small ${contactPieces.join(style.contactSeparatorTex)} +\\end{center}${style.headerSectionVSpacePt === null ? "" : `\n\\vspace{${fmtPt(style.headerSectionVSpacePt)}}`}`; } // If the field looks like a URL or domain, render as an underlined \href so the @@ -239,11 +325,12 @@ function renderSkillBullet(bullet, boldLabel) { return `\\textbf{${escapeTex(label)}}${escapeTex(rest)}`; } -// The `\\` line break between skill lines, carrying the bulletGap-derived extra -// vertical space so the PDF's skill spacing tracks the editor's. 0pt → plain `\\` -// (upstream-identical). -function skillBreak(style) { - return style.skillLineGapPt > 0 ? `\\\\[${style.skillLineGapPt}pt]` : `\\\\`; +// Separator BETWEEN skill rows that share one \item (the inline isSkillsLike +// case). \par ends the row; the optional \vspace adds the row gap. \\[len] would +// be silently dropped under the template's global \raggedright, so we never use +// it for row spacing. The last row gets no separator (rows are join()ed). +function renderSkillRowSep(style) { + return style.skillsRowGapPt ? `\\par\\vspace{${fmtPt(style.skillsRowGapPt)}}\n ` : "\\par\n "; } function renderItem(item, style) { @@ -252,10 +339,12 @@ function renderItem(item, style) { const isSkillsLike = !hasFourSlots && item.bullets.length; if (isSkillsLike) { - // Render as a single inline item — common for Skills / Technical sections. - return ` \\small{\\item{ -${item.bullets.map((bullet) => ` ${renderSkillBullet(bullet, style.boldSkillLabels)} ${skillBreak(style)}`).join("\n")} - }}`; + // Inline skill rows inside the entry list (a standard section whose item is + // all bullets). One \item; rows separated by the skills-row gap, never after + // the last row. + return ` \\item{\\small ${item.bullets + .map((bullet) => renderSkillBullet(bullet, style.boldSkillLabels)) + .join(renderSkillRowSep(style))}}`; } const heading = isProjectish @@ -267,7 +356,10 @@ ${item.bullets.map((bullet) => ` ${renderSkillBullet(bullet, style.boldSk if (!item.bullets.length) return heading; + // Head→bullet gap lives here (not in the macro) so a bullet-less entry leaves + // no trailing space. topsep is 0, so this \vspace is the whole head→bullet gap. return `${heading} + \\vspace{${fmtPt(style.headBulletPt)}} \\resumeItemListStart ${item.bullets.map((bullet) => ` \\resumeItem{${escapeTex(bullet)}}`).join("\n")} \\resumeItemListEnd`; @@ -275,7 +367,10 @@ ${item.bullets.map((bullet) => ` \\resumeItem{${escapeTex(bullet)}}`).joi function renderSection(section, style) { const hasAnyHeading = section.items.some((item) => item.title || item.subtitle || item.meta || item.location); - const heading = titleCase(section.heading); + // Headings are small-caps (\scshape) by default; the Uppercase toggle forces + // ALL CAPS by uppercasing the text (\scshape is then a no-op on the caps). + const titled = titleCase(section.heading); + const heading = style.uppercaseHeadings ? titled.toUpperCase() : titled; // Summary-like headings render their rows as plain small paragraphs instead of // the skills block (which draws \\ line breaks and bolds "Label:" prefixes). @@ -289,19 +384,20 @@ function renderSection(section, style) { ${paragraphs.map((text) => ` {\\small ${escapeTex(text)}\\par}`).join("\n")}`; } - // Skills-style: no heading rows at all → render as one flat block. + // Skills-style: no heading rows at all → one row per \item. \itemsep is the + // sole between-row gap (skillsRowGap); topsep/parsep are zeroed so the last row + // adds nothing and the rule→first-row gap is owned by the section "after" gap. if (!hasAnyHeading && section.items.length) { const allBullets = section.items.flatMap((item) => item.bullets); if (!allBullets.length) return ""; return `\\section{${escapeTex(heading)}} - \\begin{itemize}[leftmargin=0.15in, label={}] - \\small{\\item{ -${allBullets.map((bullet) => ` ${renderSkillBullet(bullet, style.boldSkillLabels)} ${skillBreak(style)}`).join("\n")} - }} + \\begin{itemize}[leftmargin=0.15in, label={}, topsep=0pt, partopsep=0pt, parsep=0pt, itemsep=${fmtPt(style.skillsRowGapPt)}] +${allBullets.map((bullet) => ` \\item{\\small ${renderSkillBullet(bullet, style.boldSkillLabels)}}`).join("\n")} \\end{itemize}`; } - const body = section.items.map((item) => renderItem(item, style)).filter(Boolean).join("\n"); + const entryJoiner = style.entryGapPt === null ? "\n" : "\n \\resumeEntryGap\n"; + const body = section.items.map((item) => renderItem(item, style)).filter(Boolean).join(entryJoiner); if (!body) return ""; return `\\section{${escapeTex(heading)}} diff --git a/src/App.tsx b/src/App.tsx index efcc5e9..6d5fecf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ChangeE import { Eye, FileCode2, FileDown, RotateCcw, Upload } from "lucide-react"; import { useResumeEditor } from "./hooks/useResumeEditor"; -import { useDocStyle, DOC_ZOOM_OPTIONS, DOC_SPACING_PRESETS, type DocStyle } from "./hooks/useDocStyle"; +import { useDocStyle, DOC_ZOOM_OPTIONS, DOC_SPACING_PRESETS, DOC_SPACING_KEYS, type DocSpacingKey, type DocSpacingPreset } from "./hooks/useDocStyle"; import { useTemplates } from "./hooks/useTemplates"; import { useResumeExport } from "./hooks/useResumeExport"; import { ResumeEditor } from "./sections/editor/ResumeEditor"; @@ -24,16 +24,45 @@ function loadSavedResume(): ResumeData | null { } } -// Fine-grained layout/spacing controls — line height + the three vertical gaps. -// Ranges mirror role-fit-ai's Format menu. -const SPACING_SLIDERS: { key: "lineHeight" | "sectionGap" | "entryGap" | "bulletGap"; label: string; min: number; max: number; unit: string }[] = [ - { key: "lineHeight", label: "Line height", min: 1, max: 1.6, unit: "" }, - { key: "sectionGap", label: "Section gap", min: 0, max: 1.6, unit: "em" }, - { key: "entryGap", label: "Entry gap", min: 0, max: 1.2, unit: "em" }, - // Controls the gap between repeated lines inside a section — bullets, skill - // rows, and summary lines all read from --doc-bullet-gap, so "Line gap" names - // it honestly rather than implying bullets only. - { key: "bulletGap", label: "Line gap", min: 0, max: 1, unit: "em" } +// 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 +// margins are intentionally fixed by the Jake template. +const SPACING_GROUPS: { label: string; sliders: { key: DocSpacingKey; label: string; min: number; max: number; unit: string }[] }[] = [ + { + label: "Page", + sliders: [{ key: "lineHeight", label: "Line height", min: 1, max: 1.6, unit: "" }] + }, + { + label: "Header", + sliders: [ + { key: "nameContactGap", label: "Name → contact", min: 0, max: 0.5, unit: "em" }, + { key: "contactGap", label: "Contact gap", min: 0.5, max: 3, unit: "em" }, + { key: "headerSectionGap", label: "Header → section", min: 0, max: 2, unit: "em" } + ] + }, + { + label: "Sections", + sliders: [ + { key: "sectionGap", label: "Section gap", min: 0, max: 1.6, unit: "em" }, + { key: "sectionEntryGap", label: "Heading → entry", min: 0, max: 1.2, unit: "em" } + ] + }, + { + label: "Entries", + sliders: [ + { key: "entryGap", label: "Entry gap", min: 0, max: 1.2, unit: "em" }, + { key: "titleSubGap", label: "Title → subtitle", min: 0, max: 0.4, unit: "em" }, + { key: "headBulletGap", label: "Entry → bullets", min: 0, max: 1.2, unit: "em" } + ] + }, + { + label: "Lists", + sliders: [ + { key: "bulletGap", label: "Bullet gap", min: 0, max: 1, unit: "em" }, + { key: "skillsRowGap", label: "Skill-row gap", min: 0, max: 0.8, unit: "em" } + ] + } ]; // Labeled range input for one spacing dimension. @@ -267,17 +296,30 @@ export default function App() { event.target.value = ""; } - const spacingActive = (values: DocStyle) => - docStyle.style.lineHeight === values.lineHeight && - docStyle.style.sectionGap === values.sectionGap && - docStyle.style.entryGap === values.entryGap && - docStyle.style.bulletGap === values.bulletGap; - - const activePresetKey = Object.entries(DOC_SPACING_PRESETS).find( - ([, p]) => spacingActive({ ...docStyle.style, ...p.values }) - )?.[0] as string | null ?? null; - - const presetItems = Object.entries(DOC_SPACING_PRESETS).map(([key, p]) => ({ key, label: p.label })); + // The live spacing equals a preset when all 11 fields match (within a hair, so + // float rounding from the sliders doesn't break the highlight). + const spacingActive = (values: DocSpacingPreset) => + DOC_SPACING_KEYS.every((k) => Math.abs(docStyle.style[k] - values[k]) < 0.005); + + // Built-in presets plus the user's saved "Custom" (when present), shown as one + // segmented selector. "custom" applies the saved snapshot; the rest apply their + // fixed values. + const presetItems = [ + ...Object.entries(DOC_SPACING_PRESETS).map(([key, p]) => ({ key, label: p.label })), + ...(docStyle.customPreset ? [{ key: "custom", label: "Custom" }] : []) + ]; + + const activePresetKey = + (Object.entries(DOC_SPACING_PRESETS).find(([, p]) => spacingActive(p.values))?.[0] ?? null) ?? + (docStyle.customPreset && spacingActive(docStyle.customPreset) ? "custom" : null); + + function applyPreset(key: string) { + if (key === "custom") { + if (docStyle.customPreset) docStyle.applySpacingPreset(docStyle.customPreset); + return; + } + docStyle.applySpacingPreset(DOC_SPACING_PRESETS[key as keyof typeof DOC_SPACING_PRESETS].values); + } const tectonicOff = !templates.tectonic.available; @@ -401,25 +443,34 @@ export default function App() {