From 3e7e24bbc4437d4f280b8c70885cce1140aba540 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 12:27:23 +0000 Subject: [PATCH 01/27] feat: add sidebar-cv Typst theme with two-column layout Add a new "Sidebar CV" Typst theme inspired by the user's CV design. Features a tinted left sidebar with photo, name, headline, summary, skills, languages, and contact details, with the main content area on the right for professional experience and other sections. Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/ffb0aacb-46e9-4725-b797-661aeab6cca3 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/sidebar-cv/main.typ | 493 ++++++++++++++++++ .../typst-themes/sidebar-cv/theme.json | 7 + shared/src/generated/typst-themes.ts | 5 + 3 files changed, 505 insertions(+) create mode 100644 orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ create mode 100644 orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/theme.json diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ new file mode 100644 index 000000000..0d78ed89e --- /dev/null +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -0,0 +1,493 @@ +// Sidebar CV — two-column layout inspired by the "Tamay Gündüz" CV style. +// Left sidebar (tinted) holds photo, headline, summary, and contact. +// Right main area holds professional experience and remaining sections. + +#let source = json(__RESUME_DATA_PATH__) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +#let with-default(value, fallback) = { + if value == none { fallback } else { value } +} + +#let text-of(value) = with-default(value, "") +#let list-of(value) = with-default(value, ()) + +#let text-of-item(item, key) = text-of(item.at(key, default: "")) + +#let link-or-text(label, url) = { + if url == "" { label } else { link(url)[#label] } +} + +#let linked-entry-label(entry, label) = { + link-or-text(label, text-of-item(entry, "url")) +} + +#let bullets-of(entry) = { + list-of(entry.at("bullets", default: ())) + .map(item => text-of(item)) + .filter(item => item != "") +} + +// --------------------------------------------------------------------------- +// Colours & metrics +// --------------------------------------------------------------------------- + +#let accent = rgb("#4a7c8f") +#let sidebar-bg = rgb("#e8ecee") +#let sidebar-width = 30% +#let main-width = 70% + +// --------------------------------------------------------------------------- +// Page setup +// --------------------------------------------------------------------------- + +#set page( + paper: "a4", + margin: (top: 0pt, bottom: 0pt, left: 0pt, right: 0pt), +) +#set text(font: "Libertinus Serif", size: 10pt, lang: "en") +#set par(leading: 0.55em) +#show link: set text(fill: accent) + +// --------------------------------------------------------------------------- +// Data extraction +// --------------------------------------------------------------------------- + +#let picture = with-default(source.at("picture", default: (:)), (:)) +#let picture-path = text-of(picture.at("renderPath", default: "")) +#let picture-hidden = with-default(picture.at("hidden", default: true), true) +#let picture-size = with-default(picture.at("size", default: 96), 96) +#let section-titles = with-default(source.at("sectionTitles", default: (:)), (:)) + +#let contact-items = list-of(source.at("contactItems", default: ())) +#let profile-items = list-of(source.at("profileItems", default: ())) +#let custom-field-items = list-of(source.at("customFieldItems", default: ())) + +#let is-email(item) = { + let t = text-of-item(item, "text") + let u = text-of-item(item, "url") + t.contains("@") or u.starts-with("mailto:") +} +#let is-phone(item) = { + text-of-item(item, "url") == "" and not is-email(item) +} + +// --------------------------------------------------------------------------- +// Sidebar section helper +// --------------------------------------------------------------------------- + +#let sidebar-section(title, body) = { + v(10pt) + align(center)[ + #text(weight: "bold", size: 11pt, tracking: 1.5pt)[#upper(title)] + ] + v(4pt) + line(length: 60%, stroke: 0.4pt + accent) + v(4pt) + body +} + +// --------------------------------------------------------------------------- +// Sidebar content +// --------------------------------------------------------------------------- + +#let sidebar-content = { + set text(size: 9pt) + set align(center) + + // Name + v(22pt) + text(size: 22pt, weight: "bold", tracking: 3pt)[ + #upper(text-of(source.at("name", default: ""))) + ] + v(10pt) + + // Photo + if picture-path != "" and picture-hidden == false { + let img-size = calc.max(48, calc.min(picture-size, 140)) * 1pt + box( + clip: true, + radius: 50%, + width: img-size, + height: img-size, + image(picture-path, width: img-size), + ) + v(8pt) + } + + // Headline / tagline + let headline = text-of(source.at("headline", default: "")) + if headline != "" { + text(style: "italic", size: 10pt)[#headline] + v(2pt) + } + + // Education — show degree(s) as a brief tagline under the headline + let education = list-of(source.at("education", default: ())) + for entry in education { + let degree = text-of-item(entry, "subtitle") + if degree != "" { + text(style: "italic", size: 9pt)[#degree] + v(2pt) + } + } + + // Profile / Summary + let summary-text = text-of(source.at("summary", default: "")) + if summary-text != "" { + sidebar-section( + text-of(section-titles.at("summary", default: "Profile")), + { + set align(center) + set text(size: 8.5pt) + text(style: "italic")[#summary-text] + }, + ) + } + + // Skills + let skill-groups = list-of(source.at("skillGroups", default: ())) + if skill-groups.len() > 0 { + sidebar-section( + text-of(section-titles.at("skills", default: "Skills")), + { + set align(left) + set text(size: 8.5pt) + for group in skill-groups { + text(weight: "bold")[#text-of-item(group, "name")] + linebreak() + let kws = list-of(group.at("keywords", default: ())) + text[#kws.join(", ")] + v(4pt) + } + }, + ) + } + + // Languages + let languages = list-of(source.at("languages", default: ())) + if languages.len() > 0 { + sidebar-section( + text-of(section-titles.at("languages", default: "Languages")), + { + set align(left) + set text(size: 8.5pt) + for item in languages { + let fluency = text-of-item(item, "fluency") + if fluency != "" { + [*#text-of-item(item, "language"):* #fluency] + } else { + [*#text-of-item(item, "language")*] + } + linebreak() + } + }, + ) + } + + // Interests + let interests = list-of(source.at("interests", default: ())) + if interests.len() > 0 { + sidebar-section( + text-of(section-titles.at("interests", default: "Interests")), + { + set align(left) + set text(size: 8.5pt) + for item in interests { + let kws = list-of(item.at("keywords", default: ())) + if kws.len() > 0 { + [*#text-of-item(item, "name"):* #kws.join(", ")] + } else { + [*#text-of-item(item, "name")*] + } + linebreak() + } + }, + ) + } + + // Contact + sidebar-section( + text-of(section-titles.at("contact", default: "Contact")), + { + set align(center) + set text(size: 8.5pt) + let location = text-of(source.at("location", default: "")) + if location != "" { + text[#location] + linebreak() + v(2pt) + } + for item in contact-items { + link-or-text(text-of-item(item, "text"), text-of-item(item, "url")) + linebreak() + } + }, + ) + + // Profiles + if profile-items.len() > 0 { + sidebar-section( + text-of(section-titles.at("profiles", default: "Profiles")), + { + set align(left) + set text(size: 8.5pt) + for item in profile-items { + let lbl = text-of-item(item, "username") + let network = text-of-item(item, "network") + let url = text-of-item(item, "url") + let display = if lbl != "" { lbl } else if network != "" { network } else { url } + [*#network:* ] + link-or-text(display, url) + linebreak() + } + }, + ) + } +} + +// --------------------------------------------------------------------------- +// Main-area section heading +// --------------------------------------------------------------------------- + +#let main-section(title) = { + v(8pt) + text(size: 14pt, weight: "bold", fill: accent, tracking: 0.5pt)[#upper(title)] + v(-1pt) + line(length: 100%, stroke: 0.5pt + accent) + v(4pt) +} + +// --------------------------------------------------------------------------- +// Experience / timeline entry in main area +// --------------------------------------------------------------------------- + +#let main-entry(title, subtitle: "", date: "", location: "", body-content: []) = { + text(size: 12pt, weight: "bold")[#upper(title)] + if subtitle != "" { + [ | ] + text(size: 12pt, weight: "bold")[#upper(subtitle)] + } + linebreak() + if date != "" or location != "" { + text(size: 9pt, tracking: 0.5pt)[ + #upper( + (date, location).filter(x => x != "").join(", "), + ) + ] + linebreak() + } + v(2pt) + set text(size: 9.5pt) + body-content + v(6pt) +} + +// --------------------------------------------------------------------------- +// Main content +// --------------------------------------------------------------------------- + +#let main-content = { + set text(size: 10pt) + + // Professional Experience + let experience = list-of(source.at("experience", default: ())) + if experience.len() > 0 { + main-section(text-of(section-titles.at("experience", default: "Professional Experience"))) + for entry in experience { + let title-text = linked-entry-label(entry, text-of-item(entry, "title")) + let sub = text-of-item(entry, "subtitle") + let date = text-of-item(entry, "date") + let loc = text-of-item(entry, "secondarySubtitle") + let entry-bullets = bullets-of(entry) + main-entry( + title-text, + subtitle: sub, + date: date, + location: loc, + body-content: { + for b in entry-bullets { + set par(leading: 0.5em, first-line-indent: 12pt) + text[#b] + linebreak() + v(2pt) + } + }, + ) + } + } + + // Education (full entries in main area) + let education = list-of(source.at("education", default: ())) + if education.len() > 0 { + main-section(text-of(section-titles.at("education", default: "Education"))) + for entry in education { + let title-text = linked-entry-label(entry, text-of-item(entry, "title")) + let sub = text-of-item(entry, "subtitle") + let date = text-of-item(entry, "date") + let loc = text-of-item(entry, "secondarySubtitle") + let entry-bullets = bullets-of(entry) + main-entry( + title-text, + subtitle: sub, + date: date, + location: loc, + body-content: { + for b in entry-bullets { + text[#b] + linebreak() + } + }, + ) + } + } + + // Projects + let projects = list-of(source.at("projects", default: ())) + if projects.len() > 0 { + main-section(text-of(section-titles.at("projects", default: "Projects"))) + for entry in projects { + let title-text = linked-entry-label(entry, text-of-item(entry, "title")) + let sub = text-of-item(entry, "subtitle") + let date = text-of-item(entry, "date") + let entry-bullets = bullets-of(entry) + main-entry( + title-text, + subtitle: sub, + date: date, + body-content: { + for b in entry-bullets { + text[#b] + linebreak() + } + }, + ) + } + } + + // Awards + let awards = list-of(source.at("awards", default: ())) + if awards.len() > 0 { + main-section(text-of(section-titles.at("awards", default: "Awards"))) + for entry in awards { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + for b in bullets-of(entry) { text[#b]; linebreak() } + }, + ) + } + } + + // Certifications + let certifications = list-of(source.at("certifications", default: ())) + if certifications.len() > 0 { + main-section(text-of(section-titles.at("certifications", default: "Certifications"))) + for entry in certifications { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + for b in bullets-of(entry) { text[#b]; linebreak() } + }, + ) + } + } + + // Publications + let publications = list-of(source.at("publications", default: ())) + if publications.len() > 0 { + main-section(text-of(section-titles.at("publications", default: "Publications"))) + for entry in publications { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + for b in bullets-of(entry) { text[#b]; linebreak() } + }, + ) + } + } + + // Volunteer + let volunteer = list-of(source.at("volunteer", default: ())) + if volunteer.len() > 0 { + main-section(text-of(section-titles.at("volunteer", default: "Volunteer"))) + for entry in volunteer { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + for b in bullets-of(entry) { text[#b]; linebreak() } + }, + ) + } + } + + // References + let references = list-of(source.at("references", default: ())) + if references.len() > 0 { + main-section(text-of(section-titles.at("references", default: "References"))) + for entry in references { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + body-content: { + for b in bullets-of(entry) { text[#b]; linebreak() } + }, + ) + } + } + + // Custom fields + if custom-field-items.len() > 0 { + main-section(text-of(section-titles.at("customFields", default: "Custom Fields"))) + for item in custom-field-items { + let title = text-of-item(item, "title") + let value = text-of-item(item, "text") + let url = text-of-item(item, "url") + if title != "" and title != value { + [*#title:* ] + link-or-text(value, url) + } else { + link-or-text(if title != "" { title } else { value }, url) + } + linebreak() + } + } +} + +// --------------------------------------------------------------------------- +// Page layout — sidebar + main in a two-column grid +// --------------------------------------------------------------------------- + +#grid( + columns: (sidebar-width, main-width), + // Sidebar column + rect( + width: 100%, + height: 100%, + fill: sidebar-bg, + inset: (x: 14pt, y: 0pt), + stroke: none, + )[ + #sidebar-content + ], + // Main column + rect( + width: 100%, + height: 100%, + inset: (x: 22pt, y: 18pt), + stroke: none, + )[ + #main-content + ], +) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/theme.json b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/theme.json new file mode 100644 index 000000000..9d326b1eb --- /dev/null +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/theme.json @@ -0,0 +1,7 @@ +{ + "id": "sidebar-cv", + "label": "Sidebar CV", + "description": "A two-column CV with a tinted sidebar for photo, summary, and contact details.", + "kind": "adapted", + "entrypoint": "main.typ" +} diff --git a/shared/src/generated/typst-themes.ts b/shared/src/generated/typst-themes.ts index 4cd7c0e4c..4be7fb5df 100644 --- a/shared/src/generated/typst-themes.ts +++ b/shared/src/generated/typst-themes.ts @@ -4,6 +4,7 @@ export const TYPST_THEME_VALUES = [ "classic", "clean-print-cv", "compact", + "sidebar-cv", ] as const; export type GeneratedTypstTheme = (typeof TYPST_THEME_VALUES)[number]; @@ -12,6 +13,7 @@ export const TYPST_THEME_LABELS: Record = { classic: "Classic", "clean-print-cv": "Clean Print CV", compact: "Compact", + "sidebar-cv": "Sidebar CV", }; export const TYPST_THEME_DESCRIPTIONS: Record = { @@ -19,6 +21,8 @@ export const TYPST_THEME_DESCRIPTIONS: Record = { "clean-print-cv": "A print-friendly adapted theme powered by @preview/clean-print-cv.", compact: "A denser Jake-style resume layout for fitting more content.", + "sidebar-cv": + "A two-column CV with a tinted sidebar for photo, summary, and contact details.", }; export const TYPST_THEME_KINDS: Record< @@ -28,6 +32,7 @@ export const TYPST_THEME_KINDS: Record< classic: "native", "clean-print-cv": "adapted", compact: "native", + "sidebar-cv": "adapted", }; export const TYPST_THEME_OPTIONS = TYPST_THEME_VALUES.map((value) => ({ From 0c3f2038d1ad37acad424fc585271596d347042c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 12:43:03 +0000 Subject: [PATCH 02/27] refine sidebar-cv template for high-fidelity match with original design - Split name across two lines with wide 8pt tracking - Larger 26pt name, regular weight (not bold) to match original - Bold italic headline at 12pt - Sidebar section headings: 12pt, 2pt tracking, centered gray line - Main section headings: 16pt, 2pt tracking, teal accent - Entry titles: 11pt bold uppercase with pipe separator - Date/location line: 8.5pt small caps - Body text: paragraph style with 14pt first-line indent - Contact block includes full name above address - Sidebar padding and main padding adjusted for balance Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/594f2a8d-264d-4452-b4dc-b72043d0aea0 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/sidebar-cv/main.typ | 483 ++++++++++-------- 1 file changed, 259 insertions(+), 224 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 0d78ed89e..8eafacbf7 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -80,13 +80,13 @@ // --------------------------------------------------------------------------- #let sidebar-section(title, body) = { - v(10pt) + v(14pt) align(center)[ - #text(weight: "bold", size: 11pt, tracking: 1.5pt)[#upper(title)] + #text(weight: "bold", size: 12pt, tracking: 2pt)[#upper(title)] ] - v(4pt) - line(length: 60%, stroke: 0.4pt + accent) - v(4pt) + v(3pt) + align(center)[#line(length: 50%, stroke: 0.5pt + luma(140))] + v(6pt) body } @@ -98,16 +98,22 @@ set text(size: 9pt) set align(center) - // Name - v(22pt) - text(size: 22pt, weight: "bold", tracking: 3pt)[ - #upper(text-of(source.at("name", default: ""))) - ] - v(10pt) + // Name — split first/last on separate lines with wide tracking + v(20pt) + { + let name = text-of(source.at("name", default: "")) + let name-parts = name.split(" ") + let first-name = name-parts.at(0, default: "") + let last-name = name-parts.slice(1).join(" ") + text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(first-name)] + v(4pt) + text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(last-name)] + } + v(16pt) - // Photo + // Photo (circular crop) — only shown when a picture is available if picture-path != "" and picture-hidden == false { - let img-size = calc.max(48, calc.min(picture-size, 140)) * 1pt + let img-size = calc.max(80, calc.min(picture-size, 160)) * 1pt box( clip: true, radius: 50%, @@ -115,98 +121,112 @@ height: img-size, image(picture-path, width: img-size), ) - v(8pt) + v(14pt) } // Headline / tagline - let headline = text-of(source.at("headline", default: "")) - if headline != "" { - text(style: "italic", size: 10pt)[#headline] - v(2pt) + { + let headline = text-of(source.at("headline", default: "")) + if headline != "" { + v(6pt) + text(style: "italic", size: 12pt, weight: "bold")[#headline] + v(4pt) + } } // Education — show degree(s) as a brief tagline under the headline - let education = list-of(source.at("education", default: ())) - for entry in education { - let degree = text-of-item(entry, "subtitle") - if degree != "" { - text(style: "italic", size: 9pt)[#degree] - v(2pt) + { + let education = list-of(source.at("education", default: ())) + for entry in education { + let degree = text-of-item(entry, "subtitle") + if degree != "" { + text(style: "italic", size: 10pt)[#degree] + v(2pt) + } } } // Profile / Summary - let summary-text = text-of(source.at("summary", default: "")) - if summary-text != "" { - sidebar-section( - text-of(section-titles.at("summary", default: "Profile")), - { - set align(center) - set text(size: 8.5pt) - text(style: "italic")[#summary-text] - }, - ) + { + let summary-text = text-of(source.at("summary", default: "")) + if summary-text != "" { + sidebar-section( + text-of(section-titles.at("summary", default: "Profile")), + { + set align(center) + set text(size: 8.5pt) + set par(leading: 0.6em) + text(style: "italic")[#summary-text] + }, + ) + } } // Skills - let skill-groups = list-of(source.at("skillGroups", default: ())) - if skill-groups.len() > 0 { - sidebar-section( - text-of(section-titles.at("skills", default: "Skills")), - { - set align(left) - set text(size: 8.5pt) - for group in skill-groups { - text(weight: "bold")[#text-of-item(group, "name")] - linebreak() - let kws = list-of(group.at("keywords", default: ())) - text[#kws.join(", ")] - v(4pt) - } - }, - ) + { + let skill-groups = list-of(source.at("skillGroups", default: ())) + if skill-groups.len() > 0 { + sidebar-section( + text-of(section-titles.at("skills", default: "Skills")), + { + set align(left) + set text(size: 8.5pt) + for group in skill-groups { + text(weight: "bold")[#text-of-item(group, "name")] + linebreak() + let kws = list-of(group.at("keywords", default: ())) + text[#kws.join(", ")] + v(4pt) + } + }, + ) + } } // Languages - let languages = list-of(source.at("languages", default: ())) - if languages.len() > 0 { - sidebar-section( - text-of(section-titles.at("languages", default: "Languages")), - { - set align(left) - set text(size: 8.5pt) - for item in languages { - let fluency = text-of-item(item, "fluency") - if fluency != "" { - [*#text-of-item(item, "language"):* #fluency] - } else { - [*#text-of-item(item, "language")*] + { + let languages = list-of(source.at("languages", default: ())) + if languages.len() > 0 { + sidebar-section( + text-of(section-titles.at("languages", default: "Languages")), + { + set align(left) + set text(size: 8.5pt) + for item in languages { + let fluency = text-of-item(item, "fluency") + if fluency != "" { + [*#text-of-item(item, "language"):* #fluency] + } else { + [*#text-of-item(item, "language")*] + } + linebreak() } - linebreak() - } - }, - ) + }, + ) + } } // Interests - let interests = list-of(source.at("interests", default: ())) - if interests.len() > 0 { - sidebar-section( - text-of(section-titles.at("interests", default: "Interests")), - { - set align(left) - set text(size: 8.5pt) - for item in interests { - let kws = list-of(item.at("keywords", default: ())) - if kws.len() > 0 { - [*#text-of-item(item, "name"):* #kws.join(", ")] - } else { - [*#text-of-item(item, "name")*] + { + let interests = list-of(source.at("interests", default: ())) + if interests.len() > 0 { + sidebar-section( + text-of(section-titles.at("interests", default: "Interests")), + { + set align(left) + set text(size: 8.5pt) + for item in interests { + let kws = list-of(item.at("keywords", default: ())) + if kws.len() > 0 { + [*#text-of-item(item, "name"):* #kws.join(", ")] + } else { + [*#text-of-item(item, "name")*] + } + linebreak() } - linebreak() - } - }, - ) + }, + ) + } } // Contact @@ -217,9 +237,15 @@ set text(size: 8.5pt) let location = text-of(source.at("location", default: "")) if location != "" { + // Show full name at top of contact block + let name-val = text-of(source.at("name", default: "")) + if name-val != "" { + text(weight: "bold")[#name-val] + linebreak() + } text[#location] linebreak() - v(2pt) + v(4pt) } for item in contact-items { link-or-text(text-of-item(item, "text"), text-of-item(item, "url")) @@ -254,11 +280,11 @@ // --------------------------------------------------------------------------- #let main-section(title) = { - v(8pt) - text(size: 14pt, weight: "bold", fill: accent, tracking: 0.5pt)[#upper(title)] - v(-1pt) - line(length: 100%, stroke: 0.5pt + accent) - v(4pt) + v(6pt) + text(size: 16pt, weight: "bold", fill: accent, tracking: 2pt)[#upper(title)] + v(-2pt) + line(length: 100%, stroke: 0.6pt + accent) + v(6pt) } // --------------------------------------------------------------------------- @@ -266,24 +292,21 @@ // --------------------------------------------------------------------------- #let main-entry(title, subtitle: "", date: "", location: "", body-content: []) = { - text(size: 12pt, weight: "bold")[#upper(title)] + text(size: 11pt, weight: "bold")[#upper(title)] if subtitle != "" { - [ | ] - text(size: 12pt, weight: "bold")[#upper(subtitle)] + text(size: 11pt, weight: "bold")[ | ] + text(size: 11pt, weight: "bold")[#upper(subtitle)] } linebreak() if date != "" or location != "" { - text(size: 9pt, tracking: 0.5pt)[ - #upper( - (date, location).filter(x => x != "").join(", "), - ) - ] + set text(size: 8.5pt) + smallcaps(upper((date, location).filter(x => x != "").join(", "))) linebreak() } - v(2pt) + v(3pt) set text(size: 9.5pt) body-content - v(6pt) + v(8pt) } // --------------------------------------------------------------------------- @@ -294,156 +317,168 @@ set text(size: 10pt) // Professional Experience - let experience = list-of(source.at("experience", default: ())) - if experience.len() > 0 { - main-section(text-of(section-titles.at("experience", default: "Professional Experience"))) - for entry in experience { - let title-text = linked-entry-label(entry, text-of-item(entry, "title")) - let sub = text-of-item(entry, "subtitle") - let date = text-of-item(entry, "date") - let loc = text-of-item(entry, "secondarySubtitle") - let entry-bullets = bullets-of(entry) - main-entry( - title-text, - subtitle: sub, - date: date, - location: loc, - body-content: { - for b in entry-bullets { - set par(leading: 0.5em, first-line-indent: 12pt) - text[#b] - linebreak() - v(2pt) - } - }, - ) + { + let experience = list-of(source.at("experience", default: ())) + if experience.len() > 0 { + main-section(text-of(section-titles.at("experience", default: "Professional Experience"))) + for entry in experience { + let title-text = linked-entry-label(entry, text-of-item(entry, "title")) + let sub = text-of-item(entry, "subtitle") + let date = text-of-item(entry, "date") + let loc = text-of-item(entry, "secondarySubtitle") + let entry-bullets = bullets-of(entry) + main-entry( + title-text, + subtitle: sub, + date: date, + location: loc, + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in entry-bullets { + par[#b] + } + }, + ) + } } } // Education (full entries in main area) - let education = list-of(source.at("education", default: ())) - if education.len() > 0 { - main-section(text-of(section-titles.at("education", default: "Education"))) - for entry in education { - let title-text = linked-entry-label(entry, text-of-item(entry, "title")) - let sub = text-of-item(entry, "subtitle") - let date = text-of-item(entry, "date") - let loc = text-of-item(entry, "secondarySubtitle") - let entry-bullets = bullets-of(entry) - main-entry( - title-text, - subtitle: sub, - date: date, - location: loc, - body-content: { - for b in entry-bullets { - text[#b] - linebreak() - } - }, - ) + { + let education = list-of(source.at("education", default: ())) + if education.len() > 0 { + main-section(text-of(section-titles.at("education", default: "Education"))) + for entry in education { + let title-text = linked-entry-label(entry, text-of-item(entry, "title")) + let sub = text-of-item(entry, "subtitle") + let date = text-of-item(entry, "date") + let loc = text-of-item(entry, "secondarySubtitle") + main-entry( + title-text, + subtitle: sub, + date: date, + location: loc, + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { + par[#b] + } + }, + ) + } } } // Projects - let projects = list-of(source.at("projects", default: ())) - if projects.len() > 0 { - main-section(text-of(section-titles.at("projects", default: "Projects"))) - for entry in projects { - let title-text = linked-entry-label(entry, text-of-item(entry, "title")) - let sub = text-of-item(entry, "subtitle") - let date = text-of-item(entry, "date") - let entry-bullets = bullets-of(entry) - main-entry( - title-text, - subtitle: sub, - date: date, - body-content: { - for b in entry-bullets { - text[#b] - linebreak() - } - }, - ) + { + let projects = list-of(source.at("projects", default: ())) + if projects.len() > 0 { + main-section(text-of(section-titles.at("projects", default: "Projects"))) + for entry in projects { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } // Awards - let awards = list-of(source.at("awards", default: ())) - if awards.len() > 0 { - main-section(text-of(section-titles.at("awards", default: "Awards"))) - for entry in awards { - main-entry( - linked-entry-label(entry, text-of-item(entry, "title")), - subtitle: text-of-item(entry, "subtitle"), - date: text-of-item(entry, "date"), - body-content: { - for b in bullets-of(entry) { text[#b]; linebreak() } - }, - ) + { + let awards = list-of(source.at("awards", default: ())) + if awards.len() > 0 { + main-section(text-of(section-titles.at("awards", default: "Awards"))) + for entry in awards { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } // Certifications - let certifications = list-of(source.at("certifications", default: ())) - if certifications.len() > 0 { - main-section(text-of(section-titles.at("certifications", default: "Certifications"))) - for entry in certifications { - main-entry( - linked-entry-label(entry, text-of-item(entry, "title")), - subtitle: text-of-item(entry, "subtitle"), - date: text-of-item(entry, "date"), - body-content: { - for b in bullets-of(entry) { text[#b]; linebreak() } - }, - ) + { + let certifications = list-of(source.at("certifications", default: ())) + if certifications.len() > 0 { + main-section(text-of(section-titles.at("certifications", default: "Certifications"))) + for entry in certifications { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } // Publications - let publications = list-of(source.at("publications", default: ())) - if publications.len() > 0 { - main-section(text-of(section-titles.at("publications", default: "Publications"))) - for entry in publications { - main-entry( - linked-entry-label(entry, text-of-item(entry, "title")), - subtitle: text-of-item(entry, "subtitle"), - date: text-of-item(entry, "date"), - body-content: { - for b in bullets-of(entry) { text[#b]; linebreak() } - }, - ) + { + let publications = list-of(source.at("publications", default: ())) + if publications.len() > 0 { + main-section(text-of(section-titles.at("publications", default: "Publications"))) + for entry in publications { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } // Volunteer - let volunteer = list-of(source.at("volunteer", default: ())) - if volunteer.len() > 0 { - main-section(text-of(section-titles.at("volunteer", default: "Volunteer"))) - for entry in volunteer { - main-entry( - linked-entry-label(entry, text-of-item(entry, "title")), - subtitle: text-of-item(entry, "subtitle"), - date: text-of-item(entry, "date"), - body-content: { - for b in bullets-of(entry) { text[#b]; linebreak() } - }, - ) + { + let volunteer = list-of(source.at("volunteer", default: ())) + if volunteer.len() > 0 { + main-section(text-of(section-titles.at("volunteer", default: "Volunteer"))) + for entry in volunteer { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + date: text-of-item(entry, "date"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } // References - let references = list-of(source.at("references", default: ())) - if references.len() > 0 { - main-section(text-of(section-titles.at("references", default: "References"))) - for entry in references { - main-entry( - linked-entry-label(entry, text-of-item(entry, "title")), - subtitle: text-of-item(entry, "subtitle"), - body-content: { - for b in bullets-of(entry) { text[#b]; linebreak() } - }, - ) + { + let references = list-of(source.at("references", default: ())) + if references.len() > 0 { + main-section(text-of(section-titles.at("references", default: "References"))) + for entry in references { + main-entry( + linked-entry-label(entry, text-of-item(entry, "title")), + subtitle: text-of-item(entry, "subtitle"), + body-content: { + set par(leading: 0.55em, first-line-indent: 14pt, spacing: 0.7em) + for b in bullets-of(entry) { par[#b] } + }, + ) + } } } @@ -476,7 +511,7 @@ width: 100%, height: 100%, fill: sidebar-bg, - inset: (x: 14pt, y: 0pt), + inset: (x: 16pt, y: 0pt), stroke: none, )[ #sidebar-content @@ -485,7 +520,7 @@ rect( width: 100%, height: 100%, - inset: (x: 22pt, y: 18pt), + inset: (x: 24pt, y: 20pt), stroke: none, )[ #main-content From d3b468169e1ebabfcd9848d985e8c0693fe802e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:34:43 +0000 Subject: [PATCH 03/27] fix(sidebar-cv): uppercase label before link wrap, guard last-name render, add render test Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/5cdbc21b-75a7-404a-b924-11244bd099c5 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/sidebar-cv/main.typ | 10 +++-- .../services/resume-renderer/typst.test.ts | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 8eafacbf7..6cd3898ff 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -22,7 +22,7 @@ } #let linked-entry-label(entry, label) = { - link-or-text(label, text-of-item(entry, "url")) + link-or-text(upper(label), text-of-item(entry, "url")) } #let bullets-of(entry) = { @@ -106,8 +106,10 @@ let first-name = name-parts.at(0, default: "") let last-name = name-parts.slice(1).join(" ") text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(first-name)] - v(4pt) - text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(last-name)] + if last-name != "" { + v(4pt) + text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(last-name)] + } } v(16pt) @@ -292,7 +294,7 @@ // --------------------------------------------------------------------------- #let main-entry(title, subtitle: "", date: "", location: "", body-content: []) = { - text(size: 11pt, weight: "bold")[#upper(title)] + text(size: 11pt, weight: "bold")[#title] if subtitle != "" { text(size: 11pt, weight: "bold")[ | ] text(size: 11pt, weight: "bold")[#upper(subtitle)] diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index 3143138ae..b6dd6cc57 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -356,6 +356,45 @@ describe("typst resume renderer", () => { }, ); + it.skipIf(!typstAvailable())( + "renders the sidebar-cv theme when typst is installed", + async () => { + const tempDir = await createTempDir(); + tempDirs.push(tempDir); + const outputPath = join(tempDir, "sidebar-cv.pdf"); + + await renderTypstPdf({ + document: { + ...baseDocument, + profileItems: [ + { + network: "LinkedIn", + username: "janedoe", + url: "https://linkedin.com/in/janedoe", + }, + ], + languages: [{ language: "English", fluency: "Native", level: 5 }], + certifications: [ + { + title: "AWS Solutions Architect", + subtitle: "Amazon", + date: "2023", + bullets: ["Professional level"], + }, + ], + }, + outputPath, + jobId: "job-render-sidebar-cv", + typstTheme: "sidebar-cv", + }); + + const stats = spawnSync("sh", ["-lc", `test -s "${outputPath}"`], { + stdio: "ignore", + }); + expect(stats.status).toBe(0); + }, + ); + it.skipIf(!typstAvailable())( "renders the clean-print-cv theme when typst is installed", async () => { From af00832dfbe5bbbb64fe06198e3e27867c86bb74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:01:57 +0000 Subject: [PATCH 04/27] fix typst picture path normalization root cause Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/e3118850-ef0c-4c4f-8d81-c09166de85dc Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../services/resume-renderer/typst.test.ts | 27 ++++++++++++ .../server/services/resume-renderer/typst.ts | 41 +++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index b6dd6cc57..615d003be 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -9,6 +9,7 @@ import { buildTypstDocument, getTypstBinary, getTypstTemplatePath, + normalizeTypstDocumentPicturePath, readTypstTemplate, readTypstTheme, readTypstThemeManifest, @@ -313,6 +314,32 @@ describe("typst resume renderer", () => { expect(typst).toContain("\\#hashes, \\*stars\\*, and \\[brackets\\]"); }); + it("normalizes absolute picture paths under compile cwd for Typst", () => { + const compileCwd = join(tmpdir(), "job-ops-resume-render-path-test"); + const normalizedDocument = normalizeTypstDocumentPicturePath( + { + ...baseDocument, + picture: { + url: "https://jane.dev/photo.png", + assetId: null, + renderPath: join(compileCwd, "resume-picture.png"), + hidden: false, + size: 88, + rotation: 0, + aspectRatio: 1, + borderRadius: 0, + borderColor: "", + borderWidth: 0, + shadowColor: "", + shadowWidth: 0, + }, + }, + compileCwd, + ); + + expect(normalizedDocument.picture?.renderPath).toBe("resume-picture.png"); + }); + it("fails with a helpful error when typst is unavailable", async () => { const previous = process.env.TYPST_BIN; process.env.TYPST_BIN = "/definitely/missing/typst"; diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index badbee72b..3ab213336 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, normalize } from "node:path"; +import { isAbsolute, join, normalize, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { logger } from "@infra/logger"; import { sanitizeUnknown } from "@infra/sanitize"; @@ -426,6 +426,37 @@ function buildAdaptedTypstDocument(template: string): string { return replaceSharedTypstPlaceholders(template); } +export function normalizeTypstDocumentPicturePath( + document: LatexResumeDocument, + compileCwd: string, +): LatexResumeDocument { + const picture = document.picture; + if (!picture?.renderPath) return document; + + const normalizedPath = normalize(picture.renderPath); + if (!isAbsolute(normalizedPath)) return document; + + const relativePath = relative(compileCwd, normalizedPath); + if ( + !relativePath || + relativePath.startsWith("..") || + isAbsolute(relativePath) + ) { + return document; + } + + const typstPath = relativePath.replaceAll("\\", "/"); + if (typstPath === picture.renderPath) return document; + + return { + ...document, + picture: { + ...picture, + renderPath: typstPath, + }, + }; +} + export function buildTypstDocument( document: LatexResumeDocument, template: string, @@ -612,6 +643,10 @@ export const typstResumeRenderer: ResumeRenderer = { document, tempDir, ); + const typstDocument = normalizeTypstDocumentPicturePath( + renderableDocument, + tempDir, + ); let typst: string; if (manifest.kind === "native") { if (!tokens) { @@ -619,14 +654,14 @@ export const typstResumeRenderer: ResumeRenderer = { `Typst theme ${typstTheme} is missing native tokens.`, ); } - typst = buildTypstDocument(renderableDocument, template, tokens); + typst = buildTypstDocument(typstDocument, template, tokens); } else { typst = buildAdaptedTypstDocument(template); } await writeFile( resumeDataPath, - JSON.stringify(renderableDocument), + JSON.stringify(typstDocument), "utf8", ); await writeFile(typPath, typst, "utf8"); From 63c77276ad1b1248441f7bf10789f1e4c7facd52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:06:06 +0000 Subject: [PATCH 05/27] fix typst image path root cause for resume editor Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/e3118850-ef0c-4c4f-8d81-c09166de85dc Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/server/services/resume-renderer/typst.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 3ab213336..08636eb82 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -659,11 +659,7 @@ export const typstResumeRenderer: ResumeRenderer = { typst = buildAdaptedTypstDocument(template); } - await writeFile( - resumeDataPath, - JSON.stringify(typstDocument), - "utf8", - ); + await writeFile(resumeDataPath, JSON.stringify(typstDocument), "utf8"); await writeFile(typPath, typst, "utf8"); await runTypst({ cwd: tempDir, From 0c43c2078fe83a3c07e73332aefe1ff1e46eac59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:08:18 +0000 Subject: [PATCH 06/27] harden typst path normalization for cross-platform separators Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/e3118850-ef0c-4c4f-8d81-c09166de85dc Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/server/services/resume-renderer/typst.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 08636eb82..eb70944b8 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -445,7 +445,7 @@ export function normalizeTypstDocumentPicturePath( return document; } - const typstPath = relativePath.replaceAll("\\", "/"); + const typstPath = relativePath.split(/[\\/]+/).join("/"); if (typstPath === picture.renderPath) return document; return { From be55273b299a9a1ce3edae7657735712c200b326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:10:23 +0000 Subject: [PATCH 07/27] address review feedback on typst path normalization tests Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/e3118850-ef0c-4c4f-8d81-c09166de85dc Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../src/server/services/resume-renderer/typst.test.ts | 5 +++-- orchestrator/src/server/services/resume-renderer/typst.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index 615d003be..26d77668e 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -314,8 +314,9 @@ describe("typst resume renderer", () => { expect(typst).toContain("\\#hashes, \\*stars\\*, and \\[brackets\\]"); }); - it("normalizes absolute picture paths under compile cwd for Typst", () => { - const compileCwd = join(tmpdir(), "job-ops-resume-render-path-test"); + it("normalizes absolute picture paths under compile cwd for Typst", async () => { + const compileCwd = await createTempDir(); + tempDirs.push(compileCwd); const normalizedDocument = normalizeTypstDocumentPicturePath( { ...baseDocument, diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index eb70944b8..0b91974d5 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { isAbsolute, join, normalize, relative } from "node:path"; +import { isAbsolute, join, normalize, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { logger } from "@infra/logger"; import { sanitizeUnknown } from "@infra/sanitize"; @@ -445,7 +445,7 @@ export function normalizeTypstDocumentPicturePath( return document; } - const typstPath = relativePath.split(/[\\/]+/).join("/"); + const typstPath = relativePath.split(sep).join("/"); if (typstPath === picture.renderPath) return document; return { From eddbb8237111fdb63f26c65ce456a7d73108dcec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:34:41 +0000 Subject: [PATCH 08/27] apply typst style metadata for colors and fonts Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/b170f5ab-08d8-4b0e-b5df-5e3a42fa5364 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../services/resume-renderer/document.test.ts | 28 ++++++++ .../services/resume-renderer/document.ts | 64 +++++++++++++++++++ .../server/services/resume-renderer/types.ts | 13 ++++ .../typst-themes/classic/main.typ | 12 ++-- .../typst-themes/clean-print-cv/main.typ | 3 + .../typst-themes/compact/main.typ | 12 ++-- .../typst-themes/sidebar-cv/main.typ | 11 ++-- .../services/resume-renderer/typst.test.ts | 28 ++++++++ .../server/services/resume-renderer/typst.ts | 39 +++++++++-- 9 files changed, 188 insertions(+), 22 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/document.test.ts b/orchestrator/src/server/services/resume-renderer/document.test.ts index b569f7a12..5acab8ddd 100644 --- a/orchestrator/src/server/services/resume-renderer/document.test.ts +++ b/orchestrator/src/server/services/resume-renderer/document.test.ts @@ -213,6 +213,23 @@ describe("normalizeResumeJsonToLatexDocument", () => { ], }, }, + metadata: { + design: { + colors: { + primary: "rgba(74, 124, 143, 1)", + text: "#1f2937", + background: "rgb(245, 247, 250)", + }, + }, + typography: { + body: { + fontFamily: "Inter", + }, + heading: { + fontFamily: "Playfair Display", + }, + }, + }, }); expect(document.picture?.assetId).toBe("asset-1"); @@ -239,6 +256,17 @@ describe("normalizeResumeJsonToLatexDocument", () => { expect(document.references).toHaveLength(1); expect(document.sectionTitles?.profiles).toBe("Links"); expect(document.sectionTitles?.summary).toBe("About"); + expect(document.style).toEqual({ + colors: { + primaryHex: "#4a7c8f", + textHex: "#1f2937", + backgroundHex: "#f5f7fa", + }, + typography: { + bodyFontFamily: "Inter", + headingFontFamily: "Playfair Display", + }, + }); }); it("respects hidden sections and items", () => { diff --git a/orchestrator/src/server/services/resume-renderer/document.ts b/orchestrator/src/server/services/resume-renderer/document.ts index 82f02d76c..adc36dbaa 100644 --- a/orchestrator/src/server/services/resume-renderer/document.ts +++ b/orchestrator/src/server/services/resume-renderer/document.ts @@ -110,6 +110,10 @@ function toBoolean(value: unknown, fallback = false): boolean { return typeof value === "boolean" ? value : fallback; } +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + function getByPath(source: RecordLike, path: string): unknown { return path.split(".").reduce((current, segment) => { if (!current || typeof current !== "object" || Array.isArray(current)) { @@ -207,6 +211,65 @@ function getCustomFieldsTitle( return toText(basics.customFieldsTitle).trim() || titles.customFields; } +function componentToHex(value: number): string { + return clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"); +} + +function normalizeColorToHex(value: unknown, fallbackHex: string): string { + const color = toText(value).trim(); + if (!color) return fallbackHex; + + const shortHex = color.match(/^#([0-9a-f]{3})$/i); + if (shortHex) { + const [r, g, b] = shortHex[1].split(""); + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + + const longHex = color.match(/^#([0-9a-f]{6})$/i); + if (longHex) { + return `#${longHex[1].toLowerCase()}`; + } + + const rgbMatch = color.match( + /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*[0-9.]+\s*)?\)$/i, + ); + if (rgbMatch) { + const r = Number(rgbMatch[1]); + const g = Number(rgbMatch[2]); + const b = Number(rgbMatch[3]); + if (![r, g, b].some((part) => Number.isNaN(part))) { + return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`; + } + } + + return fallbackHex; +} + +function buildStyle(resumeJson: RecordLike) { + const metadata = (asRecord(resumeJson.metadata) ?? {}) as RecordLike; + const design = (asRecord(metadata.design) ?? {}) as RecordLike; + const colors = (asRecord(design.colors) ?? {}) as RecordLike; + const typography = (asRecord(metadata.typography) ?? {}) as RecordLike; + const bodyTypography = (asRecord(typography.body) ?? {}) as RecordLike; + const headingTypography = (asRecord(typography.heading) ?? {}) as RecordLike; + + const bodyFontFamily = toText(bodyTypography.fontFamily).trim(); + const headingFontFamily = toText(headingTypography.fontFamily).trim(); + + return { + colors: { + primaryHex: normalizeColorToHex(colors.primary, "#dc2626"), + textHex: normalizeColorToHex(colors.text, "#000000"), + backgroundHex: normalizeColorToHex(colors.background, "#ffffff"), + }, + typography: { + bodyFontFamily: bodyFontFamily || "IBM Plex Serif", + headingFontFamily: + headingFontFamily || bodyFontFamily || "IBM Plex Serif", + }, + }; +} + function buildPicture(resumeJson: RecordLike): LatexResumePicture | null { const picture = (asRecord(resumeJson.picture) ?? {}) as RecordLike; const url = toText(picture.url).trim(); @@ -454,6 +517,7 @@ export function normalizeResumeJsonToLatexDocument( publications: buildPublicationEntries(record), volunteer: buildVolunteerEntries(record), references: buildReferenceEntries(record), + style: buildStyle(record), sectionTitles: { profiles: getSectionTitle(record, "profiles", titles), summary: getSectionTitle(record, "summary", titles), diff --git a/orchestrator/src/server/services/resume-renderer/types.ts b/orchestrator/src/server/services/resume-renderer/types.ts index 0bcd8f7da..0559554dc 100644 --- a/orchestrator/src/server/services/resume-renderer/types.ts +++ b/orchestrator/src/server/services/resume-renderer/types.ts @@ -77,6 +77,18 @@ export interface LatexResumeSectionTitles { references: string; } +export interface LatexResumeStyle { + colors: { + primaryHex: string; + textHex: string; + backgroundHex: string; + }; + typography: { + bodyFontFamily: string; + headingFontFamily: string; + }; +} + export interface LatexResumeDocument { name: string; headline?: string | null; @@ -98,6 +110,7 @@ export interface LatexResumeDocument { volunteer: LatexResumeEntry[]; references: LatexResumeEntry[]; sectionTitles?: LatexResumeSectionTitles; + style?: LatexResumeStyle; } export interface RenderResumePdfArgs { diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/classic/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/classic/main.typ index e56527a7c..9ba77a815 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/classic/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/classic/main.typ @@ -1,18 +1,18 @@ -#set page(paper: "a4", margin: __PAGE_MARGIN__) -#set text(font: "Libertinus Serif", size: __BODY_SIZE__, lang: "en") +#set page(paper: "a4", margin: __PAGE_MARGIN__, fill: __BACKGROUND_COLOR__) +#set text(font: __BODY_FONT__, size: __BODY_SIZE__, lang: "en", fill: __TEXT_COLOR__) #set par(leading: __PAR_LEADING__) -#show link: set text(fill: rgb("#0645ad")) +#show link: set text(fill: __PRIMARY_COLOR__) #show heading.where(level: 1): it => [ #v(__SECTION_TOP__) - #text(size: __SECTION_SIZE__, weight: "bold", fill: rgb("__ACCENT__"))[#it.body] + #text(font: __HEADING_FONT__, size: __SECTION_SIZE__, weight: "bold", fill: __PRIMARY_COLOR__)[#it.body] #v(-2pt) - #line(length: 100%, stroke: __LINE_WIDTH__) + #line(length: 100%, stroke: __LINE_WIDTH__ + __PRIMARY_COLOR__) #v(__SECTION_BOTTOM__) ] #align(center)[ -__PICTURE_BLOCK__ #text(size: __NAME_SIZE__, weight: "bold")[__NAME__] \ +__PICTURE_BLOCK__ #text(font: __HEADING_FONT__, size: __NAME_SIZE__, weight: "bold", fill: __PRIMARY_COLOR__)[__NAME__] \ __HEADLINE_BLOCK____LOCATION_BLOCK____CONTACT_BLOCK__ ] diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/clean-print-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/clean-print-cv/main.typ index 9f4cbd282..95b015882 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/clean-print-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/clean-print-cv/main.typ @@ -1,6 +1,9 @@ #import "@preview/clean-print-cv:0.1.0": * #let source = json(__RESUME_DATA_PATH__) +#set page(fill: __BACKGROUND_COLOR__) +#set text(font: __BODY_FONT__, fill: __TEXT_COLOR__) +#show link: set text(fill: __PRIMARY_COLOR__) #let with-default(value, fallback) = { if value == none { diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/compact/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/compact/main.typ index e56527a7c..9ba77a815 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/compact/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/compact/main.typ @@ -1,18 +1,18 @@ -#set page(paper: "a4", margin: __PAGE_MARGIN__) -#set text(font: "Libertinus Serif", size: __BODY_SIZE__, lang: "en") +#set page(paper: "a4", margin: __PAGE_MARGIN__, fill: __BACKGROUND_COLOR__) +#set text(font: __BODY_FONT__, size: __BODY_SIZE__, lang: "en", fill: __TEXT_COLOR__) #set par(leading: __PAR_LEADING__) -#show link: set text(fill: rgb("#0645ad")) +#show link: set text(fill: __PRIMARY_COLOR__) #show heading.where(level: 1): it => [ #v(__SECTION_TOP__) - #text(size: __SECTION_SIZE__, weight: "bold", fill: rgb("__ACCENT__"))[#it.body] + #text(font: __HEADING_FONT__, size: __SECTION_SIZE__, weight: "bold", fill: __PRIMARY_COLOR__)[#it.body] #v(-2pt) - #line(length: 100%, stroke: __LINE_WIDTH__) + #line(length: 100%, stroke: __LINE_WIDTH__ + __PRIMARY_COLOR__) #v(__SECTION_BOTTOM__) ] #align(center)[ -__PICTURE_BLOCK__ #text(size: __NAME_SIZE__, weight: "bold")[__NAME__] \ +__PICTURE_BLOCK__ #text(font: __HEADING_FONT__, size: __NAME_SIZE__, weight: "bold", fill: __PRIMARY_COLOR__)[__NAME__] \ __HEADLINE_BLOCK____LOCATION_BLOCK____CONTACT_BLOCK__ ] diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 6cd3898ff..103ce6336 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -35,8 +35,8 @@ // Colours & metrics // --------------------------------------------------------------------------- -#let accent = rgb("#4a7c8f") -#let sidebar-bg = rgb("#e8ecee") +#let accent = __PRIMARY_COLOR__ +#let sidebar-bg = __SIDEBAR_BG_COLOR__ #let sidebar-width = 30% #let main-width = 70% @@ -47,8 +47,9 @@ #set page( paper: "a4", margin: (top: 0pt, bottom: 0pt, left: 0pt, right: 0pt), + fill: __BACKGROUND_COLOR__, ) -#set text(font: "Libertinus Serif", size: 10pt, lang: "en") +#set text(font: __BODY_FONT__, size: 10pt, lang: "en", fill: __TEXT_COLOR__) #set par(leading: 0.55em) #show link: set text(fill: accent) @@ -105,10 +106,10 @@ let name-parts = name.split(" ") let first-name = name-parts.at(0, default: "") let last-name = name-parts.slice(1).join(" ") - text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(first-name)] + text(font: __HEADING_FONT__, size: 26pt, weight: "regular", tracking: 8pt, fill: accent)[#upper(first-name)] if last-name != "" { v(4pt) - text(size: 26pt, weight: "regular", tracking: 8pt)[#upper(last-name)] + text(font: __HEADING_FONT__, size: 26pt, weight: "regular", tracking: 8pt, fill: accent)[#upper(last-name)] } } v(16pt) diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index 26d77668e..8920475be 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -187,6 +187,34 @@ describe("typst resume renderer", () => { expect(typst).toContain('#let resume = json("resume-data.json")'); }); + it("applies document style colors and fonts in native templates", async () => { + const tokens = await readNativeThemeTokens("classic"); + const typst = buildTypstDocument( + { + ...baseDocument, + style: { + colors: { + primaryHex: "#4a7c8f", + textHex: "#1f2937", + backgroundHex: "#f5f7fa", + }, + typography: { + bodyFontFamily: "Inter", + headingFontFamily: "Playfair Display", + }, + }, + }, + "__BODY_FONT__\n__HEADING_FONT__\n__PRIMARY_COLOR__\n__TEXT_COLOR__\n__BACKGROUND_COLOR__", + tokens, + ); + + expect(typst).toContain('"Inter"'); + expect(typst).toContain('"Playfair Display"'); + expect(typst).toContain('rgb("#4a7c8f")'); + expect(typst).toContain('rgb("#1f2937")'); + expect(typst).toContain('rgb("#f5f7fa")'); + }); + it("keeps links in the clean-print-cv adapter", async () => { const template = await readTypstTemplate("clean-print-cv"); diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 0b91974d5..c98eb2bbb 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -422,8 +422,34 @@ function replaceSharedTypstPlaceholders(template: string): string { ); } -function buildAdaptedTypstDocument(template: string): string { - return replaceSharedTypstPlaceholders(template); +function replaceStylePlaceholders( + template: string, + document: LatexResumeDocument, +): string { + const style = document.style; + const bodyFont = style?.typography.bodyFontFamily || "IBM Plex Serif"; + const headingFont = style?.typography.headingFontFamily || bodyFont; + const primaryHex = style?.colors.primaryHex || "#202020"; + const textHex = style?.colors.textHex || "#000000"; + const backgroundHex = style?.colors.backgroundHex || "#ffffff"; + + return template + .replaceAll("__BODY_FONT__", JSON.stringify(bodyFont)) + .replaceAll("__HEADING_FONT__", JSON.stringify(headingFont)) + .replaceAll("__PRIMARY_COLOR__", `rgb("${primaryHex}")`) + .replaceAll("__TEXT_COLOR__", `rgb("${textHex}")`) + .replaceAll("__BACKGROUND_COLOR__", `rgb("${backgroundHex}")`) + .replaceAll("__SIDEBAR_BG_COLOR__", `rgb("${backgroundHex}")`); +} + +function buildAdaptedTypstDocument( + document: LatexResumeDocument, + template: string, +): string { + return replaceStylePlaceholders( + replaceSharedTypstPlaceholders(template), + document, + ); } export function normalizeTypstDocumentPicturePath( @@ -465,7 +491,7 @@ export function buildTypstDocument( const titles = document.sectionTitles ?? getLatexResumeSectionTitles(); const pictureBlock = renderPictureBlock(document); const headlineBlock = document.headline - ? ` #text(size: ${tokens.headlineSize})[${escapeTypstText(document.headline)}] \\\n` + ? ` #text(font: __HEADING_FONT__, size: ${tokens.headlineSize}, fill: __TEXT_COLOR__)[${escapeTypstText(document.headline)}] \\\n` : ""; const locationBlock = renderLocationBlock(document); const contactBlock = @@ -531,7 +557,10 @@ export function buildTypstDocument( .filter(Boolean) .join("\n\n"); - return replaceSharedTypstPlaceholders(template) + return replaceStylePlaceholders( + replaceSharedTypstPlaceholders(template), + document, + ) .replace("__PAGE_MARGIN__", tokens.pageMargin) .replace("__BODY_SIZE__", tokens.bodySize) .replace("__PAR_LEADING__", tokens.parLeading) @@ -656,7 +685,7 @@ export const typstResumeRenderer: ResumeRenderer = { } typst = buildTypstDocument(typstDocument, template, tokens); } else { - typst = buildAdaptedTypstDocument(template); + typst = buildAdaptedTypstDocument(typstDocument, template); } await writeFile(resumeDataPath, JSON.stringify(typstDocument), "utf8"); From 7715cb4eb00600af14f0a2869dcfab7fa21ad37f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:41:07 +0000 Subject: [PATCH 09/27] add edge-case coverage for typst style and path normalization Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/b170f5ab-08d8-4b0e-b5df-5e3a42fa5364 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../services/resume-renderer/document.test.ts | 42 +++++++++++ .../services/resume-renderer/document.ts | 2 +- .../services/resume-renderer/typst.test.ts | 72 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/orchestrator/src/server/services/resume-renderer/document.test.ts b/orchestrator/src/server/services/resume-renderer/document.test.ts index 5acab8ddd..e208ac98c 100644 --- a/orchestrator/src/server/services/resume-renderer/document.test.ts +++ b/orchestrator/src/server/services/resume-renderer/document.test.ts @@ -293,4 +293,46 @@ describe("normalizeResumeJsonToLatexDocument", () => { expect(document.awards).toHaveLength(1); expect(document.awards[0]?.title).toBe("Visible award"); }); + + it("normalizes design colors to hex with safe fallbacks", () => { + const shortHex = normalizeResumeJsonToLatexDocument({ + basics: { name: "Jane Doe" }, + metadata: { design: { colors: { primary: "#abc" } } }, + }); + expect(shortHex.style?.colors.primaryHex).toBe("#aabbcc"); + + const rgb = normalizeResumeJsonToLatexDocument({ + basics: { name: "Jane Doe" }, + metadata: { + design: { + colors: { + primary: "rgba(74, 124, 143, 1)", + text: "rgb(-20, 260, 12.7)", + background: "#f5f7fa", + }, + }, + }, + }); + expect(rgb.style?.colors.primaryHex).toBe("#4a7c8f"); + expect(rgb.style?.colors.textHex).toBe("#00ff0d"); + expect(rgb.style?.colors.backgroundHex).toBe("#f5f7fa"); + + const fallback = normalizeResumeJsonToLatexDocument({ + basics: { name: "Jane Doe" }, + metadata: { + design: { + colors: { + primary: "not-a-color", + text: "", + background: "wat", + }, + }, + }, + }); + expect(fallback.style?.colors).toEqual({ + primaryHex: "#dc2626", + textHex: "#000000", + backgroundHex: "#ffffff", + }); + }); }); diff --git a/orchestrator/src/server/services/resume-renderer/document.ts b/orchestrator/src/server/services/resume-renderer/document.ts index adc36dbaa..9938b1a69 100644 --- a/orchestrator/src/server/services/resume-renderer/document.ts +++ b/orchestrator/src/server/services/resume-renderer/document.ts @@ -231,7 +231,7 @@ function normalizeColorToHex(value: unknown, fallbackHex: string): string { } const rgbMatch = color.match( - /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*[0-9.]+\s*)?\)$/i, + /^rgba?\(\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)(?:\s*,\s*[0-9.]+\s*)?\)$/i, ); if (rgbMatch) { const r = Number(rgbMatch[1]); diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index 8920475be..bb7971013 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -215,6 +215,20 @@ describe("typst resume renderer", () => { expect(typst).toContain('rgb("#f5f7fa")'); }); + it("falls back to default style placeholders when style metadata is missing", async () => { + const tokens = await readNativeThemeTokens("classic"); + const typst = buildTypstDocument( + baseDocument, + "__BODY_FONT__\n__HEADING_FONT__\n__PRIMARY_COLOR__\n__TEXT_COLOR__\n__BACKGROUND_COLOR__", + tokens, + ); + + expect(typst).toContain('"IBM Plex Serif"'); + expect(typst).toContain('rgb("#202020")'); + expect(typst).toContain('rgb("#000000")'); + expect(typst).toContain('rgb("#ffffff")'); + }); + it("keeps links in the clean-print-cv adapter", async () => { const template = await readTypstTemplate("clean-print-cv"); @@ -369,6 +383,64 @@ describe("typst resume renderer", () => { expect(normalizedDocument.picture?.renderPath).toBe("resume-picture.png"); }); + it("keeps document unchanged when picture is missing", () => { + const normalizedDocument = normalizeTypstDocumentPicturePath( + { ...baseDocument, picture: null }, + join(tmpdir(), "job-ops-resume-render-path-test"), + ); + expect(normalizedDocument).toEqual({ ...baseDocument, picture: null }); + }); + + it("keeps relative picture paths unchanged", () => { + const normalizedDocument = normalizeTypstDocumentPicturePath( + { + ...baseDocument, + picture: { + url: "https://jane.dev/photo.png", + assetId: null, + renderPath: "resume-picture.png", + hidden: false, + size: 88, + rotation: 0, + aspectRatio: 1, + borderRadius: 0, + borderColor: "", + borderWidth: 0, + shadowColor: "", + shadowWidth: 0, + }, + }, + join(tmpdir(), "job-ops-resume-render-path-test"), + ); + expect(normalizedDocument.picture?.renderPath).toBe("resume-picture.png"); + }); + + it("does not rewrite absolute picture paths outside compile cwd", () => { + const compileCwd = join(tmpdir(), "job-ops-resume-render-path-test"); + const outsidePath = join(tmpdir(), "somewhere-else", "resume-picture.png"); + const normalizedDocument = normalizeTypstDocumentPicturePath( + { + ...baseDocument, + picture: { + url: "https://jane.dev/photo.png", + assetId: null, + renderPath: outsidePath, + hidden: false, + size: 88, + rotation: 0, + aspectRatio: 1, + borderRadius: 0, + borderColor: "", + borderWidth: 0, + shadowColor: "", + shadowWidth: 0, + }, + }, + compileCwd, + ); + expect(normalizedDocument.picture?.renderPath).toBe(outsidePath); + }); + it("fails with a helpful error when typst is unavailable", async () => { const previous = process.env.TYPST_BIN; process.env.TYPST_BIN = "/definitely/missing/typst"; From bee586b1630271fb65e6f59f0ca86d172c6f8ab1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:46:54 +0000 Subject: [PATCH 10/27] tighten rgb color parsing for style normalization Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/b170f5ab-08d8-4b0e-b5df-5e3a42fa5364 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/server/services/resume-renderer/document.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/server/services/resume-renderer/document.ts b/orchestrator/src/server/services/resume-renderer/document.ts index 9938b1a69..688c2e557 100644 --- a/orchestrator/src/server/services/resume-renderer/document.ts +++ b/orchestrator/src/server/services/resume-renderer/document.ts @@ -231,7 +231,7 @@ function normalizeColorToHex(value: unknown, fallbackHex: string): string { } const rgbMatch = color.match( - /^rgba?\(\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)(?:\s*,\s*[0-9.]+\s*)?\)$/i, + /^rgba?\(\s*(-?[0-9]+(?:\.[0-9]+)?)\s*,\s*(-?[0-9]+(?:\.[0-9]+)?)\s*,\s*(-?[0-9]+(?:\.[0-9]+)?)(?:\s*,\s*[0-9]+(?:\.[0-9]+)?\s*)?\)$/i, ); if (rgbMatch) { const r = Number(rgbMatch[1]); From c33f5d0ba65592b062db7558f539a2d12b5bd4ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:57:59 +0000 Subject: [PATCH 11/27] fix(typst): apply replaceStylePlaceholders after all block substitutions to resolve __HEADING_FONT__ in headlineBlock Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/66941e30-cbe6-44db-b58a-545f85c9091b Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../services/resume-renderer/typst.test.ts | 27 +++++++++++++++ .../server/services/resume-renderer/typst.ts | 34 +++++++++---------- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index bb7971013..02fcbebba 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -215,6 +215,33 @@ describe("typst resume renderer", () => { expect(typst).toContain('rgb("#f5f7fa")'); }); + it("resolves style placeholders inside the dynamically-generated headline block", async () => { + const tokens = await readNativeThemeTokens("classic"); + const typst = buildTypstDocument( + { + ...baseDocument, + style: { + colors: { + primaryHex: "#4a7c8f", + textHex: "#aabbcc", + backgroundHex: "#ffffff", + }, + typography: { + bodyFontFamily: "Inter", + headingFontFamily: "Roboto", + }, + }, + }, + "__HEADLINE_BLOCK__", + tokens, + ); + + expect(typst).not.toContain("__HEADING_FONT__"); + expect(typst).not.toContain("__TEXT_COLOR__"); + expect(typst).toContain('"Roboto"'); + expect(typst).toContain('rgb("#aabbcc")'); + }); + it("falls back to default style placeholders when style metadata is missing", async () => { const tokens = await readNativeThemeTokens("classic"); const typst = buildTypstDocument( diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index c98eb2bbb..d3c3e4852 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -558,24 +558,24 @@ export function buildTypstDocument( .join("\n\n"); return replaceStylePlaceholders( - replaceSharedTypstPlaceholders(template), + replaceSharedTypstPlaceholders(template) + .replace("__PAGE_MARGIN__", tokens.pageMargin) + .replace("__BODY_SIZE__", tokens.bodySize) + .replace("__PAR_LEADING__", tokens.parLeading) + .replace("__SECTION_TOP__", tokens.sectionTop) + .replace("__SECTION_SIZE__", tokens.sectionSize) + .replace("__ACCENT__", tokens.accent) + .replace("__LINE_WIDTH__", tokens.lineWidth) + .replace("__SECTION_BOTTOM__", tokens.sectionBottom) + .replace("__NAME_SIZE__", tokens.nameSize) + .replace("__PICTURE_BLOCK__", pictureBlock) + .replace("__NAME__", escapeTypstText(document.name)) + .replace("__HEADLINE_BLOCK__", headlineBlock) + .replace("__LOCATION_BLOCK__", locationBlock) + .replace("__CONTACT_BLOCK__", contactBlock) + .replace("__BODY__", body), document, - ) - .replace("__PAGE_MARGIN__", tokens.pageMargin) - .replace("__BODY_SIZE__", tokens.bodySize) - .replace("__PAR_LEADING__", tokens.parLeading) - .replace("__SECTION_TOP__", tokens.sectionTop) - .replace("__SECTION_SIZE__", tokens.sectionSize) - .replace("__ACCENT__", tokens.accent) - .replace("__LINE_WIDTH__", tokens.lineWidth) - .replace("__SECTION_BOTTOM__", tokens.sectionBottom) - .replace("__NAME_SIZE__", tokens.nameSize) - .replace("__PICTURE_BLOCK__", pictureBlock) - .replace("__NAME__", escapeTypstText(document.name)) - .replace("__HEADLINE_BLOCK__", headlineBlock) - .replace("__LOCATION_BLOCK__", locationBlock) - .replace("__CONTACT_BLOCK__", contactBlock) - .replace("__BODY__", body); + ); } function truncateOutput(value: string): string { From 8167316e003e6d8779a2f816ac1629a5df2f6e89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:03 +0000 Subject: [PATCH 12/27] fix(sidebar-cv): use page background for sidebar so main content flows across pages Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/79257a19-1228-49e8-bf5a-f63e197cd112 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/sidebar-cv/main.typ | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 103ce6336..255d7e40c 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -37,17 +37,35 @@ #let accent = __PRIMARY_COLOR__ #let sidebar-bg = __SIDEBAR_BG_COLOR__ -#let sidebar-width = 30% -#let main-width = 70% +// Fixed absolute width (≈ 30 % of A4 210 mm); page margins require absolute lengths. +#let sidebar-col-width = 63mm // --------------------------------------------------------------------------- -// Page setup +// Page setup — sidebar drawn via background so main content flows across pages // --------------------------------------------------------------------------- #set page( paper: "a4", - margin: (top: 0pt, bottom: 0pt, left: 0pt, right: 0pt), + // Left margin reserves space for the sidebar column; right margin gives breathing room. + margin: (top: 0pt, bottom: 0pt, left: sidebar-col-width, right: 1.5cm), fill: __BACKGROUND_COLOR__, + background: context { + // Tinted sidebar background appears on every page. + place(left + top, + rect(width: sidebar-col-width, height: 100%, fill: sidebar-bg, stroke: none) + ) + // Sidebar content (name, photo, summary, contact…) only on the first page. + if here().page() == 1 { + place(left + top, + box(width: sidebar-col-width, inset: (x: 16pt, y: 0pt))[ + #set text(font: __BODY_FONT__, size: 9pt, lang: "en", fill: __TEXT_COLOR__) + #set par(leading: 0.55em) + #show link: set text(fill: accent) + #sidebar-content + ] + ) + } + }, ) #set text(font: __BODY_FONT__, size: 10pt, lang: "en", fill: __TEXT_COLOR__) #set par(leading: 0.55em) @@ -504,28 +522,10 @@ } // --------------------------------------------------------------------------- -// Page layout — sidebar + main in a two-column grid +// Page layout — sidebar is drawn via page background; main content flows +// naturally across as many pages as needed. // --------------------------------------------------------------------------- -#grid( - columns: (sidebar-width, main-width), - // Sidebar column - rect( - width: 100%, - height: 100%, - fill: sidebar-bg, - inset: (x: 16pt, y: 0pt), - stroke: none, - )[ - #sidebar-content - ], - // Main column - rect( - width: 100%, - height: 100%, - inset: (x: 24pt, y: 20pt), - stroke: none, - )[ - #main-content - ], -) +#pad(x: 24pt, top: 20pt, bottom: 20pt)[ + #main-content +] From b206b7cf504109682d572af51537b2b8a1c0b899 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 21:59:15 +0000 Subject: [PATCH 13/27] fix: address review feedback - sidebar bg color, injection safety, dead accent code, unused helpers Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/c8f2791b-a148-4afc-8540-6dda89897c66 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/classic/theme.json | 3 +-- .../typst-themes/compact/theme.json | 3 +-- .../typst-themes/sidebar-cv/main.typ | 9 -------- .../server/services/resume-renderer/typst.ts | 21 +++++++++++++------ 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/classic/theme.json b/orchestrator/src/server/services/resume-renderer/typst-themes/classic/theme.json index 57717b0c6..03e2a813b 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/classic/theme.json +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/classic/theme.json @@ -15,7 +15,6 @@ "nameSize": "20pt", "headlineSize": "9.5pt", "contactSize": "8.8pt", - "entryMetaSize": "9pt", - "accent": "#202020" + "entryMetaSize": "9pt" } } diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/compact/theme.json b/orchestrator/src/server/services/resume-renderer/typst-themes/compact/theme.json index f300a751f..eb4a7481c 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/compact/theme.json +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/compact/theme.json @@ -15,7 +15,6 @@ "nameSize": "18pt", "headlineSize": "8.5pt", "contactSize": "8pt", - "entryMetaSize": "8pt", - "accent": "#111111" + "entryMetaSize": "8pt" } } diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 255d7e40c..2b325942a 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -85,15 +85,6 @@ #let profile-items = list-of(source.at("profileItems", default: ())) #let custom-field-items = list-of(source.at("customFieldItems", default: ())) -#let is-email(item) = { - let t = text-of-item(item, "text") - let u = text-of-item(item, "url") - t.contains("@") or u.starts-with("mailto:") -} -#let is-phone(item) = { - text-of-item(item, "url") == "" and not is-email(item) -} - // --------------------------------------------------------------------------- // Sidebar section helper // --------------------------------------------------------------------------- diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index d3c3e4852..734d57b25 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -36,7 +36,6 @@ const REQUIRED_NATIVE_TOKEN_KEYS = [ "headlineSize", "contactSize", "entryMetaSize", - "accent", ] as const; export type TypstThemeTokens = Record< @@ -422,6 +421,16 @@ function replaceSharedTypstPlaceholders(template: string): string { ); } +function lightenHex(hex: string, whiteFactor: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const lr = Math.round(r + (255 - r) * whiteFactor); + const lg = Math.round(g + (255 - g) * whiteFactor); + const lb = Math.round(b + (255 - b) * whiteFactor); + return `#${lr.toString(16).padStart(2, "0")}${lg.toString(16).padStart(2, "0")}${lb.toString(16).padStart(2, "0")}`; +} + function replaceStylePlaceholders( template: string, document: LatexResumeDocument, @@ -432,14 +441,15 @@ function replaceStylePlaceholders( const primaryHex = style?.colors.primaryHex || "#202020"; const textHex = style?.colors.textHex || "#000000"; const backgroundHex = style?.colors.backgroundHex || "#ffffff"; + const sidebarBgHex = lightenHex(primaryHex, 0.85); return template .replaceAll("__BODY_FONT__", JSON.stringify(bodyFont)) .replaceAll("__HEADING_FONT__", JSON.stringify(headingFont)) - .replaceAll("__PRIMARY_COLOR__", `rgb("${primaryHex}")`) - .replaceAll("__TEXT_COLOR__", `rgb("${textHex}")`) - .replaceAll("__BACKGROUND_COLOR__", `rgb("${backgroundHex}")`) - .replaceAll("__SIDEBAR_BG_COLOR__", `rgb("${backgroundHex}")`); + .replaceAll("__PRIMARY_COLOR__", `rgb(${JSON.stringify(primaryHex)})`) + .replaceAll("__TEXT_COLOR__", `rgb(${JSON.stringify(textHex)})`) + .replaceAll("__BACKGROUND_COLOR__", `rgb(${JSON.stringify(backgroundHex)})`) + .replaceAll("__SIDEBAR_BG_COLOR__", `rgb(${JSON.stringify(sidebarBgHex)})`); } function buildAdaptedTypstDocument( @@ -564,7 +574,6 @@ export function buildTypstDocument( .replace("__PAR_LEADING__", tokens.parLeading) .replace("__SECTION_TOP__", tokens.sectionTop) .replace("__SECTION_SIZE__", tokens.sectionSize) - .replace("__ACCENT__", tokens.accent) .replace("__LINE_WIDTH__", tokens.lineWidth) .replace("__SECTION_BOTTOM__", tokens.sectionBottom) .replace("__NAME_SIZE__", tokens.nameSize) From 440d1db5c214a4fa97d90e6487d591952dda15ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 22:01:39 +0000 Subject: [PATCH 14/27] fix: add hex validation guard to lightenHex helper Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/c8f2791b-a148-4afc-8540-6dda89897c66 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/server/services/resume-renderer/typst.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 734d57b25..0b123b6af 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -422,6 +422,7 @@ function replaceSharedTypstPlaceholders(template: string): string { } function lightenHex(hex: string, whiteFactor: number): string { + if (!/^#[0-9a-f]{6}$/i.test(hex)) return hex; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); From ed830f94b92f54b8b7ef7283fd341cbd090304dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 22:09:18 +0000 Subject: [PATCH 15/27] fix: move data extraction and sidebar-content definition before #set page to fix forward reference error Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/4c63970e-d825-45ae-a351-c3473f101e47 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../typst-themes/sidebar-cv/main.typ | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index 2b325942a..baf058930 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -40,37 +40,6 @@ // Fixed absolute width (≈ 30 % of A4 210 mm); page margins require absolute lengths. #let sidebar-col-width = 63mm -// --------------------------------------------------------------------------- -// Page setup — sidebar drawn via background so main content flows across pages -// --------------------------------------------------------------------------- - -#set page( - paper: "a4", - // Left margin reserves space for the sidebar column; right margin gives breathing room. - margin: (top: 0pt, bottom: 0pt, left: sidebar-col-width, right: 1.5cm), - fill: __BACKGROUND_COLOR__, - background: context { - // Tinted sidebar background appears on every page. - place(left + top, - rect(width: sidebar-col-width, height: 100%, fill: sidebar-bg, stroke: none) - ) - // Sidebar content (name, photo, summary, contact…) only on the first page. - if here().page() == 1 { - place(left + top, - box(width: sidebar-col-width, inset: (x: 16pt, y: 0pt))[ - #set text(font: __BODY_FONT__, size: 9pt, lang: "en", fill: __TEXT_COLOR__) - #set par(leading: 0.55em) - #show link: set text(fill: accent) - #sidebar-content - ] - ) - } - }, -) -#set text(font: __BODY_FONT__, size: 10pt, lang: "en", fill: __TEXT_COLOR__) -#set par(leading: 0.55em) -#show link: set text(fill: accent) - // --------------------------------------------------------------------------- // Data extraction // --------------------------------------------------------------------------- @@ -287,6 +256,38 @@ } } +// --------------------------------------------------------------------------- +// Page setup — sidebar drawn via background so main content flows across pages +// (defined after sidebar-content so the binding is in scope in the background closure) +// --------------------------------------------------------------------------- + +#set page( + paper: "a4", + // Left margin reserves space for the sidebar column; right margin gives breathing room. + margin: (top: 0pt, bottom: 0pt, left: sidebar-col-width, right: 1.5cm), + fill: __BACKGROUND_COLOR__, + background: context { + // Tinted sidebar background appears on every page. + place(left + top, + rect(width: sidebar-col-width, height: 100%, fill: sidebar-bg, stroke: none) + ) + // Sidebar content (name, photo, summary, contact…) only on the first page. + if here().page() == 1 { + place(left + top, + box(width: sidebar-col-width, inset: (x: 16pt, y: 0pt))[ + #set text(font: __BODY_FONT__, size: 9pt, lang: "en", fill: __TEXT_COLOR__) + #set par(leading: 0.55em) + #show link: set text(fill: accent) + #sidebar-content + ] + ) + } + }, +) +#set text(font: __BODY_FONT__, size: 10pt, lang: "en", fill: __TEXT_COLOR__) +#set par(leading: 0.55em) +#show link: set text(fill: accent) + // --------------------------------------------------------------------------- // Main-area section heading // --------------------------------------------------------------------------- From 4bb580cb3d90cfd6715ef8327a63dae738349b95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:51:11 +0000 Subject: [PATCH 16/27] feat: add Typst typography and color settings page Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/24cbe393-7096-4927-b73a-9b1388367b26 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../src/client/pages/SettingsPage.tsx | 99 ++++++ .../components/TypstStyleSettingsSection.tsx | 288 ++++++++++++++++++ .../src/client/pages/settings/types.ts | 8 + .../server/services/auto-pdf-regeneration.ts | 5 + .../src/server/services/pdf-fingerprint.ts | 56 +++- orchestrator/src/server/services/pdf.ts | 78 ++++- .../server/services/resume-renderer/index.ts | 7 +- .../server/services/resume-renderer/types.ts | 1 + .../server/services/resume-renderer/typst.ts | 49 ++- shared/src/settings-registry.ts | 49 +++ shared/src/testing/factories.ts | 5 + shared/src/types/settings.ts | 5 + 12 files changed, 631 insertions(+), 19 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index b01405db5..7c7295f30 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -28,6 +28,7 @@ import { PromptTemplatesSection } from "@client/pages/settings/components/Prompt import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"; import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection"; import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection"; +import { TypstStyleSettingsSection } from "@client/pages/settings/components/TypstStyleSettingsSection"; import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"; import { type LlmProviderId, @@ -80,6 +81,11 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { resumeProjects: null, pdfRenderer: "rxresume", typstTheme: "classic", + typstBodyFont: "", + typstHeadingFont: "", + typstPrimaryColor: "", + typstTextColor: "", + typstBackgroundColor: "", rxresumeBaseResumeId: null, showSponsorInfo: null, renderMarkdownInJobDescriptions: null, @@ -136,6 +142,7 @@ type SettingsSectionId = | "tracer-links" | "environment" | "display" + | "typst-style" | "backup" | "danger-zone"; @@ -255,6 +262,23 @@ const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ description: "Sponsor badges and markdown rendering behavior.", searchTerms: ["markdown", "sponsor", "rendering", "appearance"], }, + { + id: "typst-style", + label: "Typst Theme Style", + description: + "Override fonts and colors for Typst-rendered PDF resumes.", + searchTerms: [ + "typst", + "font", + "color", + "typography", + "primary", + "text", + "background", + "heading", + "body", + ], + }, ], }, { @@ -335,6 +359,13 @@ const SECTION_FIELD_MAP: Record< "adzunaAppKey", ], display: ["showSponsorInfo", "renderMarkdownInJobDescriptions"], + "typst-style": [ + "typstBodyFont", + "typstHeadingFont", + "typstPrimaryColor", + "typstTextColor", + "typstBackgroundColor", + ], backup: ["backupEnabled", "backupHour", "backupMaxCount"], "danger-zone": [], }; @@ -405,6 +436,11 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { resumeProjects: null, pdfRenderer: null, typstTheme: null, + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, rxresumeBaseResumeId: null, showSponsorInfo: null, renderMarkdownInJobDescriptions: null, @@ -455,6 +491,11 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ resumeProjects: data.resumeProjects.override, pdfRenderer: data.pdfRenderer.override ?? data.pdfRenderer.value, typstTheme: data.typstTheme.override ?? data.typstTheme.value, + typstBodyFont: data.typstBodyFont.override ?? "", + typstHeadingFont: data.typstHeadingFont.override ?? "", + typstPrimaryColor: data.typstPrimaryColor.override ?? "", + typstTextColor: data.typstTextColor.override ?? "", + typstBackgroundColor: data.typstBackgroundColor.override ?? "", rxresumeBaseResumeId: data.rxresumeBaseResumeId, showSponsorInfo: data.showSponsorInfo.override, renderMarkdownInJobDescriptions: @@ -622,6 +663,28 @@ const getDerivedSettings = (settings: AppSettings | null) => { default: settings?.typstTheme?.default ?? "classic", }, }, + typstStyle: { + bodyFont: { + effective: settings?.typstBodyFont?.value ?? "", + default: settings?.typstBodyFont?.default ?? "", + }, + headingFont: { + effective: settings?.typstHeadingFont?.value ?? "", + default: settings?.typstHeadingFont?.default ?? "", + }, + primaryColor: { + effective: settings?.typstPrimaryColor?.value ?? "", + default: settings?.typstPrimaryColor?.default ?? "", + }, + textColor: { + effective: settings?.typstTextColor?.value ?? "", + default: settings?.typstTextColor?.default ?? "", + }, + backgroundColor: { + effective: settings?.typstBackgroundColor?.value ?? "", + default: settings?.typstBackgroundColor?.default ?? "", + }, + }, display: { showSponsorInfo: { effective: settings?.showSponsorInfo?.value ?? true, @@ -908,6 +971,7 @@ export const SettingsPage: React.FC = () => { jobCompleteWebhook, reactiveResume, display, + typstStyle, chat, envSettings, defaultResumeProjects, @@ -1167,6 +1231,26 @@ export const SettingsPage: React.FC = () => { data.typstTheme, reactiveResume.typstTheme.default, ), + typstBodyFont: nullIfSame( + normalizeString(data.typstBodyFont), + typstStyle.bodyFont.default || null, + ), + typstHeadingFont: nullIfSame( + normalizeString(data.typstHeadingFont), + typstStyle.headingFont.default || null, + ), + typstPrimaryColor: nullIfSame( + normalizeString(data.typstPrimaryColor), + typstStyle.primaryColor.default || null, + ), + typstTextColor: nullIfSame( + normalizeString(data.typstTextColor), + typstStyle.textColor.default || null, + ), + typstBackgroundColor: nullIfSame( + normalizeString(data.typstBackgroundColor), + typstStyle.backgroundColor.default || null, + ), ...(dirtyFields.rxresumeBaseResumeId ? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) } : {}), @@ -1498,6 +1582,11 @@ export const SettingsPage: React.FC = () => { : null; case "display": return { label: "Active", variant: "secondary" as const }; + case "typst-style": + return typstStyle.bodyFont.effective || + typstStyle.primaryColor.effective + ? { label: "Customized", variant: "outline" as const } + : { label: "Using defaults", variant: "secondary" as const }; case "backup": return backup.backupEnabled.effective ? { label: "Scheduled", variant: "outline" as const } @@ -1620,6 +1709,16 @@ export const SettingsPage: React.FC = () => { /> ); break; + case "typst-style": + activeSectionContent = ( + + ); + break; case "backup": activeSectionContent = ( = ({ + id, + label, + description, + placeholder, + isDisabled, + effective, + defaultValue, +}) => { + const { control } = useFormContext(); + const colorInputRef = useRef(null); + + return ( +
+ +

