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() {
Preset - docStyle.applySpacingPreset(DOC_SPACING_PRESETS[key as keyof typeof DOC_SPACING_PRESETS].values)} - /> -
-
- {SPACING_SLIDERS.map(({ key, label, min, max, unit }) => ( - docStyle.set(key, value)} - /> - ))} +
+ {SPACING_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.sliders.map(({ key, label, min, max, unit }) => ( + docStyle.set(key, value)} + /> + ))} +
+
+ ))} +
diff --git a/src/hooks/useDocStyle.ts b/src/hooks/useDocStyle.ts index 5334f3c..9478083 100644 --- a/src/hooks/useDocStyle.ts +++ b/src/hooks/useDocStyle.ts @@ -9,15 +9,22 @@ export type DocStyle = { // (75% of the pane); width and font scale by the same factor. zoom: number; lineHeight: number; // body leading + nameContactGap: number; // name -> contact row + contactGap: number; // horizontal space between contact items + headerSectionGap: number; // contact/header block -> first section sectionGap: number; // em between sections + sectionEntryGap: number; // section heading -> first entry/row entryGap: number; // em between entries in a section + titleSubGap: number; // title row -> subtitle row + headBulletGap: number; // entry header -> first bullet + skillsRowGap: number; // skills row -> skills row bulletGap: number; // em between bullets boldTitles: boolean; // entry title (role / project / school) boldHeadings: boolean; // section headings boldSkillLabels: boolean; // "Languages:" style labels italicSubtitles: boolean; // subtitle + location row italicDates: boolean; // the right-aligned date / link slot - uppercaseHeadings: boolean; // force section headings to ALL CAPS (off = as typed) + uppercaseHeadings: boolean; // force section headings to ALL CAPS (off = small caps, as in the PDF) sectionRule: boolean; // the horizontal rule under each section heading contactDivider: string; // 1–2 char separator between header contact items }; @@ -25,28 +32,63 @@ export type DocStyle = { export const DOC_STYLE_DEFAULTS: DocStyle = { zoom: 1, lineHeight: 1.18, + 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, boldTitles: true, boldHeadings: false, boldSkillLabels: true, italicSubtitles: true, italicDates: false, - uppercaseHeadings: true, + // Off by default → small-caps headings that match the Jake template's \scshape. + // The toggle forces ALL CAPS for users who prefer it. + uppercaseHeadings: false, sectionRule: true, contactDivider: "|" }; -type DocSpacingPreset = Pick; +// Single source of truth for the spacing fields that make up a preset (the +// sliders, the built-in presets, and the saved Custom snapshot). The key type +// and the runtime list derive from this one array so they can't drift. +export const DOC_SPACING_KEYS = [ + "lineHeight", + "nameContactGap", + "contactGap", + "headerSectionGap", + "sectionGap", + "sectionEntryGap", + "entryGap", + "titleSubGap", + "headBulletGap", + "skillsRowGap", + "bulletGap" +] as const satisfies readonly (keyof DocStyle)[]; + +export type DocSpacingKey = (typeof DOC_SPACING_KEYS)[number]; + +export type DocSpacingPreset = Pick; export const DOC_SPACING_PRESETS = { compact: { label: "Compact", values: { lineHeight: 1.16, + nameContactGap: 0.02, + contactGap: 1.6, + headerSectionGap: 0.82, sectionGap: 0.48, + sectionEntryGap: 0.3, entryGap: 0.24, + titleSubGap: 0.03, + headBulletGap: 0.24, + skillsRowGap: 0, bulletGap: 0.08 } }, @@ -54,8 +96,15 @@ export const DOC_SPACING_PRESETS = { label: "Normal", values: { lineHeight: DOC_STYLE_DEFAULTS.lineHeight, + nameContactGap: DOC_STYLE_DEFAULTS.nameContactGap, + contactGap: DOC_STYLE_DEFAULTS.contactGap, + headerSectionGap: DOC_STYLE_DEFAULTS.headerSectionGap, sectionGap: DOC_STYLE_DEFAULTS.sectionGap, + sectionEntryGap: DOC_STYLE_DEFAULTS.sectionEntryGap, entryGap: DOC_STYLE_DEFAULTS.entryGap, + titleSubGap: DOC_STYLE_DEFAULTS.titleSubGap, + headBulletGap: DOC_STYLE_DEFAULTS.headBulletGap, + skillsRowGap: DOC_STYLE_DEFAULTS.skillsRowGap, bulletGap: DOC_STYLE_DEFAULTS.bulletGap } }, @@ -63,8 +112,15 @@ export const DOC_SPACING_PRESETS = { label: "Relaxed", values: { lineHeight: 1.3, + nameContactGap: 0.06, + contactGap: 2.1, + headerSectionGap: 1.5, sectionGap: 1.15, + sectionEntryGap: 0.6, entryGap: 0.62, + titleSubGap: 0.1, + headBulletGap: 0.62, + skillsRowGap: 0.1, bulletGap: 0.34 } } @@ -73,37 +129,87 @@ export const DOC_SPACING_PRESETS = { // Google-Docs-style zoom steps for the Resume tab's page-zoom select. export const DOC_ZOOM_OPTIONS = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5] as const; -const STORAGE_KEY = "jakeforge.docStyle.v1"; +const STORAGE_KEY = "jakeforge.docStyle.v2"; +// User-saved spacing preset (the 11 spacing sliders only, like the built-in +// presets). Stored separately so it survives Reset and live edits. +const CUSTOM_STORAGE_KEY = "jakeforge.docStyle.custom.v1"; +// v1 had one `entryGap` slider wired to the entry-header -> bullets gap in both +// CSS and LaTeX (and one `sectionGap` that doubled as the header->section gap). +// Migrate that value to `headBulletGap`; the new `entryGap` starts at the +// calibrated entry-to-entry default and `headerSectionGap` from sectionGap+0.34. +const LEGACY_V1_STORAGE_KEY = "jakeforge.docStyle.v1"; const clamp = (value: unknown, fallback: number, min: number, max: number) => { const n = typeof value === "number" && Number.isFinite(value) ? value : fallback; return Math.min(max, Math.max(min, n)); }; -function coerce(raw: unknown): DocStyle { +function coerce(raw: unknown, legacyMode: "current" | "v1" = "current"): DocStyle { const r = (raw ?? {}) as Partial>; + const legacySectionGap = clamp(r.sectionGap, DOC_STYLE_DEFAULTS.sectionGap, 0, 2); + const legacyEntryGap = clamp(r.entryGap, DOC_STYLE_DEFAULTS.entryGap, 0, 1.6); return { zoom: clamp(r.zoom, DOC_STYLE_DEFAULTS.zoom, 0.4, 2), lineHeight: clamp(r.lineHeight, DOC_STYLE_DEFAULTS.lineHeight, 1, 1.8), - sectionGap: clamp(r.sectionGap, DOC_STYLE_DEFAULTS.sectionGap, 0, 2), - entryGap: clamp(r.entryGap, DOC_STYLE_DEFAULTS.entryGap, 0, 1.6), + nameContactGap: clamp(r.nameContactGap, DOC_STYLE_DEFAULTS.nameContactGap, 0, 0.8), + contactGap: clamp(r.contactGap, DOC_STYLE_DEFAULTS.contactGap, 0.4, 3), + headerSectionGap: clamp( + r.headerSectionGap, + legacyMode === "v1" ? legacySectionGap + 0.34 : DOC_STYLE_DEFAULTS.headerSectionGap, + 0, + 2.4 + ), + sectionGap: legacySectionGap, + sectionEntryGap: clamp(r.sectionEntryGap, DOC_STYLE_DEFAULTS.sectionEntryGap, 0, 1.2), + entryGap: legacyMode === "v1" ? DOC_STYLE_DEFAULTS.entryGap : legacyEntryGap, + titleSubGap: clamp(r.titleSubGap, DOC_STYLE_DEFAULTS.titleSubGap, 0, 0.6), + headBulletGap: clamp( + r.headBulletGap, + legacyMode === "v1" ? legacyEntryGap : DOC_STYLE_DEFAULTS.headBulletGap, + 0, + 1.4 + ), + skillsRowGap: clamp(r.skillsRowGap, DOC_STYLE_DEFAULTS.skillsRowGap, 0, 0.8), bulletGap: clamp(r.bulletGap, DOC_STYLE_DEFAULTS.bulletGap, 0, 1.2), boldTitles: r.boldTitles !== false, boldHeadings: r.boldHeadings === true, boldSkillLabels: r.boldSkillLabels !== false, italicSubtitles: r.italicSubtitles !== false, italicDates: r.italicDates === true, - uppercaseHeadings: r.uppercaseHeadings !== false, + uppercaseHeadings: r.uppercaseHeadings === true, sectionRule: r.sectionRule !== false, // Cap at 2 chars; fall back to "|" when missing (not when intentionally blank). contactDivider: typeof r.contactDivider === "string" ? r.contactDivider.slice(0, 2) : DOC_STYLE_DEFAULTS.contactDivider }; } +// The spacing fields that make up a preset, lifted off a full DocStyle. +function pickSpacing(style: DocStyle): DocSpacingPreset { + return Object.fromEntries(DOC_SPACING_KEYS.map((k) => [k, style[k]])) as DocSpacingPreset; +} + +function loadCustom(): DocSpacingPreset | null { + try { + const raw = window.localStorage.getItem(CUSTOM_STORAGE_KEY); + if (!raw) return null; + // Reuse coerce() so a partial/corrupt blob is clamped and back-filled before + // we lift the spacing fields off it. + return pickSpacing(coerce(JSON.parse(raw))); + } catch { + return null; + } +} + function load(): DocStyle { try { const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) return coerce(JSON.parse(raw)); + const legacy = window.localStorage.getItem(LEGACY_V1_STORAGE_KEY); + if (legacy) { + const migrated = coerce(JSON.parse(legacy), "v1"); + window.localStorage.removeItem(LEGACY_V1_STORAGE_KEY); + return migrated; + } return { ...DOC_STYLE_DEFAULTS }; } catch { return { ...DOC_STYLE_DEFAULTS }; @@ -112,6 +218,7 @@ function load(): DocStyle { export function useDocStyle() { const [style, setStyle] = useState(load); + const [customPreset, setCustomPreset] = useState(loadCustom); const saveTimer = useRef(undefined); // Persist (debounced — sliders emit a burst of changes per drag). @@ -132,14 +239,22 @@ export function useDocStyle() { ({ "--doc-zoom": String(style.zoom), "--doc-line": String(style.lineHeight), + "--doc-name-contact-gap": `${style.nameContactGap}em`, + "--doc-contact-gap": `${style.contactGap}em`, + "--doc-header-section-gap": `${style.headerSectionGap}em`, "--doc-section-gap": `${style.sectionGap}em`, + "--doc-section-entry-gap": `${style.sectionEntryGap}em`, "--doc-entry-gap": `${style.entryGap}em`, + "--doc-title-sub-gap": `${style.titleSubGap}em`, + "--doc-head-bullet-gap": `${style.headBulletGap}em`, + "--doc-skills-row-gap": `${style.skillsRowGap}em`, "--doc-bullet-gap": `${style.bulletGap}em`, "--doc-title-weight": style.boldTitles ? "700" : "400", "--doc-heading-weight": style.boldHeadings ? "700" : "400", "--doc-skill-label-weight": style.boldSkillLabels ? "700" : "400", "--doc-subtitle-style": style.italicSubtitles ? "italic" : "normal", "--doc-date-style": style.italicDates ? "italic" : "normal", + // Section headings render small-caps by default; the toggle forces caps. "--doc-heading-transform": style.uppercaseHeadings ? "uppercase" : "none", "--doc-rule-width": style.sectionRule ? "1px" : "0", // Quoted so it's a valid CSS `content` token. @@ -174,12 +289,32 @@ export function useDocStyle() { setStyle((current) => ({ ...current, ...preset })); } + // Snapshot the current spacing as the user's Custom preset (persisted). + function saveCustomPreset() { + const snapshot = pickSpacing(style); + setCustomPreset(snapshot); + try { + window.localStorage.setItem(CUSTOM_STORAGE_KEY, JSON.stringify(snapshot)); + } catch { + // Storage unavailable (private mode); the preset still applies this session. + } + } + const isTypographyDefault = useMemo( () => (Object.keys(TYPOGRAPHY_DEFAULTS) as (keyof typeof TYPOGRAPHY_DEFAULTS)[]).every((k) => style[k] === TYPOGRAPHY_DEFAULTS[k]), [style] ); - return { style, set, resetTypography, applySpacingPreset, isTypographyDefault, cssVars }; + return { + style, + set, + resetTypography, + applySpacingPreset, + saveCustomPreset, + customPreset, + isTypographyDefault, + cssVars + }; } export type DocStyleControls = ReturnType; diff --git a/src/lib/resumeDocument.ts b/src/lib/resumeDocument.ts index c4a9843..039fac7 100644 --- a/src/lib/resumeDocument.ts +++ b/src/lib/resumeDocument.ts @@ -69,14 +69,18 @@ function isNameLine(line: string): boolean { ); } -// Canonical, uppercased section label. Known synonyms collapse to one heading; -// arbitrary titles (LaTeX \section{...}) are just uppercased. +// Canonical, title-cased section label. Known synonyms collapse to one heading; +// arbitrary titles (LaTeX \section{...}) are title-cased to match the PDF's +// \scshape small-caps rendering (first letter full cap, rest small caps). +function titleCase(s: string): string { + return s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()); +} function headingLabel(raw: string): string { const normalized = cleanLine(raw).toLowerCase(); - if (normalized === "targeted summary") return "SUMMARY"; - if (normalized === "core skills") return "TECHNICAL SKILLS"; - if (normalized === "experience") return "WORK EXPERIENCE"; - return cleanLine(raw).toUpperCase(); + if (normalized === "targeted summary") return "Summary"; + if (normalized === "core skills") return "Technical Skills"; + if (normalized === "experience") return "Work Experience"; + return titleCase(cleanLine(raw)); } // Read a brace-balanced argument starting at s[i] === "{"; returns the inner diff --git a/src/styles/index.css b/src/styles/index.css index df9425c..fac6962 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -413,6 +413,26 @@ color: var(--ink-faint); } +/* Save-the-current-spacing-as-Custom button under the spacing sliders. */ +.ctl-save-preset { + align-self: flex-start; + margin-top: var(--s1); + padding: 5px 12px; + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); + background: var(--card-elev); + border: 1px solid var(--hairline-strong); + border-radius: var(--r-md); + cursor: pointer; + transition: color 0.12s var(--ease), border-color 0.12s var(--ease), background 0.12s var(--ease); +} + +.ctl-save-preset:hover { + color: var(--ink-strong); + border-color: var(--accent); +} + .ctl-divider { width: 44px; padding: 4px 6px; diff --git a/src/styles/resume-document.css b/src/styles/resume-document.css index 97e88c4..30931ce 100644 --- a/src/styles/resume-document.css +++ b/src/styles/resume-document.css @@ -10,6 +10,38 @@ fill) so they print without "Background graphics". ============================================================= */ +/* Latin Modern Roman — the same font Tectonic embeds in the PDF. + Without this @font-face the browser falls back to Times/serif, + which is ~17% narrower and causes line breaks to diverge. */ +@font-face { + font-family: "Latin Modern Roman"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("/fonts/LMRoman10-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Latin Modern Roman"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("/fonts/LMRoman10-Bold.woff2") format("woff2"); +} +@font-face { + font-family: "Latin Modern Roman"; + font-weight: 400; + font-style: italic; + font-display: swap; + src: url("/fonts/LMRoman10-Italic.woff2") format("woff2"); +} +@font-face { + font-family: "Latin Modern Roman"; + font-weight: 700; + font-style: italic; + font-display: swap; + src: url("/fonts/LMRoman10-BoldItalic.woff2") format("woff2"); +} + .resume-doc { box-sizing: border-box; --doc-page-border-width: 1px; @@ -94,14 +126,14 @@ margin-top: 0.62em; } -/* Section heading: ALL CAPS with a rule below. */ +/* Section heading: small caps with a rule below, matching the PDF's \scshape. */ .resume-doc__heading { margin: 0 0 0.16em; padding-bottom: 0.05em; border-bottom: 1px solid #000000; font-size: 1.02em; font-weight: 700; - text-transform: uppercase; + font-variant: small-caps; letter-spacing: 0.04em; color: #000000; } diff --git a/src/styles/resume-editor.css b/src/styles/resume-editor.css index 6bbcc46..f0168b9 100644 --- a/src/styles/resume-editor.css +++ b/src/styles/resume-editor.css @@ -227,7 +227,7 @@ gap: 0.05em 0; /* PDF name→contact baseline gap is 14.3pt; the name line box's descent plus the contact line's ascent already supply nearly all of it. */ - margin-top: 0.04em; + margin-top: var(--doc-name-contact-gap, 0.04em); /* \small at the 11pt documentclass = 10pt = 0.913em. */ font-size: 0.91em; color: #1a1a1a; @@ -263,7 +263,7 @@ sit under the INPUT, not the divider — stay centered. A margin-based divider made the real footprint (~2em) drift from this value (was 0.85em), shifting the × off-center. */ - --contact-divider-width: 1.82em; + --contact-divider-width: var(--doc-contact-gap, 1.82em); } .rdx-contact__chip::after { @@ -339,32 +339,20 @@ color: var(--danger); } -/* Sections. The PDF's gap into a section heading varies with what precedes it - (TeX glue: ~23.9pt after a bullet list, 25.3pt after the skills block, - 26.7pt after the contact line, at the normal preset) while the slider moves - all of them together at 11pt/em — mirror both with per-predecessor offsets. */ +/* Section gap is a pure separator (matches the PDF's \titlespacing "before"): + one uniform gap above every heading, regardless of what precedes it. The + per-predecessor offsets and leading-scaled (--doc-line) terms are gone — the + header→first-section distance has its own slider below. */ .rdx-section { position: relative; - margin-top: calc(var(--doc-section-gap, 0.85em) + 0.02em); - /* BFC: keep child margins (esp. the bullet-less entry's trailing-glue - margin-bottom below) inside the section so they ADD to the next section's - margin like TeX glue sums, instead of collapsing into it. */ + margin-top: var(--doc-section-gap, 0.85em); + /* BFC: keep child margins inside the section so the section gap is reliable + instead of collapsing into the first child's margin. */ display: flow-root; } -/* The (--doc-line − 1.18) terms below mirror how the PDF's list-closing glue - grows super-linearly with leading (TeX rubber/ex-unit glue): calibrated - against compiled PDFs at lineHeight 1.18 and 1.3, zero at the 1.18 default. */ -.rdx-section:has(.rdx-bullet) + .rdx-section { - margin-top: calc(var(--doc-section-gap, 0.85em) + 0.09em + (var(--doc-line, 1.18) - 1.18) * 2.6em); -} - -.rdx-section--skills + .rdx-section { - margin-top: calc(var(--doc-section-gap, 0.85em) + 0.21em + (var(--doc-line, 1.18) - 1.18) * 0.75em); -} - .resume-doc section.rdx-section:first-of-type { - margin-top: calc(var(--doc-section-gap, 0.85em) + 0.34em); + margin-top: var(--doc-header-section-gap, 1.19em); } .rdx-section__head { @@ -392,13 +380,16 @@ z-index: 1; } -/* Section heading: ALL CAPS with a rule below (the stored heading is uppercased - on parse; this also forces caps for hand-typed headings). */ +/* Section heading: small caps with a rule below, matching the PDF's \scshape. + Headings are stored in title case ("Education") so CSS small-caps renders the + first letter as a full capital and the rest as small capitals. The Uppercase + toggle (--doc-heading-transform: uppercase) still forces ALL CAPS on demand. */ .rdx-section__heading { /* \large at the 11pt documentclass = 12pt = 1.096em of \normalsize 10.95pt. */ font-size: 1.1em; font-weight: var(--doc-heading-weight, 400); - text-transform: var(--doc-heading-transform, uppercase); + font-variant: small-caps; + text-transform: var(--doc-heading-transform, none); letter-spacing: 0.04em; color: #000000; width: auto; @@ -475,24 +466,15 @@ \resumeSubHeadingListStart leftmargin=0.15in (10.8pt = 0.99em). The right edge (dates/locations) stays flush, so only the left content shifts in. */ padding-left: 0.99em; - /* In the compiled PDF, entry→entry distance is FIXED glue (the Format menu's - entry gap feeds the \resumeSubheading trailing vspace, i.e. the head→bullets - gap below) — so the editor's entry margin is a constant too. */ - margin-top: calc(0.43em + (var(--doc-line, 1.18) - 1.18) * 3em); + /* Entry→entry rhythm — the sole gap between entries, matching the PDF's + \resumeEntryGap (em*11pt). Rigid: it no longer scales with line height. */ + margin-top: var(--doc-entry-gap, 0.42em); } -/* The PDF's heading→first-entry gap is fixed template glue (independent of the - spacing sliders) and tighter than the entry→entry rhythm. */ +/* Section heading→first entry/row. Normal preset is calibrated from the compiled + PDF: section rule → first entry title top ≈ 4.56pt = 0.42em. */ .rdx-section__head + .rdx-entry { - margin-top: 0.33em; -} - -/* A bullet-less entry's \resumeSubheading trailing vspace has no bullet list to - absorb it in the PDF, so it leaks into whatever follows — mirror that so the - gap after e.g. the Education row tracks the entry-gap slider like the PDF - does. Zero at the default 0.42em. */ -.rdx-entry:not(:has(.rdx-bullet)) { - margin-bottom: calc((var(--doc-entry-gap, 0.42em) - 0.42em) * 1.15); + margin-top: var(--doc-section-entry-gap, 0.42em); } .rdx-entry__head { @@ -500,6 +482,10 @@ display: flex; align-items: flex-start; gap: 0.4em; + /* The heading tabular is 0.97\textwidth in the PDF — its right edge stops + 0.075in (0.49em) short of the content area, inset from the section rule. + Bullets extend to the full width, so only the head gets this padding. */ + padding-right: 0.49em; } .rdx-entry__rows { @@ -508,10 +494,11 @@ } /* Title row → subtitle row: the PDF's \resumeSubheading tabular spaces its two - rows one \normalsize baselineskip apart (13.3pt) — wider than the stacked - line boxes alone. */ + rows one \normalsize baselineskip apart — measured glyph-bbox gap ≈ 0.063em in + the compiled PDF. The CSS box gap is slightly larger because inputs include + line-height leading; 0.06em box gap lands closest to the PDF visual. */ .rdx-entry__row + .rdx-entry__row { - margin-top: 0.09em; + margin-top: var(--doc-title-sub-gap, 0.06em); } .rdx-entry__row { @@ -570,8 +557,10 @@ } /* Skills sections: Jake-style "Label: inline list" rows, not bullets. */ +/* Skills rule → first row = section/entry gap (the PDF's \titlespacing "after"), + same as a standard section's rule → first entry. */ .rdx-section--skills .rdx-section__head { - margin-bottom: calc(0.39em - (var(--doc-line, 1.18) - 1.18) * 0.7em); + margin-bottom: var(--doc-section-entry-gap, 0.42em); } .rdx-skills-row { @@ -630,15 +619,15 @@ color: #222222; } -/* Each skill line is a "bullet" of the skills block — give consecutive rows the - same adjustable gap as bullets/summaries so the Bullet-gap slider controls it. */ +/* Consecutive skill rows get their own dedicated gap slider (the PDF stacks them + with no inter-row glue by default, hence the 0em default). */ .rdx-skills-row + .rdx-skills-row { - margin-top: var(--doc-bullet-gap, 0.2em); + margin-top: var(--doc-skills-row-gap, 0em); } /* Summary sections: plain paragraphs — no bullet glyphs, no heading slots. */ .rdx-section--summary .rdx-section__head { - margin-bottom: 0.25em; + margin-bottom: var(--doc-section-entry-gap, 0.42em); } .rdx-summary-row { @@ -670,10 +659,9 @@ display: block; gap: 0; list-style: none; - /* Head→bullets gap: this is where the PDF spends the Format menu's "entry - gap" — \resumeSubheading's trailing \vspace{(entryGap − 0.65em) · 11pt} - sits between the heading tabular and the bullet list. */ - margin: calc(var(--doc-entry-gap, 0.42em) - 0.02em + (var(--doc-line, 1.18) - 1.18) * 1em) 0 0; + /* Head→bullets gap = the PDF's explicit \vspace before the bullet list (inner + topsep is 0). Rigid em, no list-opening glue to model. */ + margin: var(--doc-head-bullet-gap, 0.42em) 0 0; padding: 0; color: inherit; font-size: inherit; @@ -686,10 +674,17 @@ align-items: flex-start; /* PDF geometry: marker at 61.5pt, bullet text at 70.8pt from the page edge. */ gap: 0.5em; - /* Breathing room between bullets — user-adjustable (Format menu). The PDF's - bullet trail moves 11pt per em of the slider; margins here resolve against - the \small 10pt bullet font, hence the 1.1 slope correction. */ - margin: calc(var(--doc-bullet-gap, 0.2em) * 1.1 - 0.02em + (var(--doc-line, 1.18) - 1.18) * 0.97em) 0 0; + /* Between-bullet gap = the inner list \itemsep (em*11pt) in the PDF. Applied + as margin-top, so it sits only BETWEEN bullets; the first bullet resets it + below so the head→bullet gap stays owned by .rdx-bullets. */ + margin: var(--doc-bullet-gap, 0.2em) 0 0; +} + +/* First bullet's gap to the entry head is owned by .rdx-bullets (head→bullet), + so it must not also carry the between-bullet margin — mirrors the PDF, where + itemize adds \itemsep only between items, never before the first. */ +.rdx-bullets .rdx-bullet:first-child { + margin-top: 0; } .rdx-bullet__dot {