@@ -166,11 +166,10 @@ def cover_letter_palette(self) -> dict:
166166 "header_border_width" : f"{ self .header_border_px } px" ,
167167 "header_rule_color" : self .header_rule_color ,
168168 # The letter is all prose, so it follows the theme's PROSE
169- # font (not body). For classic_ats / professional_neutral
170- # prose IS Georgia → byte-identical to the old hardcoded
171- # value (zero regression). For sans themes (modern_blue)
172- # the letter now matches its résumé instead of clashing as
173- # a lone serif document — the "matched set" guarantee.
169+ # font (not body). Since the 2026-05-21 typography
170+ # unification every theme's prose font is the shared Arial
171+ # sans family, so the cover letter always matches its
172+ # résumé — the "matched set" guarantee.
174173 "body_font_family" : self .prose_font_family ,
175174 }
176175
@@ -186,10 +185,19 @@ def docx_palette(self) -> dict:
186185 }
187186
188187
189- # The registry. EXISTING two themes reproduce the pre-refactor literal
190- # maps byte-for-byte (verified via golden snapshots). NEW themes are
191- # added here only — the three derived maps + every renderer pick them
192- # up automatically, so resume + cover letter + DOCX can never drift.
188+ # The registry. NEW themes are added here only — the three derived
189+ # maps + every renderer pick them up automatically, so resume + cover
190+ # letter + DOCX can never drift.
191+ #
192+ # TYPOGRAPHY (operator decision 2026-05-21): all five themes share ONE
193+ # font family — Arial / Helvetica sans-serif (h1, body, prose; Arial
194+ # for the DOCX OOXML names). Themes used to mix serif/sans for
195+ # identity (professional_neutral was all-Georgia, classic_ats +
196+ # creative_warm had serif headings); that was unified to a single sans
197+ # family per the operator's request. Themes now differentiate by
198+ # COLOR, PAPER, and HEADER TREATMENT only — not by typeface. Any new
199+ # theme MUST use the same Arial sans family unless that decision is
200+ # revisited.
193201_THEME_SPECS : dict [str , "ThemeSpec" ] = {
194202 "classic_ats" : ThemeSpec (
195203 key = "classic_ats" ,
@@ -202,13 +210,15 @@ def docx_palette(self) -> dict:
202210 surface = "#fffdfa" ,
203211 accent_soft = "rgba(143, 104, 69, 0.10)" ,
204212 cover_strong_color = "#17100b" ,
213+ # Unified Arial/Helvetica sans family across all themes
214+ # (operator decision 2026-05-21) — see the registry note above.
205215 body_font_family = "Arial, Helvetica, sans-serif" ,
206- h1_font_family = 'Georgia, "Times New Roman", serif' ,
207- prose_font_family = 'Georgia, "Times New Roman", serif' ,
216+ h1_font_family = "Arial, Helvetica, sans- serif" ,
217+ prose_font_family = "Arial, Helvetica, sans- serif" ,
208218 prose_line_height = "1.55" ,
209219 docx_body_font = "Arial" ,
210- docx_heading_font = "Georgia " ,
211- docx_prose_font = "Georgia " ,
220+ docx_heading_font = "Arial " ,
221+ docx_prose_font = "Arial " ,
212222 header_border_px = 3 ,
213223 layout = "single_column" ,
214224 ),
@@ -223,24 +233,25 @@ def docx_palette(self) -> dict:
223233 surface = "#ffffff" ,
224234 accent_soft = "rgba(0, 0, 0, 0.04)" ,
225235 cover_strong_color = "#0a0a0a" ,
226- body_font_family = 'Georgia, "Times New Roman", serif' ,
227- h1_font_family = 'Georgia, "Times New Roman", serif' ,
228- prose_font_family = 'Georgia, "Times New Roman", serif' ,
236+ # Unified Arial/Helvetica sans family across all themes
237+ # (operator decision 2026-05-21) — see the registry note above.
238+ body_font_family = "Arial, Helvetica, sans-serif" ,
239+ h1_font_family = "Arial, Helvetica, sans-serif" ,
240+ prose_font_family = "Arial, Helvetica, sans-serif" ,
229241 prose_line_height = "1.55" ,
230- docx_body_font = "Georgia " ,
231- docx_heading_font = "Georgia " ,
232- docx_prose_font = "Georgia " ,
242+ docx_body_font = "Arial " ,
243+ docx_heading_font = "Arial " ,
244+ docx_prose_font = "Arial " ,
233245 header_border_px = 2 ,
234246 layout = "single_column" ,
235247 ),
236- # NEW (Phase 2a) — modern all-sans + one deep professional blue.
237- # Single-column → fully ATS-safe. Differs from the two originals
238- # by being sans THROUGHOUT (classic_ats = serif headings/prose;
239- # professional_neutral = all-serif) with a cool slate ink + blue
240- # accent. Accent #1A56DB clears ~5.9:1 on white (safe for any
241- # text). Fonts stay web-safe sans so WeasyPrint renders it
242- # identically on the Linux render host (no missing-font fallback).
243- # Audience: tech / product / data / ops.
248+ # NEW (Phase 2a) — modern, one deep professional blue.
249+ # Single-column → fully ATS-safe. Differentiated by a cool slate
250+ # ink + blue accent on a faint cool paper. Accent #1A56DB clears
251+ # ~5.9:1 on white (safe for any text). Fonts are the shared
252+ # Arial/Helvetica sans family (see registry note) — web-safe so
253+ # WeasyPrint renders identically on the Linux render host (no
254+ # missing-font fallback). Audience: tech / product / data / ops.
244255 "modern_blue" : ThemeSpec (
245256 key = "modern_blue" ,
246257 label = "Modern Blue" ,
@@ -266,14 +277,13 @@ def docx_palette(self) -> dict:
266277 header_border_px = 2 ,
267278 layout = "single_column" ,
268279 ),
269- # NEW (Phase 2b) — modern editorial. Serif NAME (Georgia, h1 only)
270- # for gravitas; everything else clean sans (scannable + ATS-safe);
271- # emerald accent for creative energy without leaving professional.
272- # Distinct from classic_ats (brown + serif prose, heavy cream) and
273- # from modern_blue (cool blue, all-sans). Faint near-neutral warm
274- # paper. Emerald #00a388 is the proven Awesome-CV accent; it only
275- # colors headings/rules/labels (body stays ink) so contrast is
276- # fine. Single-column → ATS-safe. Audience: marketing / comms /
280+ # NEW (Phase 2b) — modern editorial. Emerald accent for creative
281+ # energy without leaving professional; a soft warm "sand" header
282+ # band. Distinct from classic_ats (warm brown + cream paper) and
283+ # from modern_blue (cool blue). Faint near-neutral warm paper.
284+ # Emerald #00a388 is the proven Awesome-CV accent; it only colors
285+ # headings/rules/labels (body stays ink) so contrast is fine.
286+ # Single-column → ATS-safe. Audience: marketing / comms /
277287 # design-adjacent that still needs to pass ATS.
278288 "creative_warm" : ThemeSpec (
279289 key = "creative_warm" ,
@@ -295,26 +305,28 @@ def docx_palette(self) -> dict:
295305 # name/body divider reads as a deliberate anchor line rather
296306 # than the same bright emerald (operator request).
297307 header_rule_color = "#0b7c5e" ,
298- h1_font_family = 'Georgia, "Times New Roman", serif' ,
308+ # Unified Arial/Helvetica sans family across all themes
309+ # (operator decision 2026-05-21) — see the registry note above.
310+ h1_font_family = "Arial, Helvetica, sans-serif" ,
299311 body_font_family = "Arial, Helvetica, sans-serif" ,
300312 prose_font_family = "Arial, Helvetica, sans-serif" ,
301313 prose_line_height = "1.55" ,
302314 docx_body_font = "Arial" ,
303- docx_heading_font = "Georgia " ,
315+ docx_heading_font = "Arial " ,
304316 docx_prose_font = "Arial" ,
305317 header_border_px = 2 ,
306318 layout = "single_column" ,
307319 ),
308320 # NEW (Phase 2c) — architectural minimal. Near-monochrome: deep
309321 # cool near-black ink AND accent (no colour — the "design" is
310- # typographic), one HAIRLINE rule (header_border_px=1), geometric
311- # sans, airier prose line-height for generous whitespace. Crisp
312- # pure white on purpose (deliberate contrast to modern_blue's
313- # tint — minimalism reads as stark, not "designed paper").
314- # Single-column → ATS-safe. Audience: architecture / design /
315- # senior-eng "confident minimal". Distinct from professional_neutral
316- # (which is the same monochrome idea but SERIF Georgia; this is
317- # SANS + hairline + airy ).
322+ # typographic), one HAIRLINE rule (header_border_px=1), airier
323+ # prose line-height for generous whitespace, and a solid ink
324+ # masthead band. Crisp pure white on purpose (deliberate contrast
325+ # to modern_blue's tint — minimalism reads as stark, not "designed
326+ # paper"). Single-column → ATS-safe. Audience: architecture /
327+ # design / senior-eng "confident minimal". Distinct from
328+ # professional_neutral ( same monochrome idea, but architect_mono
329+ # adds the hairline rule + ink masthead + airier spacing ).
318330 "architect_mono" : ThemeSpec (
319331 key = "architect_mono" ,
320332 label = "Architect Mono" ,
@@ -511,8 +523,8 @@ def _build_cover_letter_html(text, title="Cover Letter", theme="classic_ats"):
511523 <style>
512524 /* Theme-keyed palette: classic_ats keeps the warm-brown letter
513525 feel; professional_neutral drops to pure black/white/gray for
514- conservative recipients while keeping the same Georgia letter
515- prose . */
526+ conservative recipients. Prose font is the shared Arial sans
527+ family across every theme (2026-05-21 unification) . */
516528 :root {{
517529 --ink: {ink};
518530 --muted: {muted};
@@ -1280,10 +1292,10 @@ def _build_resume_html(text, title="Tailored Resume", theme="classic_ats", artif
12801292 <title>{title}</title>
12811293 <style>
12821294 @page {{ size: A4; margin: 0; }}
1283- /* Theme-keyed palette: classic_ats ships warm-brown + Georgia
1284- prose so the resume reads as a set with the cover letter;
1285- professional_neutral collapses to pure black/white/gray with
1286- all- Arial body for conservative recruiters / B&W printing . */
1295+ /* Theme-keyed palette: classic_ats ships warm-brown,
1296+ professional_neutral collapses to pure black/white/gray for
1297+ conservative recruiters / B&W printing. Typeface is the
1298+ shared Arial sans family for every theme (2026-05-21) . */
12871299 :root {{
12881300 --ink: {ink};
12891301 --muted: {muted};
@@ -1296,9 +1308,11 @@ def _build_resume_html(text, title="Tailored Resume", theme="classic_ats", artif
12961308 * {{ box-sizing: border-box; }}
12971309 html, body {{ margin: 0; padding: 0; }}
12981310 body {{ font-family: {body_font_family}; color: var(--ink); background: var(--paper); font-size: 10.5pt; line-height: 1.5; }}
1299- /* Editorial pairing: in classic_ats the prose-y parts (summary,
1300- bullets) shift to Georgia so the resume harmonizes with the
1301- cover letter; professional_neutral keeps Arial throughout. */
1311+ /* Prose-y parts (summary, bullets) use the theme's prose font.
1312+ Since the 2026-05-21 typography unification that is the same
1313+ shared Arial sans family as the body for every theme; the
1314+ rule is kept so a future theme could re-introduce a distinct
1315+ prose face without a renderer change. */
13021316 .resume-summary, .resume-bullet-list li {{ font-family: {prose_font_family}; line-height: {prose_line_height}; }}
13031317 /* min-height keeps short resumes filling a single A4 page;
13041318 overflow-x: hidden prevents the negative-margin h2 trick from
@@ -1784,6 +1798,10 @@ def _resolve_docx_palette(theme: str | None) -> dict[str, str]:
17841798# Default page margins (in inches). Matches the ~18mm @page margin the
17851799# WeasyPrint renderer uses for the classic_ats resume shell.
17861800_DOCX_PAGE_MARGIN_INCHES = 0.7
1801+ # US Letter page width. The résumé DOCX is always Letter; the content
1802+ # column width = this minus both margins. Used to place the role-row
1803+ # RIGHT tab stop at the true right edge of the text column.
1804+ _DOCX_LETTER_WIDTH_INCHES = 8.5
17871805
17881806
17891807def _docx_add_bottom_border (paragraph , * , color_hex : str , size_eighths_pt : int = 6 ):
@@ -1899,6 +1917,26 @@ def _docx_add_resume_header(document, artifact: TailoredResumeArtifact, *, palet
18991917 bold = True ,
19001918 )
19011919
1920+ # Mode-aware headline / role line. The HTML+PDF builders render
1921+ # `artifact.target_role` as an uppercase muted line between the
1922+ # name and the contact block (`.resume-classic-role`, added
1923+ # 2026-05-19). The DOCX header builder was missed in that change —
1924+ # so a JD-tailored résumé showed its role on the PDF but not the
1925+ # DOCX. Render it here too (omitted entirely when target_role is
1926+ # "" — a name-only header stays standard; we never fabricate one).
1927+ headline = str (getattr (artifact , "target_role" , "" ) or "" ).strip ()
1928+ if headline :
1929+ role_paragraph = document .add_paragraph ()
1930+ role_paragraph .alignment = _docx_alignment ("left" )
1931+ role_paragraph .paragraph_format .space_after = _docx_pt (2 )
1932+ role_run = role_paragraph .add_run (headline .upper ())
1933+ _docx_apply_run_font (
1934+ role_run ,
1935+ family = palette ["body_font" ],
1936+ size_pt = 10 ,
1937+ color_hex = palette ["muted" ],
1938+ )
1939+
19021940 contact_values = []
19031941 if artifact .header .location :
19041942 contact_values .append (artifact .header .location .strip ())
@@ -1951,8 +1989,16 @@ def _docx_add_role_row(document, *, title: str, dates: str, palette: dict):
19511989 paragraph = document .add_paragraph ()
19521990 paragraph .paragraph_format .space_before = _docx_pt (4 )
19531991 paragraph .paragraph_format .space_after = _docx_pt (0 )
1992+ # RIGHT tab stop at the right edge of the text column so the date
1993+ # sits flush-right (mirrors the PDF's flex `.resume-role-row`).
1994+ # Tab-stop positions are measured from the LEFT margin, so the
1995+ # right edge = content width = page width minus both margins.
1996+ # BUGFIX: this previously read `7.1 - 2 * margin` — but 7.1 was
1997+ # ALREADY the content width (8.5 - 2*0.7), so the margins were
1998+ # subtracted twice and every date landed 1.4in short of the
1999+ # margin instead of flush-right.
19542000 paragraph .paragraph_format .tab_stops .add_tab_stop (
1955- _docx_inches (7.1 - 2 * _DOCX_PAGE_MARGIN_INCHES ),
2001+ _docx_inches (_DOCX_LETTER_WIDTH_INCHES - 2 * _DOCX_PAGE_MARGIN_INCHES ),
19562002 WD_TAB_ALIGNMENT .RIGHT ,
19572003 )
19582004
@@ -2025,9 +2071,9 @@ def _docx_add_paragraph_text(document, text: str, *, palette: dict, font_key: st
20252071
20262072def _docx_resume_summary_block (document , artifact : TailoredResumeArtifact , * , palette : dict ):
20272073 _docx_resume_section_heading (document , "Summary" , palette = palette )
2028- # Prose summary uses the prose font (Georgia in both themes) so
2029- # the headline reads more like a written paragraph than a body
2030- # bullet, regardless of theme .
2074+ # Prose summary uses the theme's prose font — the shared Arial
2075+ # sans family across every theme since the 2026-05-21 typography
2076+ # unification .
20312077 _docx_add_paragraph_text (
20322078 document ,
20332079 artifact .professional_summary or "No professional summary generated." ,
@@ -2201,10 +2247,11 @@ def _build_resume_docx(artifact: TailoredResumeArtifact) -> bytes:
22012247 `artifact.section_order` and falls back to
22022248 `_DEFAULT_RESUME_SECTION_ORDER` for legacy callers.
22032249
2204- Theme is read from `artifact.theme`; supported values are
2205- `classic_ats` (warm-cream / brown accents, Arial body) and
2206- `professional_neutral` (pure black / gray, Georgia body). Unknown
2207- values fall back to `classic_ats`.
2250+ Theme is read from `artifact.theme`; supported values are the five
2251+ user-facing themes (`professional_neutral`, `classic_ats`,
2252+ `modern_blue`, `creative_warm`, `architect_mono`) — all sharing the
2253+ one Arial sans family, differentiated by colour / paper / header.
2254+ Unknown values fall back to `classic_ats`.
22082255 """
22092256 from docx import Document
22102257
@@ -2266,11 +2313,10 @@ def _build_cover_letter_docx(artifact: CoverLetterArtifact) -> bytes:
22662313 `_split_cover_letter_title` so the heading + role-eyebrow read the
22672314 same way as the HTML render.
22682315
2269- Theme is read from `artifact.theme`; both classic_ats and
2270- professional_neutral use the prose font (Georgia) for the body
2271- because the cover letter is letter-shaped prose in either palette.
2272- The theme switch only changes ink / muted / accent / line colors
2273- + the small-caps eyebrow font (which is body_font).
2316+ Theme is read from `artifact.theme`; every theme uses the shared
2317+ Arial sans family (prose font) for the letter body — the cover
2318+ letter is letter-shaped prose in any palette. The theme switch
2319+ only changes ink / muted / accent / line colors.
22742320 """
22752321 from docx import Document
22762322
0 commit comments