{description}

+ { + const textValue = (field.value as string | null | undefined) ?? ""; + const swatchColor = isValidHex(textValue) + ? textValue + : isValidHex(effective) + ? effective + : "#ffffff"; + + return ( +
+
+ ); + }} + /> +
+
+
Effective
+
+ + {effective || "(from resume)"} +
+
+
+
Default
+
+ {defaultValue || "(from resume)"} +
+
+
+
+ ); +}; + +export const TypstStyleSettingsSection: React.FC< + TypstStyleSettingsSectionProps +> = ({ values, isLoading, isSaving, layoutMode }) => { + const { control, formState } = useFormContext(); + const isDisabled = isLoading || isSaving; + + return ( + +
+

+ Override the fonts and colors used when rendering PDFs with a Typst + theme. Leave a field blank to inherit the value from the resume design + (metadata.typography / metadata.design.colors). +

+ +
+

Typography

+ +
+ +

+ Font used for body text, contact lines, and skill tags. Defaults + to the resume typography setting or "IBM Plex Serif". +

+ ( + field.onChange(e.target.value)} + onBlur={field.onBlur} + className={ + fieldState.error ? "border-destructive" : undefined + } + /> + )} + /> + {formState.errors.typstBodyFont && ( +

+ {formState.errors.typstBodyFont.message as string} +

+ )} +
+
+
Effective
+
+ {values.bodyFont.effective || "(from resume)"} +
+
+
+
Default
+
+ {values.bodyFont.default || "(from resume)"} +
+
+
+
+ + + +
+ +

+ Font used for section headings and the name. Defaults to the body + font when not set. +

+ ( + field.onChange(e.target.value)} + onBlur={field.onBlur} + className={ + fieldState.error ? "border-destructive" : undefined + } + /> + )} + /> + {formState.errors.typstHeadingFont && ( +

+ {formState.errors.typstHeadingFont.message as string} +

+ )} +
+
+
Effective
+
+ {values.headingFont.effective || "(from resume)"} +
+
+
+
Default
+
+ {values.headingFont.default || "(from resume)"} +
+
+
+
+
+ + + +
+

Colors

+

+ Enter a 6-digit hex color (e.g. #dc2626) or click the swatch to use + a color picker. Leave blank to inherit from the resume design. +

+ + + + + + + + + + +
+
+
+ ); +}; diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 5fbd458a5..f228a6f15 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -64,6 +64,14 @@ export type ScoringValues = { scoringInstructions: EffectiveDefault; }; +export type TypstStyleValues = { + bodyFont: EffectiveDefault; + headingFont: EffectiveDefault; + primaryColor: EffectiveDefault; + textColor: EffectiveDefault; + backgroundColor: EffectiveDefault; +}; + export type PromptTemplatesValues = { ghostwriterSystemPromptTemplate: EffectiveDefault; tailoringPromptTemplate: EffectiveDefault; diff --git a/orchestrator/src/server/services/auto-pdf-regeneration.ts b/orchestrator/src/server/services/auto-pdf-regeneration.ts index d1023c4a2..9bd5711af 100644 --- a/orchestrator/src/server/services/auto-pdf-regeneration.ts +++ b/orchestrator/src/server/services/auto-pdf-regeneration.ts @@ -18,6 +18,11 @@ const AUTO_PDF_REGEN_RETRY_DELAY_MS = 5000; const SETTINGS_INVALIDATION_KEYS = new Set([ "pdfRenderer", "typstTheme", + "typstBodyFont", + "typstHeadingFont", + "typstPrimaryColor", + "typstTextColor", + "typstBackgroundColor", "rxresumeBaseResumeId", "rxresumeUrl", "rxresumeApiKey", diff --git a/orchestrator/src/server/services/pdf-fingerprint.ts b/orchestrator/src/server/services/pdf-fingerprint.ts index 9c66f84a4..0f8b741f4 100644 --- a/orchestrator/src/server/services/pdf-fingerprint.ts +++ b/orchestrator/src/server/services/pdf-fingerprint.ts @@ -32,17 +32,36 @@ export interface PdfFingerprintContext { designResumeUpdatedAt: string | null; pdfRenderer: PdfRenderer; typstTheme: TypstTheme; + typstBodyFont: string | null; + typstHeadingFont: string | null; + typstPrimaryColor: string | null; + typstTextColor: string | null; + typstBackgroundColor: string | null; rxresumeBaseResumeId: string | null; } export async function resolvePdfFingerprintContext(): Promise { - const [designResume, rawRenderer, rawTypstTheme, configuredBaseResume] = - await Promise.all([ - getCurrentDesignResumeOrNullOnLegacy(), - settingsRepo.getSetting("pdfRenderer"), - settingsRepo.getSetting("typstTheme"), - getConfiguredRxResumeBaseResumeId(), - ]); + const [ + designResume, + rawRenderer, + rawTypstTheme, + configuredBaseResume, + rawBodyFont, + rawHeadingFont, + rawPrimaryColor, + rawTextColor, + rawBackgroundColor, + ] = await Promise.all([ + getCurrentDesignResumeOrNullOnLegacy(), + settingsRepo.getSetting("pdfRenderer"), + settingsRepo.getSetting("typstTheme"), + getConfiguredRxResumeBaseResumeId(), + settingsRepo.getSetting("typstBodyFont"), + settingsRepo.getSetting("typstHeadingFont"), + settingsRepo.getSetting("typstPrimaryColor"), + settingsRepo.getSetting("typstTextColor"), + settingsRepo.getSetting("typstBackgroundColor"), + ]); const parsedRenderer = settingsRegistry.pdfRenderer.parse( rawRenderer ?? undefined, @@ -58,6 +77,20 @@ export async function resolvePdfFingerprintContext(): Promise { + const [bodyFont, headingFont, primaryColor, textColor, backgroundColor] = + await Promise.all([ + getSetting("typstBodyFont"), + getSetting("typstHeadingFont"), + getSetting("typstPrimaryColor"), + getSetting("typstTextColor"), + getSetting("typstBackgroundColor"), + ]); + + const typography: { bodyFontFamily?: string; headingFontFamily?: string } = + {}; + const colors: { + primaryHex?: string; + textHex?: string; + backgroundHex?: string; + } = {}; + + const parsedBodyFont = settingsRegistry.typstBodyFont.parse( + bodyFont ?? undefined, + ); + if (parsedBodyFont) typography.bodyFontFamily = parsedBodyFont; + + const parsedHeadingFont = settingsRegistry.typstHeadingFont.parse( + headingFont ?? undefined, + ); + if (parsedHeadingFont) typography.headingFontFamily = parsedHeadingFont; + + const parsedPrimary = settingsRegistry.typstPrimaryColor.parse( + primaryColor ?? undefined, + ); + if (parsedPrimary) colors.primaryHex = parsedPrimary; + + const parsedText = settingsRegistry.typstTextColor.parse( + textColor ?? undefined, + ); + if (parsedText) colors.textHex = parsedText; + + const parsedBg = settingsRegistry.typstBackgroundColor.parse( + backgroundColor ?? undefined, + ); + if (parsedBg) colors.backgroundHex = parsedBg; + + if ( + Object.keys(typography).length === 0 && + Object.keys(colors).length === 0 + ) { + return undefined; + } + + return { + ...(Object.keys(typography).length > 0 ? { typography } : {}), + ...(Object.keys(colors).length > 0 ? { colors } : {}), + }; +} + async function resolveLocalResumeLanguage(resumeJson: Record) { const writingStyle = await getWritingStyle(); return resolveWritingOutputLanguageForResumeJson({ @@ -382,9 +448,12 @@ export async function generatePdf( const outputPath = getTenantJobPdfPath(jobId); if (renderer !== "rxresume") { - const [language, typstTheme] = await Promise.all([ + const [language, typstTheme, typstStyleOverrides] = await Promise.all([ resolveLocalResumeLanguage(preparedResume.data), renderer === "typst" ? resolveTypstTheme() : Promise.resolve(undefined), + renderer === "typst" + ? resolveTypstStyleOverrides() + : Promise.resolve(undefined), ]); await renderResumePdf({ resumeJson: preparedResume.data, @@ -393,6 +462,7 @@ export async function generatePdf( language, renderer, typstTheme, + typstStyleOverrides, }); } else { await renderRxResumePdf({ @@ -441,9 +511,12 @@ export async function generateDesignResumePdf(options?: { }); if (renderer !== "rxresume") { - const [language, typstTheme] = await Promise.all([ + const [language, typstTheme, typstStyleOverrides] = await Promise.all([ resolveLocalResumeLanguage(designResume.data), renderer === "typst" ? resolveTypstTheme() : Promise.resolve(undefined), + renderer === "typst" + ? resolveTypstStyleOverrides() + : Promise.resolve(undefined), ]); await renderResumePdf({ resumeJson: designResume.data, @@ -452,6 +525,7 @@ export async function generateDesignResumePdf(options?: { language, renderer, typstTheme, + typstStyleOverrides, }); } else { await renderRxResumePdf({ diff --git a/orchestrator/src/server/services/resume-renderer/index.ts b/orchestrator/src/server/services/resume-renderer/index.ts index 9a017f3ad..e980ce29d 100644 --- a/orchestrator/src/server/services/resume-renderer/index.ts +++ b/orchestrator/src/server/services/resume-renderer/index.ts @@ -1,7 +1,10 @@ import type { PdfRenderer, TypstTheme } from "@shared/types"; import { normalizeResumeJsonToLatexDocument } from "./document"; import { renderLatexPdf } from "./latex"; -import type { NormalizeResumeJsonToLatexDocumentOptions } from "./types"; +import type { + LatexResumeStyle, + NormalizeResumeJsonToLatexDocumentOptions, +} from "./types"; import { renderTypstPdf } from "./typst"; export { normalizeResumeJsonToLatexDocument } from "./document"; @@ -26,6 +29,7 @@ export async function renderResumePdf(args: { language?: NormalizeResumeJsonToLatexDocumentOptions["language"]; renderer?: LocalPdfRenderer; typstTheme?: TypstTheme; + typstStyleOverrides?: Partial; }): Promise { const document = normalizeResumeJsonToLatexDocument(args.resumeJson, { language: args.language, @@ -36,6 +40,7 @@ export async function renderResumePdf(args: { outputPath: args.outputPath, jobId: args.jobId, typstTheme: args.typstTheme, + typstStyleOverrides: args.typstStyleOverrides, }); return; } diff --git a/orchestrator/src/server/services/resume-renderer/types.ts b/orchestrator/src/server/services/resume-renderer/types.ts index 0559554dc..7f827a949 100644 --- a/orchestrator/src/server/services/resume-renderer/types.ts +++ b/orchestrator/src/server/services/resume-renderer/types.ts @@ -118,6 +118,7 @@ export interface RenderResumePdfArgs { outputPath: string; jobId: string; typstTheme?: TypstTheme; + typstStyleOverrides?: Partial; } export interface ResumeRenderer { diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 0b123b6af..792fdfdf1 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -16,6 +16,7 @@ import type { LatexResumeEntry, LatexResumeInterestItem, LatexResumeLanguageItem, + LatexResumeStyle, ResumeRenderer, } from "./types"; @@ -435,13 +436,25 @@ function lightenHex(hex: string, whiteFactor: number): string { function replaceStylePlaceholders( template: string, document: LatexResumeDocument, + overrides?: Partial, ): string { const style = document.style; - const bodyFont = style?.typography.bodyFontFamily || "IBM Plex Serif"; - const headingFont = style?.typography.headingFontFamily || bodyFont; - const primaryHex = style?.colors.primaryHex || "#202020"; - const textHex = style?.colors.textHex || "#000000"; - const backgroundHex = style?.colors.backgroundHex || "#ffffff"; + const bodyFont = + overrides?.typography?.bodyFontFamily || + style?.typography.bodyFontFamily || + "IBM Plex Serif"; + const headingFont = + overrides?.typography?.headingFontFamily || + style?.typography.headingFontFamily || + bodyFont; + const primaryHex = + overrides?.colors?.primaryHex || style?.colors.primaryHex || "#202020"; + const textHex = + overrides?.colors?.textHex || style?.colors.textHex || "#000000"; + const backgroundHex = + overrides?.colors?.backgroundHex || + style?.colors.backgroundHex || + "#ffffff"; const sidebarBgHex = lightenHex(primaryHex, 0.85); return template @@ -456,10 +469,12 @@ function replaceStylePlaceholders( function buildAdaptedTypstDocument( document: LatexResumeDocument, template: string, + overrides?: Partial, ): string { return replaceStylePlaceholders( replaceSharedTypstPlaceholders(template), document, + overrides, ); } @@ -498,6 +513,7 @@ export function buildTypstDocument( document: LatexResumeDocument, template: string, tokens: TypstThemeTokens, + overrides?: Partial, ): string { const titles = document.sectionTitles ?? getLatexResumeSectionTitles(); const pictureBlock = renderPictureBlock(document); @@ -585,6 +601,7 @@ export function buildTypstDocument( .replace("__CONTACT_BLOCK__", contactBlock) .replace("__BODY__", body), document, + overrides, ); } @@ -670,7 +687,13 @@ async function runTypst(args: { } export const typstResumeRenderer: ResumeRenderer = { - async render({ document, outputPath, jobId, typstTheme = "classic" }) { + async render({ + document, + outputPath, + jobId, + typstTheme = "classic", + typstStyleOverrides, + }) { const tempDir = await mkdtemp(join(tmpdir(), "job-ops-resume-render-")); const typPath = join(tempDir, "resume.typ"); const resumeDataPath = join(tempDir, RESUME_DATA_FILENAME); @@ -693,9 +716,18 @@ export const typstResumeRenderer: ResumeRenderer = { `Typst theme ${typstTheme} is missing native tokens.`, ); } - typst = buildTypstDocument(typstDocument, template, tokens); + typst = buildTypstDocument( + typstDocument, + template, + tokens, + typstStyleOverrides, + ); } else { - typst = buildAdaptedTypstDocument(typstDocument, template); + typst = buildAdaptedTypstDocument( + typstDocument, + template, + typstStyleOverrides, + ); } await writeFile(resumeDataPath, JSON.stringify(typstDocument), "utf8"); @@ -756,6 +788,7 @@ export async function renderTypstPdf(args: { outputPath: string; jobId: string; typstTheme?: TypstTheme; + typstStyleOverrides?: Partial; }): Promise { await typstResumeRenderer.render(args); } diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 814bdc386..8240ead83 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -415,6 +415,55 @@ export const settingsRegistry = { serialize: (value: TypstTheme | null | undefined): string | null => value ?? null, }, + typstBodyFont: { + kind: "typed" as const, + schema: z.string().trim().max(200), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, + typstHeadingFont: { + kind: "typed" as const, + schema: z.string().trim().max(200), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, + typstPrimaryColor: { + kind: "typed" as const, + schema: z + .string() + .trim() + .regex(/^(#[0-9a-fA-F]{6})?$/, "Must be a 6-digit hex color or empty"), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, + typstTextColor: { + kind: "typed" as const, + schema: z + .string() + .trim() + .regex(/^(#[0-9a-fA-F]{6})?$/, "Must be a 6-digit hex color or empty"), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, + typstBackgroundColor: { + kind: "typed" as const, + schema: z + .string() + .trim() + .regex(/^(#[0-9a-fA-F]{6})?$/, "Must be a 6-digit hex color or empty"), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, ukvisajobsMaxJobs: { kind: "typed" as const, schema: z.number().int().min(1).max(1000), diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index 3ecb70772..d1a445c59 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -169,6 +169,11 @@ export const createAppSettings = ( default: "classic", override: null, }, + typstBodyFont: { value: "", default: "", override: null }, + typstHeadingFont: { value: "", default: "", override: null }, + typstPrimaryColor: { value: "", default: "", override: null }, + typstTextColor: { value: "", default: "", override: null }, + typstBackgroundColor: { value: "", default: "", override: null }, rxresumeBaseResumeId: null, ukvisajobsMaxJobs: { value: 50, default: 50, override: null }, adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null }, diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index deb51145a..193abcbbf 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -210,6 +210,11 @@ export interface AppSettings { resumeProjects: Resolved; pdfRenderer: Resolved; typstTheme: Resolved; + typstBodyFont: Resolved; + typstHeadingFont: Resolved; + typstPrimaryColor: Resolved; + typstTextColor: Resolved; + typstBackgroundColor: Resolved; ukvisajobsMaxJobs: Resolved; adzunaMaxJobsPerTerm: Resolved; gradcrackerMaxJobsPerTerm: Resolved; From e22b40ebdc60a79571ebd8fb06b9f3e407fa7d30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 00:57:32 +0000 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20simplify=20hasValues=20check,=20fix=20import=20orde?= =?UTF-8?q?r,=20check=20all=205=20style=20settings=20in=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/24cbe393-7096-4927-b73a-9b1388367b26 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../src/client/pages/SettingsPage.tsx | 5 ++- .../components/TypstStyleSettingsSection.tsx | 3 +- orchestrator/src/server/services/pdf.ts | 33 ++++++++++++------- shared/src/settings-registry.ts | 2 ++ 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 7c7295f30..fe3faf69d 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -1584,7 +1584,10 @@ export const SettingsPage: React.FC = () => { return { label: "Active", variant: "secondary" as const }; case "typst-style": return typstStyle.bodyFont.effective || - typstStyle.primaryColor.effective + typstStyle.headingFont.effective || + typstStyle.primaryColor.effective || + typstStyle.textColor.effective || + typstStyle.backgroundColor.effective ? { label: "Customized", variant: "outline" as const } : { label: "Using defaults", variant: "secondary" as const }; case "backup": diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index f9033ab66..8da3434eb 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -1,5 +1,6 @@ import { SettingsSectionFrame } from "@client/pages/settings/components/SettingsSectionFrame"; import type { TypstStyleValues } from "@client/pages/settings/types"; +import { HEX_COLOR_REGEX } from "@shared/settings-registry.js"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type React from "react"; import { useRef } from "react"; @@ -15,7 +16,7 @@ type TypstStyleSettingsSectionProps = { }; function isValidHex(value: string): boolean { - return /^#[0-9a-fA-F]{6}$/.test(value); + return HEX_COLOR_REGEX.test(value); } type ColorFieldProps = { diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index aaa8833ed..3530219bf 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -107,6 +107,7 @@ async function resolveTypstStyleOverrides(): Promise< getSetting("typstBackgroundColor"), ]); + let hasValues = false; const typography: { bodyFontFamily?: string; headingFontFamily?: string } = {}; const colors: { @@ -118,35 +119,45 @@ async function resolveTypstStyleOverrides(): Promise< const parsedBodyFont = settingsRegistry.typstBodyFont.parse( bodyFont ?? undefined, ); - if (parsedBodyFont) typography.bodyFontFamily = parsedBodyFont; + if (parsedBodyFont) { + typography.bodyFontFamily = parsedBodyFont; + hasValues = true; + } const parsedHeadingFont = settingsRegistry.typstHeadingFont.parse( headingFont ?? undefined, ); - if (parsedHeadingFont) typography.headingFontFamily = parsedHeadingFont; + if (parsedHeadingFont) { + typography.headingFontFamily = parsedHeadingFont; + hasValues = true; + } const parsedPrimary = settingsRegistry.typstPrimaryColor.parse( primaryColor ?? undefined, ); - if (parsedPrimary) colors.primaryHex = parsedPrimary; + if (parsedPrimary) { + colors.primaryHex = parsedPrimary; + hasValues = true; + } const parsedText = settingsRegistry.typstTextColor.parse( textColor ?? undefined, ); - if (parsedText) colors.textHex = parsedText; + if (parsedText) { + colors.textHex = parsedText; + hasValues = true; + } const parsedBg = settingsRegistry.typstBackgroundColor.parse( backgroundColor ?? undefined, ); - if (parsedBg) colors.backgroundHex = parsedBg; - - if ( - Object.keys(typography).length === 0 && - Object.keys(colors).length === 0 - ) { - return undefined; + if (parsedBg) { + colors.backgroundHex = parsedBg; + hasValues = true; } + if (!hasValues) return undefined; + return { ...(Object.keys(typography).length > 0 ? { typography } : {}), ...(Object.keys(colors).length > 0 ? { colors } : {}), diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 8240ead83..6f5fcea58 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -25,6 +25,8 @@ function parseNonEmptyStringOrNull(raw: string | undefined): string | null { return raw === undefined || raw === "" ? null : raw; } +export const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + function parseIntOrNull(raw: string | undefined): number | null { if (!raw) return null; const parsed = parseInt(raw, 10); From 9e0d17fd9f5120ff9ced6bdfea5fb658e21ba957 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:29:27 +0000 Subject: [PATCH 18/27] feat: add typst secondary background and font dropdowns Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/b1e97374-96ec-4a6d-b9c7-7206e25511fc Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../src/client/pages/SettingsPage.tsx | 16 +- .../components/TypstStyleSettingsSection.tsx | 243 +++++++++++------- .../src/client/pages/settings/types.ts | 1 + .../services/auto-pdf-regeneration.test.ts | 12 + .../server/services/auto-pdf-regeneration.ts | 1 + .../server/services/pdf-fingerprint.test.ts | 6 + .../src/server/services/pdf-fingerprint.ts | 8 + orchestrator/src/server/services/pdf.ts | 33 ++- .../server/services/resume-renderer/index.ts | 4 +- .../server/services/resume-renderer/types.ts | 8 +- .../typst-themes/sidebar-cv/main.typ | 2 +- .../server/services/resume-renderer/typst.ts | 24 +- .../src/server/tailoring-flow.test.ts | 12 + shared/src/settings-registry.ts | 11 + shared/src/testing/factories.ts | 1 + shared/src/types/settings.ts | 1 + 16 files changed, 265 insertions(+), 118 deletions(-) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index fe3faf69d..145f6efe8 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -86,6 +86,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { typstPrimaryColor: "", typstTextColor: "", typstBackgroundColor: "", + typstSecondaryBackgroundColor: "", rxresumeBaseResumeId: null, showSponsorInfo: null, renderMarkdownInJobDescriptions: null, @@ -365,6 +366,7 @@ const SECTION_FIELD_MAP: Record< "typstPrimaryColor", "typstTextColor", "typstBackgroundColor", + "typstSecondaryBackgroundColor", ], backup: ["backupEnabled", "backupHour", "backupMaxCount"], "danger-zone": [], @@ -441,6 +443,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { typstPrimaryColor: null, typstTextColor: null, typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: null, showSponsorInfo: null, renderMarkdownInJobDescriptions: null, @@ -496,6 +499,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ typstPrimaryColor: data.typstPrimaryColor.override ?? "", typstTextColor: data.typstTextColor.override ?? "", typstBackgroundColor: data.typstBackgroundColor.override ?? "", + typstSecondaryBackgroundColor: + data.typstSecondaryBackgroundColor.override ?? "", rxresumeBaseResumeId: data.rxresumeBaseResumeId, showSponsorInfo: data.showSponsorInfo.override, renderMarkdownInJobDescriptions: @@ -684,6 +689,10 @@ const getDerivedSettings = (settings: AppSettings | null) => { effective: settings?.typstBackgroundColor?.value ?? "", default: settings?.typstBackgroundColor?.default ?? "", }, + secondaryBackgroundColor: { + effective: settings?.typstSecondaryBackgroundColor?.value ?? "", + default: settings?.typstSecondaryBackgroundColor?.default ?? "", + }, }, display: { showSponsorInfo: { @@ -1251,6 +1260,10 @@ export const SettingsPage: React.FC = () => { normalizeString(data.typstBackgroundColor), typstStyle.backgroundColor.default || null, ), + typstSecondaryBackgroundColor: nullIfSame( + normalizeString(data.typstSecondaryBackgroundColor), + typstStyle.secondaryBackgroundColor.default || null, + ), ...(dirtyFields.rxresumeBaseResumeId ? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) } : {}), @@ -1587,7 +1600,8 @@ export const SettingsPage: React.FC = () => { typstStyle.headingFont.effective || typstStyle.primaryColor.effective || typstStyle.textColor.effective || - typstStyle.backgroundColor.effective + typstStyle.backgroundColor.effective || + typstStyle.secondaryBackgroundColor.effective ? { label: "Customized", variant: "outline" as const } : { label: "Using defaults", variant: "secondary" as const }; case "backup": diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index 8da3434eb..84426ec93 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -6,7 +6,12 @@ import type React from "react"; import { useRef } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { Input } from "@/components/ui/input"; +import { + SearchableDropdown, + type SearchableDropdownOption, +} from "@/components/ui/searchable-dropdown"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; type TypstStyleSettingsSectionProps = { values: TypstStyleValues; @@ -19,6 +24,32 @@ function isValidHex(value: string): boolean { return HEX_COLOR_REGEX.test(value); } +const FONT_FAMILY_OPTIONS: SearchableDropdownOption[] = [ + { value: "", label: "Use resume default" }, + { value: "System UI", label: "System UI" }, + { value: "Segoe UI", label: "Segoe UI" }, + { value: "San Francisco", label: "San Francisco" }, + { value: "Roboto", label: "Roboto" }, + { value: "Helvetica", label: "Helvetica" }, + { value: "Arial", label: "Arial" }, + { value: "Verdana", label: "Verdana" }, + { value: "Tahoma", label: "Tahoma" }, + { value: "Trebuchet MS", label: "Trebuchet MS" }, + { value: "Times New Roman", label: "Times New Roman" }, + { value: "Georgia", label: "Georgia" }, + { value: "Garamond", label: "Garamond" }, + { value: "Palatino", label: "Palatino" }, + { value: "Noto Sans", label: "Noto Sans" }, + { value: "Noto Serif", label: "Noto Serif" }, + { value: "IBM Plex Sans", label: "IBM Plex Sans" }, + { value: "IBM Plex Serif", label: "IBM Plex Serif" }, + { value: "IBM Plex Mono", label: "IBM Plex Mono" }, + { value: "Courier New", label: "Courier New" }, + { value: "Consolas", label: "Consolas" }, + { value: "Monaco", label: "Monaco" }, + { value: "Menlo", label: "Menlo" }, +]; + type ColorFieldProps = { id: keyof UpdateSettingsInput; label: string; @@ -29,6 +60,81 @@ type ColorFieldProps = { defaultValue: string; }; +type FontFieldProps = { + id: keyof UpdateSettingsInput; + label: string; + description: string; + placeholder: string; + isDisabled: boolean; + effective: string; + defaultValue: string; +}; + +const FontField: React.FC = ({ + id, + label, + description, + placeholder, + isDisabled, + effective, + defaultValue, +}) => { + const { control } = useFormContext(); + + return ( +
+ +

{description}

+ ( + <> + field.onChange(nextValue)} + placeholder={placeholder} + searchPlaceholder="Search fonts..." + emptyText="No matching fonts." + allowCustomValue + disabled={isDisabled} + triggerClassName={cn( + "h-10 w-full", + fieldState.error && "border-destructive", + )} + ariaLabel={field.value ? String(field.value) : placeholder} + /> + {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} + + )} + /> +
+ Type any font name to add a custom value. +
+
+
+
Effective
+
{effective || "(from resume)"}
+
+
+
Default
+
+ {defaultValue || "(from resume)"} +
+
+
+
+ ); +}; + const ColorField: React.FC = ({ id, label, @@ -122,7 +228,6 @@ const ColorField: React.FC = ({ export const TypstStyleSettingsSection: React.FC< TypstStyleSettingsSectionProps > = ({ values, isLoading, isSaving, layoutMode }) => { - const { control, formState } = useFormContext(); const isDisabled = isLoading || isSaving; return ( @@ -141,103 +246,31 @@ export const TypstStyleSettingsSection: React.FC<

Typography

-
- -

- Font used for body text, contact lines, and skill tags. Defaults - to the resume typography setting or "IBM Plex Serif". -

- ( - field.onChange(e.target.value)} - onBlur={field.onBlur} - className={ - fieldState.error ? "border-destructive" : undefined - } - /> - )} - /> - {formState.errors.typstBodyFont && ( -

- {formState.errors.typstBodyFont.message as string} -

- )} -
-
-
Effective
-
- {values.bodyFont.effective || "(from resume)"} -
-
-
-
Default
-
- {values.bodyFont.default || "(from resume)"} -
-
-
-
+ -
- -

- Font used for section headings and the name. Defaults to the body - font when not set. -

- ( - field.onChange(e.target.value)} - onBlur={field.onBlur} - className={ - fieldState.error ? "border-destructive" : undefined - } - /> - )} - /> - {formState.errors.typstHeadingFont && ( -

- {formState.errors.typstHeadingFont.message as string} -

- )} -
-
-
Effective
-
- {values.headingFont.effective || "(from resume)"} -
-
-
-
Default
-
- {values.headingFont.default || "(from resume)"} -
-
-
-
+
@@ -246,7 +279,9 @@ export const TypstStyleSettingsSection: React.FC<

Colors

Enter a 6-digit hex color (e.g. #dc2626) or click the swatch to use - a color picker. Leave blank to inherit from the resume design. + a color picker. Primary background fills the page; secondary + background is used for sidebars and other secondary surfaces. Leave + blank to inherit from the resume design.

+ + + + diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index f228a6f15..dcee9fd46 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -70,6 +70,7 @@ export type TypstStyleValues = { primaryColor: EffectiveDefault; textColor: EffectiveDefault; backgroundColor: EffectiveDefault; + secondaryBackgroundColor: EffectiveDefault; }; export type PromptTemplatesValues = { diff --git a/orchestrator/src/server/services/auto-pdf-regeneration.test.ts b/orchestrator/src/server/services/auto-pdf-regeneration.test.ts index 384879f7a..eaafcb729 100644 --- a/orchestrator/src/server/services/auto-pdf-regeneration.test.ts +++ b/orchestrator/src/server/services/auto-pdf-regeneration.test.ts @@ -68,6 +68,12 @@ describe("auto PDF regeneration", () => { designResumeUpdatedAt: null, pdfRenderer: "latex", typstTheme: "classic", + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: null, }); }); @@ -184,6 +190,12 @@ describe("auto PDF regeneration", () => { designResumeUpdatedAt: null, pdfRenderer: "typst", typstTheme: "compact", + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: null, }); mocks.getReadyJobsWithGeneratedPdfs.mockResolvedValue([ diff --git a/orchestrator/src/server/services/auto-pdf-regeneration.ts b/orchestrator/src/server/services/auto-pdf-regeneration.ts index 9bd5711af..b366e499b 100644 --- a/orchestrator/src/server/services/auto-pdf-regeneration.ts +++ b/orchestrator/src/server/services/auto-pdf-regeneration.ts @@ -23,6 +23,7 @@ const SETTINGS_INVALIDATION_KEYS = new Set([ "typstPrimaryColor", "typstTextColor", "typstBackgroundColor", + "typstSecondaryBackgroundColor", "rxresumeBaseResumeId", "rxresumeUrl", "rxresumeApiKey", diff --git a/orchestrator/src/server/services/pdf-fingerprint.test.ts b/orchestrator/src/server/services/pdf-fingerprint.test.ts index d992691a0..9c731c146 100644 --- a/orchestrator/src/server/services/pdf-fingerprint.test.ts +++ b/orchestrator/src/server/services/pdf-fingerprint.test.ts @@ -10,6 +10,12 @@ const context: PdfFingerprintContext = { designResumeUpdatedAt: "2026-05-01T10:00:00.000Z", pdfRenderer: "latex", typstTheme: "classic", + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: "rxresume-base-1", }; diff --git a/orchestrator/src/server/services/pdf-fingerprint.ts b/orchestrator/src/server/services/pdf-fingerprint.ts index 0f8b741f4..572e02e24 100644 --- a/orchestrator/src/server/services/pdf-fingerprint.ts +++ b/orchestrator/src/server/services/pdf-fingerprint.ts @@ -37,6 +37,7 @@ export interface PdfFingerprintContext { typstPrimaryColor: string | null; typstTextColor: string | null; typstBackgroundColor: string | null; + typstSecondaryBackgroundColor: string | null; rxresumeBaseResumeId: string | null; } @@ -51,6 +52,7 @@ export async function resolvePdfFingerprintContext(): Promise { - const [bodyFont, headingFont, primaryColor, textColor, backgroundColor] = - await Promise.all([ - getSetting("typstBodyFont"), - getSetting("typstHeadingFont"), - getSetting("typstPrimaryColor"), - getSetting("typstTextColor"), - getSetting("typstBackgroundColor"), - ]); + const [ + bodyFont, + headingFont, + primaryColor, + textColor, + backgroundColor, + secondaryBackgroundColor, + ] = await Promise.all([ + getSetting("typstBodyFont"), + getSetting("typstHeadingFont"), + getSetting("typstPrimaryColor"), + getSetting("typstTextColor"), + getSetting("typstBackgroundColor"), + getSetting("typstSecondaryBackgroundColor"), + ]); let hasValues = false; const typography: { bodyFontFamily?: string; headingFontFamily?: string } = @@ -114,6 +121,7 @@ async function resolveTypstStyleOverrides(): Promise< primaryHex?: string; textHex?: string; backgroundHex?: string; + secondaryBackgroundHex?: string; } = {}; const parsedBodyFont = settingsRegistry.typstBodyFont.parse( @@ -156,6 +164,15 @@ async function resolveTypstStyleOverrides(): Promise< hasValues = true; } + const parsedSecondaryBg = + settingsRegistry.typstSecondaryBackgroundColor.parse( + secondaryBackgroundColor ?? undefined, + ); + if (parsedSecondaryBg) { + colors.secondaryBackgroundHex = parsedSecondaryBg; + hasValues = true; + } + if (!hasValues) return undefined; return { diff --git a/orchestrator/src/server/services/resume-renderer/index.ts b/orchestrator/src/server/services/resume-renderer/index.ts index e980ce29d..03f693890 100644 --- a/orchestrator/src/server/services/resume-renderer/index.ts +++ b/orchestrator/src/server/services/resume-renderer/index.ts @@ -2,7 +2,7 @@ import type { PdfRenderer, TypstTheme } from "@shared/types"; import { normalizeResumeJsonToLatexDocument } from "./document"; import { renderLatexPdf } from "./latex"; import type { - LatexResumeStyle, + LatexResumeStyleOverrides, NormalizeResumeJsonToLatexDocumentOptions, } from "./types"; import { renderTypstPdf } from "./typst"; @@ -29,7 +29,7 @@ export async function renderResumePdf(args: { language?: NormalizeResumeJsonToLatexDocumentOptions["language"]; renderer?: LocalPdfRenderer; typstTheme?: TypstTheme; - typstStyleOverrides?: Partial; + typstStyleOverrides?: LatexResumeStyleOverrides; }): Promise { const document = normalizeResumeJsonToLatexDocument(args.resumeJson, { language: args.language, diff --git a/orchestrator/src/server/services/resume-renderer/types.ts b/orchestrator/src/server/services/resume-renderer/types.ts index 7f827a949..47fc79270 100644 --- a/orchestrator/src/server/services/resume-renderer/types.ts +++ b/orchestrator/src/server/services/resume-renderer/types.ts @@ -82,6 +82,7 @@ export interface LatexResumeStyle { primaryHex: string; textHex: string; backgroundHex: string; + secondaryBackgroundHex?: string; }; typography: { bodyFontFamily: string; @@ -89,6 +90,11 @@ export interface LatexResumeStyle { }; } +export type LatexResumeStyleOverrides = { + colors?: Partial; + typography?: Partial; +}; + export interface LatexResumeDocument { name: string; headline?: string | null; @@ -118,7 +124,7 @@ export interface RenderResumePdfArgs { outputPath: string; jobId: string; typstTheme?: TypstTheme; - typstStyleOverrides?: Partial; + typstStyleOverrides?: LatexResumeStyleOverrides; } export interface ResumeRenderer { diff --git a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ index baf058930..01631a420 100644 --- a/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ +++ b/orchestrator/src/server/services/resume-renderer/typst-themes/sidebar-cv/main.typ @@ -36,7 +36,7 @@ // --------------------------------------------------------------------------- #let accent = __PRIMARY_COLOR__ -#let sidebar-bg = __SIDEBAR_BG_COLOR__ +#let sidebar-bg = __SECONDARY_BACKGROUND_COLOR__ // Fixed absolute width (≈ 30 % of A4 210 mm); page margins require absolute lengths. #let sidebar-col-width = 63mm diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 792fdfdf1..236195674 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -16,7 +16,7 @@ import type { LatexResumeEntry, LatexResumeInterestItem, LatexResumeLanguageItem, - LatexResumeStyle, + LatexResumeStyleOverrides, ResumeRenderer, } from "./types"; @@ -436,7 +436,7 @@ function lightenHex(hex: string, whiteFactor: number): string { function replaceStylePlaceholders( template: string, document: LatexResumeDocument, - overrides?: Partial, + overrides?: LatexResumeStyleOverrides, ): string { const style = document.style; const bodyFont = @@ -455,7 +455,10 @@ function replaceStylePlaceholders( overrides?.colors?.backgroundHex || style?.colors.backgroundHex || "#ffffff"; - const sidebarBgHex = lightenHex(primaryHex, 0.85); + const secondaryBackgroundHex = + overrides?.colors?.secondaryBackgroundHex || + style?.colors.secondaryBackgroundHex || + lightenHex(primaryHex, 0.85); return template .replaceAll("__BODY_FONT__", JSON.stringify(bodyFont)) @@ -463,13 +466,20 @@ function replaceStylePlaceholders( .replaceAll("__PRIMARY_COLOR__", `rgb(${JSON.stringify(primaryHex)})`) .replaceAll("__TEXT_COLOR__", `rgb(${JSON.stringify(textHex)})`) .replaceAll("__BACKGROUND_COLOR__", `rgb(${JSON.stringify(backgroundHex)})`) - .replaceAll("__SIDEBAR_BG_COLOR__", `rgb(${JSON.stringify(sidebarBgHex)})`); + .replaceAll( + "__SECONDARY_BACKGROUND_COLOR__", + `rgb(${JSON.stringify(secondaryBackgroundHex)})`, + ) + .replaceAll( + "__SIDEBAR_BG_COLOR__", + `rgb(${JSON.stringify(secondaryBackgroundHex)})`, + ); } function buildAdaptedTypstDocument( document: LatexResumeDocument, template: string, - overrides?: Partial, + overrides?: LatexResumeStyleOverrides, ): string { return replaceStylePlaceholders( replaceSharedTypstPlaceholders(template), @@ -513,7 +523,7 @@ export function buildTypstDocument( document: LatexResumeDocument, template: string, tokens: TypstThemeTokens, - overrides?: Partial, + overrides?: LatexResumeStyleOverrides, ): string { const titles = document.sectionTitles ?? getLatexResumeSectionTitles(); const pictureBlock = renderPictureBlock(document); @@ -788,7 +798,7 @@ export async function renderTypstPdf(args: { outputPath: string; jobId: string; typstTheme?: TypstTheme; - typstStyleOverrides?: Partial; + typstStyleOverrides?: LatexResumeStyleOverrides; }): Promise { await typstResumeRenderer.render(args); } diff --git a/orchestrator/src/server/tailoring-flow.test.ts b/orchestrator/src/server/tailoring-flow.test.ts index 31697d9ad..92c9f75c8 100644 --- a/orchestrator/src/server/tailoring-flow.test.ts +++ b/orchestrator/src/server/tailoring-flow.test.ts @@ -20,6 +20,12 @@ vi.mock("./services/pdf-fingerprint", () => ({ designResumeUpdatedAt: null, pdfRenderer: "latex", typstTheme: "classic", + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: null, }), })); @@ -37,6 +43,12 @@ describe("Tailoring Flow", () => { designResumeUpdatedAt: null, pdfRenderer: "latex", typstTheme: "classic", + typstBodyFont: null, + typstHeadingFont: null, + typstPrimaryColor: null, + typstTextColor: null, + typstBackgroundColor: null, + typstSecondaryBackgroundColor: null, rxresumeBaseResumeId: null, }); vi.mocked(jobsRepo.finalizeGeneratedPdfIfCurrent).mockResolvedValue( diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index 6f5fcea58..e9724b07e 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -466,6 +466,17 @@ export const settingsRegistry = { serialize: (value: string | null | undefined): string | null => value ?? null, }, + typstSecondaryBackgroundColor: { + kind: "typed" as const, + schema: z + .string() + .trim() + .regex(/^(#[0-9a-fA-F]{6})?$/, "Must be a 6-digit hex color or empty"), + default: (): string => "", + parse: parseNonEmptyStringOrNull, + serialize: (value: string | null | undefined): string | null => + value ?? null, + }, ukvisajobsMaxJobs: { kind: "typed" as const, schema: z.number().int().min(1).max(1000), diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index d1a445c59..86fc995c8 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -174,6 +174,7 @@ export const createAppSettings = ( typstPrimaryColor: { value: "", default: "", override: null }, typstTextColor: { value: "", default: "", override: null }, typstBackgroundColor: { value: "", default: "", override: null }, + typstSecondaryBackgroundColor: { value: "", default: "", override: null }, rxresumeBaseResumeId: null, ukvisajobsMaxJobs: { value: 50, default: 50, override: null }, adzunaMaxJobsPerTerm: { value: 50, default: 50, override: null }, diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts index 193abcbbf..452d1fdba 100644 --- a/shared/src/types/settings.ts +++ b/shared/src/types/settings.ts @@ -215,6 +215,7 @@ export interface AppSettings { typstPrimaryColor: Resolved; typstTextColor: Resolved; typstBackgroundColor: Resolved; + typstSecondaryBackgroundColor: Resolved; ukvisajobsMaxJobs: Resolved; adzunaMaxJobsPerTerm: Resolved; gradcrackerMaxJobsPerTerm: Resolved; From 2b9fc65ee40a6dab00d815468fdbb23e30a7a1e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:43:46 +0000 Subject: [PATCH 19/27] feat: add font preview sentence Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/f4e27866-ad7d-422a-8969-d5a98cd86e1c Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../components/TypstStyleSettingsSection.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index 84426ec93..0edce87f9 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -4,7 +4,7 @@ import { HEX_COLOR_REGEX } from "@shared/settings-registry.js"; import type { UpdateSettingsInput } from "@shared/settings-schema.js"; import type React from "react"; import { useRef } from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { SearchableDropdown, @@ -49,6 +49,7 @@ const FONT_FAMILY_OPTIONS: SearchableDropdownOption[] = [ { value: "Monaco", label: "Monaco" }, { value: "Menlo", label: "Menlo" }, ]; +const FONT_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog."; type ColorFieldProps = { id: keyof UpdateSettingsInput; @@ -80,6 +81,11 @@ const FontField: React.FC = ({ defaultValue, }) => { const { control } = useFormContext(); + const previewValue = useWatch({ control, name: id }); + const previewFont = + (typeof previewValue === "string" ? previewValue : "").trim() || + effective || + defaultValue; return (
@@ -131,6 +137,12 @@ const FontField: React.FC = ({
+
+ {FONT_PREVIEW_TEXT} +
); }; From f7d7cf024b38448a491d8625b3c7dce9e294200e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:46:24 +0000 Subject: [PATCH 20/27] chore: clarify font preview value naming Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/f4e27866-ad7d-422a-8969-d5a98cd86e1c Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../pages/settings/components/TypstStyleSettingsSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index 0edce87f9..96304a95b 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -81,9 +81,9 @@ const FontField: React.FC = ({ defaultValue, }) => { const { control } = useFormContext(); - const previewValue = useWatch({ control, name: id }); + const selectedFontValue = useWatch({ control, name: id }); const previewFont = - (typeof previewValue === "string" ? previewValue : "").trim() || + (typeof selectedFontValue === "string" ? selectedFontValue : "").trim() || effective || defaultValue; From 6f65c501f9fe874d4c11cf4bb42ed28ee5b3676f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 02:04:29 +0000 Subject: [PATCH 21/27] fix: properly format font preview with CSS quoting and fallbacks Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/3d93d8b1-2c33-402c-939a-85c9864b7bd3 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../components/TypstStyleSettingsSection.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index 96304a95b..67fccc20f 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -87,6 +87,11 @@ const FontField: React.FC = ({ effective || defaultValue; + // Format font family for CSS - wrap in quotes if it contains spaces and add fallbacks + const previewFontFamily = previewFont + ? `"${previewFont}", system-ui, -apple-system, sans-serif` + : undefined; + return (
-
- {FONT_PREVIEW_TEXT} -
+ {previewFontFamily && ( +
+ {FONT_PREVIEW_TEXT} +
+ )} ); }; From 3a0476cd008bbea240f9d54264b370ccf0be8db4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 06:15:38 +0000 Subject: [PATCH 22/27] refactor: move SettingsPage to settings/index.tsx to resolve upstream merge conflict Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/db209ea8-96f7-467e-b1ca-866de661dace Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/client/App.test.tsx | 2 +- orchestrator/src/client/App.tsx | 2 +- .../pages/{SettingsPage.test.tsx => settings/index.test.tsx} | 0 .../src/client/pages/{SettingsPage.tsx => settings/index.tsx} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename orchestrator/src/client/pages/{SettingsPage.test.tsx => settings/index.test.tsx} (100%) rename orchestrator/src/client/pages/{SettingsPage.tsx => settings/index.tsx} (100%) diff --git a/orchestrator/src/client/App.test.tsx b/orchestrator/src/client/App.test.tsx index 195661e44..a10a209e0 100644 --- a/orchestrator/src/client/App.test.tsx +++ b/orchestrator/src/client/App.test.tsx @@ -46,7 +46,7 @@ vi.mock("./pages/OrchestratorPage", () => ({ OrchestratorPage: () => null, })); -vi.mock("./pages/SettingsPage", () => ({ +vi.mock("./pages/settings", () => ({ SettingsPage: () => null, })); diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 487fb9977..9e6ae5181 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -27,7 +27,7 @@ import { JobPage } from "./pages/JobPage"; import { OfflinePage } from "./pages/OfflinePage"; import { OnboardingPage } from "./pages/OnboardingPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; -import { SettingsPage } from "./pages/SettingsPage"; +import { SettingsPage } from "./pages/settings"; import { SignInPage } from "./pages/SignInPage"; import { TracerLinksPage } from "./pages/TracerLinksPage"; import { TrackingInboxPage } from "./pages/TrackingInboxPage"; diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/settings/index.test.tsx similarity index 100% rename from orchestrator/src/client/pages/SettingsPage.test.tsx rename to orchestrator/src/client/pages/settings/index.test.tsx diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/settings/index.tsx similarity index 100% rename from orchestrator/src/client/pages/SettingsPage.tsx rename to orchestrator/src/client/pages/settings/index.tsx From f81b7735812772e4b5b50c7939aa400d625dc752 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 06:32:46 +0000 Subject: [PATCH 23/27] merge: incorporate upstream PR #549 resume-renderer changes - types.ts: add LatexResumeOrderedSectionKey type, sectionOrder field in LatexResumeDocument, and type aliases (ResumeRenderDocument, NormalizeResumeJsonOptions, etc.) - document.ts: add ORDERABLE_SECTION_KEYS, getOrderedSectionKeys(), improved stripHtml preserving bold/italic tags, sectionOrder field in normalizeResumeJsonToLatexDocument, buildResumeRenderDocument alias - index.ts: export buildResumeRenderDocument alongside normalizeResumeJsonToLatexDocument - typst.ts: add escapeRawTypst + HTML-aware escapeTypstText, renderOrderedCoreSections, convertDocFieldsToTypst; wire sectionOrder into buildTypstDocument; use convertDocFieldsToTypst for adapted themes - typst.test.ts: add section ordering, HTML formatting, and convertDocFieldsToTypst tests; import convertDocFieldsToTypst - document.test.ts: add "preserves basic formatting tags and strips other HTML tags" test Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/fad5eadd-65e5-48a3-8dda-1d0de30b38e6 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../services/resume-renderer/document.test.ts | 31 +++ .../services/resume-renderer/document.ts | 60 ++++- .../server/services/resume-renderer/index.ts | 5 +- .../server/services/resume-renderer/types.ts | 23 ++ .../services/resume-renderer/typst.test.ts | 93 ++++++++ .../server/services/resume-renderer/typst.ts | 214 +++++++++++++----- 6 files changed, 370 insertions(+), 56 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/document.test.ts b/orchestrator/src/server/services/resume-renderer/document.test.ts index e208ac98c..9e47d5111 100644 --- a/orchestrator/src/server/services/resume-renderer/document.test.ts +++ b/orchestrator/src/server/services/resume-renderer/document.test.ts @@ -335,4 +335,35 @@ describe("normalizeResumeJsonToLatexDocument", () => { backgroundHex: "#ffffff", }); }); + + it("preserves basic formatting tags and strips other HTML tags", () => { + const document = normalizeResumeJsonToLatexDocument({ + basics: { name: "Jane Doe" }, + summary: { + hidden: false, + content: + '

Bold statement and italic explanation. Also ignored tag.

', + }, + sections: { + experience: { + hidden: false, + items: [ + { + id: "exp-1", + company: "Acme", + description: + '
  • Worked with bold text and italic text.
    Strip this div
', + }, + ], + }, + }, + }); + + expect(document.summary).toBe( + "Bold statement and italic explanation. Also ignored tag .", + ); + expect(document.experience[0]?.bullets).toEqual([ + "Worked with bold text and italic text. Strip this div", + ]); + }); }); diff --git a/orchestrator/src/server/services/resume-renderer/document.ts b/orchestrator/src/server/services/resume-renderer/document.ts index 688c2e557..327e6f33e 100644 --- a/orchestrator/src/server/services/resume-renderer/document.ts +++ b/orchestrator/src/server/services/resume-renderer/document.ts @@ -6,6 +6,7 @@ import type { LatexResumeEntry, LatexResumeInterestItem, LatexResumeLanguageItem, + LatexResumeOrderedSectionKey, LatexResumePicture, LatexResumeProfileItem, LatexResumeSectionTitles, @@ -88,6 +89,21 @@ const LATEX_RESUME_SECTION_TITLES: Record< }, }; +const ORDERABLE_SECTION_KEYS = [ + "profiles", + "experience", + "education", + "projects", + "skills", + "languages", + "interests", + "awards", + "certifications", + "publications", + "volunteer", + "references", +] as const satisfies readonly LatexResumeOrderedSectionKey[]; + function asRecord(value: unknown): RecordLike | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as RecordLike) @@ -149,7 +165,10 @@ function stripHtml(value: string): string { .replace(//gi, "\n") .replace(/<\/p>\s*]*>/gi, "\n") .replace(/<\/li>\s*]*>/gi, "\n") - .replace(/<\/?[^>]+>/g, " "), + .replace( + /<(?!strong\b|b\b|em\b|i\b|\/strong\b|\/b\b|\/em\b|\/i\b)\/?[a-zA-Z0-9]+(?:\s+[^>]*)?>/gi, + " ", + ), ) .replace(/\s*\n\s*/g, "\n") .replace(/[ \t]+/g, " ") @@ -211,6 +230,37 @@ function getCustomFieldsTitle( return toText(basics.customFieldsTitle).trim() || titles.customFields; } +function getOrderedSectionKeys( + resumeJson: RecordLike, +): LatexResumeOrderedSectionKey[] { + const metadata = asRecord(resumeJson.metadata); + const layout = asRecord(metadata?.layout); + const pages = asArray(layout?.pages); + const firstPage = asRecord(pages[0]); + const mainSections = asArray(firstPage?.main); + + const order: LatexResumeOrderedSectionKey[] = []; + const allowed = new Set(ORDERABLE_SECTION_KEYS); + + for (const key of mainSections) { + if ( + typeof key === "string" && + allowed.has(key as LatexResumeOrderedSectionKey) && + !order.includes(key as LatexResumeOrderedSectionKey) + ) { + order.push(key as LatexResumeOrderedSectionKey); + } + } + + for (const key of ORDERABLE_SECTION_KEYS) { + if (!order.includes(key)) { + order.push(key); + } + } + + return order; +} + function componentToHex(value: number): string { return clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"); } @@ -517,6 +567,7 @@ export function normalizeResumeJsonToLatexDocument( publications: buildPublicationEntries(record), volunteer: buildVolunteerEntries(record), references: buildReferenceEntries(record), + sectionOrder: getOrderedSectionKeys(record), style: buildStyle(record), sectionTitles: { profiles: getSectionTitle(record, "profiles", titles), @@ -536,3 +587,10 @@ export function normalizeResumeJsonToLatexDocument( }, }; } + +export function buildResumeRenderDocument( + resumeJson: Record, + options: NormalizeResumeJsonToLatexDocumentOptions = {}, +): LatexResumeDocument { + return normalizeResumeJsonToLatexDocument(resumeJson, options); +} diff --git a/orchestrator/src/server/services/resume-renderer/index.ts b/orchestrator/src/server/services/resume-renderer/index.ts index 03f693890..910a6e37c 100644 --- a/orchestrator/src/server/services/resume-renderer/index.ts +++ b/orchestrator/src/server/services/resume-renderer/index.ts @@ -7,7 +7,10 @@ import type { } from "./types"; import { renderTypstPdf } from "./typst"; -export { normalizeResumeJsonToLatexDocument } from "./document"; +export { + buildResumeRenderDocument, + normalizeResumeJsonToLatexDocument, +} from "./document"; export { getLatexTemplatePath, getTectonicBinary, diff --git a/orchestrator/src/server/services/resume-renderer/types.ts b/orchestrator/src/server/services/resume-renderer/types.ts index 47fc79270..0cee62805 100644 --- a/orchestrator/src/server/services/resume-renderer/types.ts +++ b/orchestrator/src/server/services/resume-renderer/types.ts @@ -77,6 +77,20 @@ export interface LatexResumeSectionTitles { references: string; } +export type LatexResumeOrderedSectionKey = + | "profiles" + | "experience" + | "education" + | "projects" + | "skills" + | "languages" + | "interests" + | "awards" + | "certifications" + | "publications" + | "volunteer" + | "references"; + export interface LatexResumeStyle { colors: { primaryHex: string; @@ -115,6 +129,7 @@ export interface LatexResumeDocument { publications: LatexResumeEntry[]; volunteer: LatexResumeEntry[]; references: LatexResumeEntry[]; + sectionOrder?: LatexResumeOrderedSectionKey[]; sectionTitles?: LatexResumeSectionTitles; style?: LatexResumeStyle; } @@ -134,3 +149,11 @@ export interface ResumeRenderer { export interface NormalizeResumeJsonToLatexDocumentOptions { language?: ChatStyleManualLanguage; } + +export type ResumeRenderContactItem = LatexResumeContactItem; +export type ResumeRenderEntry = LatexResumeEntry; +export type ResumeRenderSkillGroup = LatexResumeSkillGroup; +export type ResumeRenderSectionTitles = LatexResumeSectionTitles; +export type ResumeRenderDocument = LatexResumeDocument; +export type NormalizeResumeJsonOptions = + NormalizeResumeJsonToLatexDocumentOptions; diff --git a/orchestrator/src/server/services/resume-renderer/typst.test.ts b/orchestrator/src/server/services/resume-renderer/typst.test.ts index 02fcbebba..6312e9f3e 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.test.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { LatexResumeDocument } from "./types"; import { buildTypstDocument, + convertDocFieldsToTypst, getTypstBinary, getTypstTemplatePath, normalizeTypstDocumentPicturePath, @@ -187,6 +188,41 @@ describe("typst resume renderer", () => { expect(typst).toContain('#let resume = json("resume-data.json")'); }); + it("respects custom core section ordering", async () => { + const tokens = await readNativeThemeTokens("classic"); + const typst = buildTypstDocument( + { + ...baseDocument, + education: [ + { + title: "University", + subtitle: "MSc", + date: "2020", + bullets: ["Studied distributed systems"], + }, + ], + projects: [ + { + title: "Platform", + subtitle: "TypeScript", + date: "2024", + bullets: ["Built deployment tooling"], + }, + ], + sectionOrder: ["skills", "projects", "experience", "education"], + }, + "__BODY__", + tokens, + ); + + expect(typst.indexOf("= Technical Skills")).toBeLessThan( + typst.indexOf("= Projects"), + ); + expect(typst.indexOf("= Projects")).toBeLessThan( + typst.indexOf("= Experience"), + ); + }); + it("applies document style colors and fonts in native templates", async () => { const tokens = await readNativeThemeTokens("classic"); const typst = buildTypstDocument( @@ -383,6 +419,63 @@ describe("typst resume renderer", () => { expect(typst).toContain("\\#hashes, \\*stars\\*, and \\[brackets\\]"); }); + it("compiles HTML formatting tags into Typst macros and escapes special characters", async () => { + const tokens = await readNativeThemeTokens("classic"); + const typst = buildTypstDocument( + { + ...baseDocument, + summary: + "Bold summary & Italic summary with # and * sign.", + experience: [ + { + title: "Acme", + subtitle: "Senior Platform Engineer", + date: "2023", + bullets: [ + "Managed critical systems.", + "Handled $50k budgets.", + ], + }, + ], + }, + "__BODY__", + tokens, + ); + + expect(typst).toContain( + "#strong[Bold summary] & #emph[Italic summary] with \\# and \\* sign.", + ); + expect(typst).toContain("#strong[Senior] Platform Engineer"); + expect(typst).toContain("Managed #emph[critical] systems."); + expect(typst).toContain("Handled \\$50k budgets."); + }); + + it("converts HTML formatting tags to Typst markup formatting in convertDocFieldsToTypst", () => { + const converted = convertDocFieldsToTypst({ + ...baseDocument, + summary: "Bold and Italic and normal.", + experience: [ + { + title: "Acme", + subtitle: "Platform Engineer", + date: "2023", + bullets: [ + "Managed critical systems.", + "Handled $50k budgets.", + ], + }, + ], + }); + + expect(converted.summary).toBe( + "#strong[Bold] and #emph[Italic] and normal.", + ); + expect(converted.experience[0]?.bullets).toEqual([ + "Managed #emph[critical] systems.", + "Handled \\$50k budgets.", + ]); + }); + it("normalizes absolute picture paths under compile cwd for Typst", async () => { const compileCwd = await createTempDir(); tempDirs.push(compileCwd); diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 236195674..55c59ed22 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -16,6 +16,7 @@ import type { LatexResumeEntry, LatexResumeInterestItem, LatexResumeLanguageItem, + LatexResumeOrderedSectionKey, LatexResumeStyleOverrides, ResumeRenderer, } from "./types"; @@ -188,8 +189,50 @@ function normalizeText(value: string): string { .trim(); } +function escapeRawTypst(value: string): string { + return value.replace(/([\\#*$@_[\]{}<>`])/g, "\\$1"); +} + function escapeTypstText(value: string): string { - return normalizeText(value).replace(/([\\#*$@_[\]{}<>`])/g, "\\$1"); + const normalized = normalizeText(value); + const parts = normalized.split(/(<\/?(?:strong|b|em|i)\b[^>]*>)/gi); + const result: string[] = []; + const tagStack: string[] = []; + + for (const part of parts) { + if (!part) continue; + + if (part.startsWith("<") && part.endsWith(">")) { + const lower = part.toLowerCase(); + if (lower.startsWith("")) { + if (tagStack.pop() === "bold") { + result.push("]"); + } else { + result.push("]"); + } + } else if (lower.startsWith("")) { + if (tagStack.pop() === "italic") { + result.push("]"); + } else { + result.push("]"); + } + } + } else { + result.push(escapeRawTypst(part)); + } + } + + while (tagStack.pop()) { + result.push("]"); + } + + return result.join(""); } function escapeTypstUrl(value: string): string { @@ -519,6 +562,92 @@ export function normalizeTypstDocumentPicturePath( }; } +function renderOrderedCoreSections( + document: LatexResumeDocument, + titles: ReturnType, + metaSize: string, +): string[] { + const sectionOrder: LatexResumeOrderedSectionKey[] = + document.sectionOrder ?? [ + "profiles", + "experience", + "education", + "projects", + "skills", + "languages", + "interests", + "awards", + "certifications", + "publications", + "volunteer", + "references", + ]; + const builders: Record string> = { + profiles: () => renderProfilesSection(document), + experience: () => + renderEntrySection({ + title: titles.experience, + entries: document.experience, + kind: "subheading", + metaSize, + }), + education: () => + renderEntrySection({ + title: titles.education, + entries: document.education, + kind: "subheading", + metaSize, + }), + projects: () => + renderEntrySection({ + title: titles.projects, + entries: document.projects, + kind: "project", + metaSize, + }), + skills: () => renderSkillsSection(document), + languages: () => renderLanguagesSection(document), + interests: () => renderInterestsSection(document), + awards: () => + renderEntrySection({ + title: titles.awards, + entries: document.awards, + kind: "subheading", + metaSize, + }), + certifications: () => + renderEntrySection({ + title: titles.certifications, + entries: document.certifications, + kind: "subheading", + metaSize, + }), + publications: () => + renderEntrySection({ + title: titles.publications, + entries: document.publications, + kind: "subheading", + metaSize, + }), + volunteer: () => + renderEntrySection({ + title: titles.volunteer, + entries: document.volunteer, + kind: "subheading", + metaSize, + }), + references: () => + renderEntrySection({ + title: titles.references, + entries: document.references, + kind: "subheading", + metaSize, + }), + }; + + return sectionOrder.map((key) => builders[key]()); +} + export function buildTypstDocument( document: LatexResumeDocument, template: string, @@ -537,59 +666,8 @@ export function buildTypstDocument( : ""; const body = [ renderSummarySection(document), - renderProfilesSection(document), renderCustomFieldsSection(document), - renderEntrySection({ - title: titles.experience, - entries: document.experience, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.education, - entries: document.education, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.projects, - entries: document.projects, - kind: "project", - metaSize: tokens.entryMetaSize, - }), - renderSkillsSection(document), - renderLanguagesSection(document), - renderInterestsSection(document), - renderEntrySection({ - title: titles.awards, - entries: document.awards, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.certifications, - entries: document.certifications, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.publications, - entries: document.publications, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.volunteer, - entries: document.volunteer, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), - renderEntrySection({ - title: titles.references, - entries: document.references, - kind: "subheading", - metaSize: tokens.entryMetaSize, - }), + ...renderOrderedCoreSections(document, titles, tokens.entryMetaSize), ] .filter(Boolean) .join("\n\n"); @@ -696,6 +774,31 @@ async function runTypst(args: { }); } +export function convertDocFieldsToTypst( + doc: LatexResumeDocument, +): LatexResumeDocument { + const convertBullets = (bullets: string[]) => + bullets.map((bullet) => escapeTypstText(bullet)); + + const convertEntry = (entry: LatexResumeEntry): LatexResumeEntry => ({ + ...entry, + bullets: convertBullets(entry.bullets), + }); + + return { + ...doc, + summary: doc.summary ? escapeTypstText(doc.summary) : null, + experience: doc.experience.map(convertEntry), + education: doc.education.map(convertEntry), + projects: doc.projects.map(convertEntry), + awards: doc.awards.map(convertEntry), + certifications: doc.certifications.map(convertEntry), + publications: doc.publications.map(convertEntry), + volunteer: doc.volunteer.map(convertEntry), + references: doc.references.map(convertEntry), + }; +} + export const typstResumeRenderer: ResumeRenderer = { async render({ document, @@ -720,6 +823,7 @@ export const typstResumeRenderer: ResumeRenderer = { tempDir, ); let typst: string; + let resumeDataDoc: LatexResumeDocument; if (manifest.kind === "native") { if (!tokens) { throw new Error( @@ -732,15 +836,17 @@ export const typstResumeRenderer: ResumeRenderer = { tokens, typstStyleOverrides, ); + resumeDataDoc = typstDocument; } else { typst = buildAdaptedTypstDocument( typstDocument, template, typstStyleOverrides, ); + resumeDataDoc = convertDocFieldsToTypst(typstDocument); } - await writeFile(resumeDataPath, JSON.stringify(typstDocument), "utf8"); + await writeFile(resumeDataPath, JSON.stringify(resumeDataDoc), "utf8"); await writeFile(typPath, typst, "utf8"); await runTypst({ cwd: tempDir, From e305d5e647369f4003ae03c0c9928e22eb5ccc26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 06:35:13 +0000 Subject: [PATCH 24/27] fix: simplify redundant if-else branches in escapeTypstText and add regex comment Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/fad5eadd-65e5-48a3-8dda-1d0de30b38e6 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../server/services/resume-renderer/document.ts | 1 + .../src/server/services/resume-renderer/typst.ts | 14 ++++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/document.ts b/orchestrator/src/server/services/resume-renderer/document.ts index 327e6f33e..110a55759 100644 --- a/orchestrator/src/server/services/resume-renderer/document.ts +++ b/orchestrator/src/server/services/resume-renderer/document.ts @@ -166,6 +166,7 @@ function stripHtml(value: string): string { .replace(/<\/p>\s*]*>/gi, "\n") .replace(/<\/li>\s*]*>/gi, "\n") .replace( + // Strip all tags except inline formatting: , , , and their closing variants /<(?!strong\b|b\b|em\b|i\b|\/strong\b|\/b\b|\/em\b|\/i\b)\/?[a-zA-Z0-9]+(?:\s+[^>]*)?>/gi, " ", ), diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index 55c59ed22..bc528e7c4 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -208,20 +208,14 @@ function escapeTypstText(value: string): string { result.push("#strong["); tagStack.push("bold"); } else if (lower.startsWith("")) { - if (tagStack.pop() === "bold") { - result.push("]"); - } else { - result.push("]"); - } + tagStack.pop(); + result.push("]"); } else if (lower.startsWith("")) { - if (tagStack.pop() === "italic") { - result.push("]"); - } else { - result.push("]"); - } + tagStack.pop(); + result.push("]"); } } else { result.push(escapeRawTypst(part)); From 85c7ad8e0ad965f278ba1a577dd5eb002affcc37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 07:10:44 +0000 Subject: [PATCH 25/27] fix: stray closing tags in escapeTypstText, ColorField error message, resolveTypstStyleOverrides return type Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/b3e79194-7bbc-4a92-9a61-53ab3e09f241 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- .../components/TypstStyleSettingsSection.tsx | 5 +++++ orchestrator/src/server/services/pdf.ts | 15 +++++---------- .../src/server/services/resume-renderer/typst.ts | 12 ++++++++---- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx index 67fccc20f..6bb7151be 100644 --- a/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/TypstStyleSettingsSection.tsx @@ -215,6 +215,11 @@ const ColorField: React.FC = ({ fieldState.error ? "border-destructive" : undefined } /> + {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} ); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 8f8c0ebf1..820fb5a2d 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -19,7 +19,10 @@ import { getTenantJobPdfPath, getTenantPdfDir, } from "./pdf-storage"; -import { renderResumePdf } from "./resume-renderer"; +import { + type LatexResumeStyleOverrides, + renderResumePdf, +} from "./resume-renderer"; import { deleteResume as deleteRxResume, exportResumePdf as exportRxResumePdf, @@ -88,15 +91,7 @@ async function resolveTypstTheme() { } async function resolveTypstStyleOverrides(): Promise< - | { - colors?: { - primaryHex?: string; - textHex?: string; - backgroundHex?: string; - }; - typography?: { bodyFontFamily?: string; headingFontFamily?: string }; - } - | undefined + LatexResumeStyleOverrides | undefined > { const [ bodyFont, diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index ed5936dae..d2bf981f7 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -208,14 +208,18 @@ function escapeTypstText(value: string): string { result.push("#strong["); tagStack.push("bold"); } else if (lower.startsWith("")) { - tagStack.pop(); - result.push("]"); + if (tagStack.length > 0 && tagStack[tagStack.length - 1] === "bold") { + tagStack.pop(); + result.push("]"); + } } else if (lower.startsWith("")) { - tagStack.pop(); - result.push("]"); + if (tagStack.length > 0 && tagStack[tagStack.length - 1] === "italic") { + tagStack.pop(); + result.push("]"); + } } } else { result.push(escapeRawTypst(part)); From 9c35686352526d51e7bfc1a561cdd696f0ddbe6c Mon Sep 17 00:00:00 2001 From: Tamay <92460164+tamaygz@users.noreply.github.com> Date: Mon, 25 May 2026 09:27:34 +0200 Subject: [PATCH 26/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/services/resume-renderer/typst.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/orchestrator/src/server/services/resume-renderer/typst.ts b/orchestrator/src/server/services/resume-renderer/typst.ts index d2bf981f7..990926f4d 100644 --- a/orchestrator/src/server/services/resume-renderer/typst.ts +++ b/orchestrator/src/server/services/resume-renderer/typst.ts @@ -595,10 +595,6 @@ function replaceStylePlaceholders( .replaceAll( "__SECONDARY_BACKGROUND_COLOR__", `rgb(${JSON.stringify(secondaryBackgroundHex)})`, - ) - .replaceAll( - "__SIDEBAR_BG_COLOR__", - `rgb(${JSON.stringify(secondaryBackgroundHex)})`, ); } From ac1ed223653b7118300548bb7e02457cb49496a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 11:06:23 +0000 Subject: [PATCH 27/27] fix: CI failures - Biome import order in App.tsx and stale relative paths in settings/index.test.tsx Agent-Logs-Url: https://github.com/tamaygz/job-ops/sessions/0418b07a-41f6-4e0a-98d6-f26d1433b952 Co-authored-by: tamaygz <92460164+tamaygz@users.noreply.github.com> --- orchestrator/src/client/App.tsx | 2 +- orchestrator/src/client/pages/settings/index.test.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 9e6ae5181..d3837283c 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -27,8 +27,8 @@ import { JobPage } from "./pages/JobPage"; import { OfflinePage } from "./pages/OfflinePage"; import { OnboardingPage } from "./pages/OnboardingPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; -import { SettingsPage } from "./pages/settings"; import { SignInPage } from "./pages/SignInPage"; +import { SettingsPage } from "./pages/settings"; import { TracerLinksPage } from "./pages/TracerLinksPage"; import { TrackingInboxPage } from "./pages/TrackingInboxPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; diff --git a/orchestrator/src/client/pages/settings/index.test.tsx b/orchestrator/src/client/pages/settings/index.test.tsx index 3276db9af..6253e90e9 100644 --- a/orchestrator/src/client/pages/settings/index.test.tsx +++ b/orchestrator/src/client/pages/settings/index.test.tsx @@ -4,17 +4,17 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as api from "../api"; -import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; -import { renderWithQueryClient } from "../test/renderWithQueryClient"; -import { SettingsPage } from "./SettingsPage"; +import * as api from "../../api"; +import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; +import { SettingsPage } from "./index"; const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; const render = (ui: Parameters[0]) => renderWithQueryClient(ui); -vi.mock("../api", () => ({ +vi.mock("../../api", () => ({ getSettings: vi.fn(), getLlmModels: vi.fn().mockResolvedValue([]), getCodexAuthStatus: vi.fn().mockResolvedValue({