openvibers/afresume Commits

Resume PDF: overhaul header/feature-cards/projects, add role-chip icons, fix orange-number bug; wire Download PDF to the visitor's chosen theme

OP OpenVibers committed 20 Sept 2026 · 6be2dd9cbc · 34 files, +182 −112

- block_header(): role chips now carry per-role Font Awesome icons
  (matched by keyword against profile.json's roles), the missing
  "icons" gap called out in the last screenshot round.
- main(): every preset, including the default/blue build, now goes
  through configure_palette() — the default no longer keeps the old
  hardcoded orange CORAL while every other preset got the newer
  monochromatic derivation.
- block_feature_cards() and block_projects() carried over from the
  prior pass (2x2 grid, top-right link badge) — rebuilt this round's
  13 PDFs (content hash 4a270599) on top of the icon fix.
- New: Base.astro exposes window.__ovApplyResumeLinks(hex), matching
  a chosen accent to the nearest theme-presets.json entry by hue and
  rewriting every [data-resume-link] anchor (Nav x2, Footer, Contact,
  Hero, resume.astro's primary button) to that preset's pre-generated
  PDF. Runs on load from localStorage and live from ThemePicker's
  choose()/live-preview, so the button always matches what's on
  screen. resume.astro's separate one-off swatch picker is untouched.

Changes

ddiff --git a/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf
nnew file mode 100644
iindex 0000000..da35485
BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf b/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf
ddeleted file mode 100644
iindex d23a1e2..0000000
BBinary files a/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf
nnew file mode 100644
iindex 0000000..22f4047
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf
ddeleted file mode 100644
iindex 0aaa7dd..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf
ddeleted file mode 100644
iindex 0d2baab..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf
nnew file mode 100644
iindex 0000000..9ef1316
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf
ddeleted file mode 100644
iindex df4134a..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_4a270599.pdf b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf
nnew file mode 100644
iindex 0000000..9f8d3f0
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf
ddeleted file mode 100644
iindex 585aad0..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf
nnew file mode 100644
iindex 0000000..d7757d7
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf
ddeleted file mode 100644
iindex 9558b5b..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf
nnew file mode 100644
iindex 0000000..f46a47c
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf
ddeleted file mode 100644
iindex 5b52c14..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf
nnew file mode 100644
iindex 0000000..8bef843
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf
ddeleted file mode 100644
iindex fef1968..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf
nnew file mode 100644
iindex 0000000..7185c8a
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf
ddeleted file mode 100644
iindex ebf482b..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_4a270599.pdf b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf
nnew file mode 100644
iindex 0000000..7faf28c
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf
ddeleted file mode 100644
iindex ce48c10..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf
nnew file mode 100644
iindex 0000000..844739b
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf
ddeleted file mode 100644
iindex 7d4168d..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf
nnew file mode 100644
iindex 0000000..d878c7b
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf
ddeleted file mode 100644
iindex cd8c040..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf
nnew file mode 100644
iindex 0000000..ef6ad06
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf b/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf
ddeleted file mode 100644
iindex eabc806..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf and /dev/null differ
ddiff --git a/resume/build_resume.py b/resume/build_resume.py
iindex 68e2313..fc3e96d 100644
---- a/resume/build_resume.py
++++ b/resume/build_resume.py
@@@ -53,7 +53,7 @@ SELECTED_PROJECTS = [
# Metrics strip: pick by a keyword found in the label, in this order.
# Falls back to the first four metrics if fewer than four match.
METRIC_KEYWORDS = ["services", "routes", "payment processors", "irdrs", "years"]
--FULL_ROLES = 2 # roles with summary + all bullets on page 1 (feature-cards + a full metrics strip need the room)
++FULL_ROLES = 2 # PowerChat + OpenVibe get full treatment on page 1
COMPACT_BULLETS = 2 # bullets per role on page 2
# --------------------------------------------------------------------------
@@@ -79,11 +79,11 @@ ICON_BG = HexColor("#dbe3ff")
# Page geometry
# --------------------------------------------------------------------------
PAGE_W, PAGE_H = letter
--MARGIN = 28.0
++MARGIN = 23.0
CONTENT_W = PAGE_W - 2 * MARGIN
TOP = PAGE_H - MARGIN
BOTTOM = MARGIN
--CARD_PAD = 6.5
++CARD_PAD = 6.0
# --------------------------------------------------------------------------
# Fonts
@@@ -187,6 +187,24 @@ def metric_icon(css_class: str) -> str:
return DEFAULT_ICON
++# profile.json `roles[]` (the header chip row) → icon, matched by keyword; falls back to
++# the generic "engineer" glyph so an unrecognized role degrades instead of going icon-less.
++ROLE_ICON_KEYWORDS = [
++ ("idea", "\uf0eb"), ("business", "\uf0eb"), ("automat", "\uf085"),
++ ("platform", "\uf233"), ("architect", "\uf233"), ("founder", "\uf135"),
++ ("ai ", "\uf2db"), ("engineer", "\uf121"),
++]
++DEFAULT_ROLE_ICON = "\uf121" # code
++
++
++def role_icon(text: str) -> str:
++ t = (text or "").lower()
++ for kw, glyph in ROLE_ICON_KEYWORDS:
++ if kw in t:
++ return glyph
++ return DEFAULT_ROLE_ICON
++
++
# profile.json `seeking[].label` → icon for the feature-card row, matched by keyword.
SEEKING_ICON_KEYWORDS = [
("anything", ""), ("engineer", ""), ("analytic", ""), ("data", ""),
@@@ -302,11 +320,11 @@ def style(name: str, size: float, leading: float, color, font: str | None = None
class Styles:
def __init__(self) -> None:
self.body = style("body", 8.7, 10.8, BODY)
-- self.summary = style("summary", 8.0, 9.6, MUTED, FONT["italic"])
-- self.bullet = style("bullet", 8.1, 9.5, BODY, leftIndent=9, bulletIndent=0,
-- bulletFontName=FONT["regular"], bulletFontSize=8.1, bulletColor=BLUE)
-- self.bullet_compact = style("bulletc", 8.1, 9.5, BODY, leftIndent=9, bulletIndent=0,
-- bulletFontName=FONT["regular"], bulletFontSize=8.1, bulletColor=BLUE)
++ self.summary = style("summary", 8.1, 9.8, MUTED, FONT["italic"])
++ self.bullet = style("bullet", 8.2, 9.9, BODY, leftIndent=9, bulletIndent=0,
++ bulletFontName=FONT["regular"], bulletFontSize=8.2, bulletColor=BLUE)
++ self.bullet_compact = style("bulletc", 8.2, 10.0, BODY, leftIndent=9, bulletIndent=0,
++ bulletFontName=FONT["regular"], bulletFontSize=8.2, bulletColor=BLUE)
self.stack = style("stack", 6.9, 8.3, MUTED)
self.metric_label = style("mlabel", 7.0, 8.5, MUTED)
self.proj_title = style("ptitle", 8.5, 10.2, INK, FONT["bold"])
@@@ -517,18 +535,19 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
# feature cards above) there is no separate "measure" vs "draw" width that could disagree.
roles = (profile.get("roles") or [])[:6]
chip_font, chip_size, chip_pad_x, chip_gap = FONT["bold"], 7.6, 20.0, 7.0
++ chip_icon_w = 13.0 # icon glyph + gap before the label
chips_w = w - pad * 2 - panel_w - 14
chip_rows: list[list[tuple[str, float]]] = [[]]
cx = 0.0
for r in roles:
-- cw = pdfmetrics.stringWidth(r, chip_font, chip_size) + chip_pad_x
++ cw = pdfmetrics.stringWidth(r, chip_font, chip_size) + chip_pad_x + chip_icon_w
if cx > 0 and cx + cw > chips_w:
chip_rows.append([])
cx = 0.0
chip_rows[-1].append((r, cw))
cx += cw + chip_gap
-- chip_h = chip_size + 10
-- chips_total_h = len(chip_rows) * chip_h + max(0, len(chip_rows) - 1) * 5
++ chip_h = chip_size + 8
++ chips_total_h = len(chip_rows) * chip_h + max(0, len(chip_rows) - 1) * 4
banner_h = pad + name_size * 0.78 + 5 + tag_size * 0.78 + 12 + chips_total_h + pad
banner_h = max(banner_h, pad * 1.6 + panel_h)
@@@ -567,9 +586,10 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
c.setFillAlpha(0.4)
c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)
c.restoreState()
++ icon(c, cx2 + chip_pad_x / 2, row_top - chip_h / 2 - chip_size * 0.36, role_icon(text), chip_size, white)
c.setFillColor(white)
c.setFont(chip_font, chip_size)
-- c.drawString(cx2 + chip_pad_x / 2, row_top - chip_h / 2 - chip_size * 0.36, text)
++ c.drawString(cx2 + chip_pad_x / 2 + chip_icon_w, row_top - chip_h / 2 - chip_size * 0.36, text)
cx2 += cw + chip_gap
row_top -= chip_h + 5
@@@ -595,51 +615,61 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw: bool) -> float:
-- """Four bordered cards, one per profile.json `seeking` entry: an icon circle, a bold
-- one-line title (truncated with an ellipsis if it doesn't fit — the full label is never
-- hidden, since it also has its own place on the site's Now.astro section), a divider, and
-- its detail sentence as a bullet.
--
-- Titles here are deliberately capped to a single line rather than left to wrap: with four
-- cards forced to a shared height, letting one label wrap to 2-3 lines either stretched every
-- other card into wasted blank space or — when the wrap was measured against one width and
-- room was tighter at draw time — let the detail text collide with the title's last line.
-- A fixed one-line title makes every card's height the same simple sum, with no dependency on
-- how long any one label happens to be."""
-- n = max(1, len(seeking))
++ """A 2×2 grid, one card per profile.json `seeking` entry: icon, one-line title (still
++ truncated as a last resort if a future label is unusually long — but at this width none of
++ the current four are), a divider, and its detail sentence as a bullet.
++
++ Two columns instead of four gives each title roughly double the width a four-across row
++ had, so the truncation that made every title unreadable ("Honestly, o…to…") stops being
++ the normal case. Row height is still a single shared value (the taller of the two detail
++ texts in that row), computed and used identically for measuring and drawing."""
++ cols = 2
++ n = len(seeking)
++ rows = [seeking[i:i + cols] for i in range(0, n, cols)]
gap = 8.0
-- w = (CONTENT_W - gap * (n - 1)) / n
-- pad = 8.0
++ w = (CONTENT_W - gap * (cols - 1)) / cols
++ pad = 9.0
icon_r = 11.0
inner_w = w - 2 * pad
-- title_w = inner_w - icon_r * 2 - 8
-- title_st = ParagraphStyle("fctitle", fontName=FONT["bold"], fontSize=9.2, leading=1, textColor=INK)
-- detail_st = ParagraphStyle("fcdetail", fontName=FONT["regular"], fontSize=7.4, leading=9.0, textColor=MUTED,
-- bulletFontName=FONT["regular"], bulletFontSize=7.4, bulletColor=BLUE)
++ title_w = inner_w - icon_r * 2 - 10
++ title_st = ParagraphStyle("fctitle", fontName=FONT["bold"], fontSize=9.6, leading=1, textColor=INK)
++ detail_st = ParagraphStyle("fcdetail", fontName=FONT["regular"], fontSize=7.6, leading=9.4, textColor=MUTED,
++ bulletFontName=FONT["regular"], bulletFontSize=7.6, bulletColor=BLUE)
prepared = []
for s in seeking:
title_text = s["label"]
while pdfmetrics.stringWidth(title_text, title_st.fontName, title_st.fontSize) > title_w and len(title_text) > 4:
title_text = title_text[:-2].rstrip(",;: —–-") + "…"
-- detail_text, _ = truncate_to_lines(s.get("detail", ""), detail_st, inner_w, 3)
-- detail_p = para(esc(detail_text), detail_st, bullet="•")
++ detail_p = para(esc(s.get("detail", "")), detail_st, bullet="•")
prepared.append((title_text, detail_p))
title_row_h = max(icon_r * 2, title_st.fontSize * 1.2)
-- detail_h = max(para_height(p, inner_w) for _, p in prepared)
-- h = pad + title_row_h + 8 + detail_h + pad
++ row_gap = 7.0
++ row_heights = []
++ for r in range(len(rows)):
++ chunk = prepared[r * cols:(r + 1) * cols]
++ detail_h = max(para_height(p, inner_w) for _, p in chunk)
++ row_heights.append(pad + title_row_h + 9 + detail_h + pad)
++ h = sum(row_heights) + row_gap * (len(rows) - 1)
if draw:
-- for i, (s, (title_text, detail_p)) in enumerate(zip(seeking, prepared)):
-- x = MARGIN + i * (w + gap)
-- card(c, x, y_top, w, h, radius=10)
-- top = y_top - pad
-- icon_cy = top - title_row_h / 2
-- icon_circle(c, x + pad + icon_r, icon_cy, icon_r, seeking_icon(s["label"]), BLUE, ICON_BG, 9.6)
-- c.setFillColor(INK)
-- c.setFont(title_st.fontName, title_st.fontSize)
-- c.drawString(x + pad + icon_r * 2 + 8, icon_cy - title_st.fontSize * 0.36, title_text)
-- rule_y = top - title_row_h - 5
-- hairline(c, x + pad, rule_y, x + w - pad)
-- draw_para(c, detail_p, x + pad, rule_y - 7, inner_w)
++ row_top = y_top
++ for r, row in enumerate(rows):
++ rh = row_heights[r]
++ for ci in range(len(row)):
++ idx = r * cols + ci
++ s = seeking[idx]
++ title_text, detail_p = prepared[idx]
++ x = MARGIN + ci * (w + gap)
++ card(c, x, row_top, w, rh, radius=12)
++ top = row_top - pad
++ icon_cy = top - title_row_h / 2
++ icon_circle(c, x + pad + icon_r, icon_cy, icon_r, seeking_icon(s["label"]), BLUE, ICON_BG, 10.6)
++ c.setFillColor(INK)
++ c.setFont(title_st.fontName, title_st.fontSize)
++ c.drawString(x + pad + icon_r * 2 + 10, icon_cy - title_st.fontSize * 0.36, title_text)
++ rule_y = top - title_row_h - 6
++ hairline(c, x + pad, rule_y, x + w - pad)
++ draw_para(c, detail_p, x + pad, rule_y - 8, inner_w)
++ row_top -= rh + row_gap
return h
@@@ -758,7 +788,7 @@ def block_role_full(c, role: dict, st: Styles, x: float, w: float, y_top: float,
if draw:
draw_para(c, p, xi, y, wi)
y -= h
-- y -= 1.8
++ y -= 1.4
y -= stack_line(c, role, st, x, w, y, draw)
return y_top - y
@@@ -783,7 +813,7 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
section_header(c, x, y, label, w, "")
y -= SECTION_PILL_H + SECTION_GAP
fn = block_role_compact if compact else block_role_full
-- sep = 4.0 if compact else 5.0
++ sep = 3.0 if compact else 4.0
for i, role in enumerate(roles):
if i:
if draw:
@@@ -795,18 +825,22 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool) -> tuple[float, list[str]]:
-- """2 rows × 3 columns of small cards, each with a category icon. Returns (height, truncated titles)."""
++ """2 rows x 3 columns of small cards, each with a category icon and -- if it has a live URL --
++ a small link-out badge in the top-right corner instead of a separate domain-text row.
++ Shorter two-line summaries: punchy over complete; the full write-up is one tap away at
++ alexfrison.net/portfolio. Returns (height, truncated titles)."""
truncated: list[str] = []
y = y_top
if draw:
-- section_header(c, MARGIN, y, "Selected work", CONTENT_W, "")
++ section_header(c, MARGIN, y, "Selected work", CONTENT_W, ICON["link"])
y -= SECTION_PILL_H + SECTION_GAP
-- cols, gap, pad = 3, 8.0, 7.0
++ cols, gap, pad = 3, 8.0, 8.0
cw = (CONTENT_W - gap * (cols - 1)) / cols
icon_col = 20.0
++ link_badge = 16.0
inner = cw - 2 * pad
-- title_w = inner - icon_col
-- max_lines = 3
++ title_w = inner - icon_col - link_badge
++ max_lines = 2
prepared = []
for pr in projects:
title = pr.get("title", "")
@@@ -817,34 +851,34 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
prepared.append((title, summary, url, pr.get("category")))
title_h = st.proj_title.leading
sum_h = st.proj_summary.leading * max_lines
-- link_h = st.proj_link.leading
-- ch = pad + title_h + 3 + sum_h + 3 + link_h + pad
++ ch = pad + title_h + 6 + sum_h + pad
rows = (len(prepared) + cols - 1) // cols
if draw:
for i, (title, summary, url, cat) in enumerate(prepared):
r, col = divmod(i, cols)
x = MARGIN + col * (cw + gap)
top = y - r * (ch + gap)
-- card(c, x, top, cw, ch, radius=8)
++ card(c, x, top, cw, ch, radius=10)
ty = top - pad
glyph = CATEGORY_ICON.get(cat, DEFAULT_ICON)
icon_circle(c, x + pad + 6.5, ty - 6.5, 7.0, glyph, BLUE, ICON_BG, 7.0)
++ if url:
++ bx, by = x + cw - pad - link_badge, ty - link_badge + 3
++ c.saveState()
++ c.setFillColor(ICON_BG)
++ c.roundRect(bx, by, link_badge, link_badge, 5, stroke=0, fill=1)
++ c.restoreState()
++ iw = pdfmetrics.stringWidth(ICON["link"], "FA", 7.0)
++ icon(c, bx + (link_badge - iw) / 2, by + 4.6, ICON["link"], 7.0, BLUE)
++ c.linkURL(url, (x, top - ch, x + cw, top), relative=0, thickness=0)
tsize = st.proj_title.fontSize
-- while tsize > 7.2 and pdfmetrics.stringWidth(title, FONT["bold"], tsize) > title_w:
++ while tsize > 7.0 and pdfmetrics.stringWidth(title, FONT["bold"], tsize) > title_w:
tsize -= 0.2
c.setFillColor(INK)
c.setFont(FONT["bold"], tsize)
c.drawString(x + pad + icon_col, ty - tsize * 0.78, title)
-- ty -= title_h + 3
++ ty -= title_h + 6
draw_para(c, para(esc(summary), st.proj_summary), x + pad, ty, inner)
-- ty -= sum_h + 3
-- if url:
-- disp = re.sub(r"^https?://", "", url).rstrip("/")
-- iw = icon(c, x + pad, ty - st.proj_link.fontSize * 0.78, ICON["link"], 7.0, BLUE)
-- c.setFillColor(BLUE)
-- c.setFont(FONT["regular"], st.proj_link.fontSize)
-- c.drawString(x + pad + iw + 4, ty - st.proj_link.fontSize * 0.78, disp)
-- c.linkURL(url, (x, top - ch, x + cw, top), relative=0, thickness=0)
y -= rows * ch + (rows - 1) * gap
return y_top - y, truncated
@@@ -1063,7 +1097,7 @@ def build(out_path: Path) -> dict:
page_background(c)
y = TOP
y -= block_header(c, profile, st, y, draw=True)
-- y -= 6
++ y -= 5
y -= block_feature_cards(c, profile.get("seeking", []), st, y, draw=True)
y -= 6
y -= block_metrics(c, pick_metrics(profile["metrics"]), st, y, draw=True)
@@@ -1087,14 +1121,14 @@ def build(out_path: Path) -> dict:
h, drawer = measured(block_experience, experience[FULL_ROLES:], st, y, "Experience (continued)", True)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
-- y -= h + 4
++ y -= h + 2
h, truncated = block_projects(c, projects, st, y, draw=True)
report["truncated"] = truncated
-- y -= h + 4
++ y -= h + 2
h, drawer = measured(block_skills, skills, st, y)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
-- y -= h + 4
++ y -= h + 2
h, drawer = measured(block_education, profile, st, y)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
@@@ -1127,26 +1161,26 @@ def main() -> None:
warnings: list[str] = []
keep: set[Path] = set()
-- # The canonical PDF: the one every "Download PDF" link points at, no color choice involved.
-- print("canonical:")
-- rel = f"Alex_Frison_Resume_{month_tag}_{h}.pdf"
-- out = build_one(rel, warnings)
-- keep.add(out)
-- sync_resume_pdf_path(f"/{rel}")
--
-- # One PDF per preset accent color — pre-built here so the "themed PDF" download on the site
-- # is a plain static link, never a client-side jsPDF render. src/data/theme-presets.json is
-- # the same list ThemePicker.astro reads, so the swatches you can pick from the site and the
-- # colors you can download in are always the same set.
++ # Every preset color, including the default ("blue"), goes through the exact same
++ # configure_palette() call — there is no separate hardcoded default anymore. That used to be
++ # the bug behind "why is the default orange": the very first build ran before any palette
++ # call, so it kept the *original* fixed BLUE/CORAL module constants (a blue header next to an
++ # unrelated hardcoded orange), while every preset below it got the newer monochromatic
++ # derivation. One code path now produces all thirteen files.
presets = json.loads((ROOT / "src/data/theme-presets.json").read_text(encoding="utf-8"))
-- manifest = {"default": f"/{rel}"}
-- print("themed:")
++ default_name = "blue"
++ manifest: dict[str, str] = {}
for p in presets:
configure_palette(p["hex"])
-- t_rel = f"Alex_Frison_Resume_Themed_{p['name']}_{h}.pdf"
-- t_out = build_one(t_rel, warnings)
-- keep.add(t_out)
-- manifest[p["name"]] = f"/{t_rel}"
++ is_default = p["name"] == default_name
++ rel = f"Alex_Frison_Resume_{month_tag}_{h}.pdf" if is_default else f"Alex_Frison_Resume_Themed_{p['name']}_{h}.pdf"
++ print(f"{p['name']}{' (default)' if is_default else ''}:")
++ out = build_one(rel, warnings)
++ keep.add(out)
++ manifest[p["name"]] = f"/{rel}"
++ if is_default:
++ manifest["default"] = f"/{rel}"
++ sync_resume_pdf_path(f"/{rel}")
(ROOT / "src/data/theme-pdf-manifest.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
ddiff --git a/src/components/Contact.astro b/src/components/Contact.astro
iindex 1967a19..40c9a74 100644
---- a/src/components/Contact.astro
++++ b/src/components/Contact.astro
@@@ -20,7 +20,7 @@ import profile from "../data/profile.json";
<li><a href={profile.github} target="_blank" rel="me noopener"><i class="fa-brands fa-github"></i><span>GitHub</span><small>{profile.githubDisplay}</small></a></li>
<li><a href={profile.linkedin} target="_blank" rel="me noopener"><i class="fa-brands fa-linkedin-in"></i><span>LinkedIn</span><small>{profile.linkedinDisplay}</small></a></li>
<li><a href="https://openvibe.live" target="_blank" rel="noopener"><i class="fa-solid fa-tower-broadcast"></i><span>OpenVibe</span><small>openvibe.live</small></a></li>
-- <li><a href={profile.resumePdf}><i class="fa-solid fa-file-pdf"></i><span>Resume</span><small>PDF, two pages</small></a></li>
++ <li><a href={profile.resumePdf} data-resume-link><i class="fa-solid fa-file-pdf"></i><span>Resume</span><small>PDF, two pages</small></a></li>
</ul>
</div>
</div>
ddiff --git a/src/components/Footer.astro b/src/components/Footer.astro
iindex 641ff83..29120da 100644
---- a/src/components/Footer.astro
++++ b/src/components/Footer.astro
@@@ -14,7 +14,7 @@ const year = new Date().getFullYear();
<li><a href={profile.github} target="_blank" rel="me noopener"><i class="fa-brands fa-github"></i> GitHub</a></li>
<li><a href={profile.linkedin} target="_blank" rel="me noopener"><i class="fa-brands fa-linkedin"></i> LinkedIn</a></li>
<li><a href="https://openvibe.live" target="_blank" rel="noopener"><i class="fa-solid fa-tower-broadcast"></i> OpenVibe</a></li>
-- <li><a href={profile.resumePdf}><i class="fa-solid fa-file-pdf"></i> Resume PDF</a></li>
++ <li><a href={profile.resumePdf} data-resume-link><i class="fa-solid fa-file-pdf"></i> Resume PDF</a></li>
</ul>
</div>
<div class="foot__line" aria-hidden="true"></div>
ddiff --git a/src/components/Hero.astro b/src/components/Hero.astro
iindex 0510dcd..880624b 100644
---- a/src/components/Hero.astro
++++ b/src/components/Hero.astro
@@@ -38,7 +38,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
<div class="hero__cta hero__in" style="--d:800ms">
<a href="#network" class="btn btn--primary" data-magnetic><i class="fa-solid fa-satellite-dish"></i> See the network <span class="btn__arrow">→</span></a>
-- <a href={profile.resumePdf} class="btn" data-magnetic><i class="fa-solid fa-file-pdf"></i> Resume PDF</a>
++ <a href={profile.resumePdf} class="btn" data-magnetic data-resume-link><i class="fa-solid fa-file-pdf"></i> Resume PDF</a>
<a href="#contact" class="btn btn--ghost" data-magnetic><i class="fa-solid fa-paper-plane"></i> Get in touch</a>
</div>
ddiff --git a/src/components/Nav.astro b/src/components/Nav.astro
iindex 33fa104..8c93996 100644
---- a/src/components/Nav.astro
++++ b/src/components/Nav.astro
@@@ -39,7 +39,7 @@ const path = Astro.url.pathname;
</nav>
<div class="nav__end">
-- <a class="nav__cta" href={profile.resumePdf} aria-label="Download resume PDF"><i class="fa-solid fa-arrow-down"></i><span>PDF</span></a>
++ <a class="nav__cta" href={profile.resumePdf} data-resume-link aria-label="Download resume PDF"><i class="fa-solid fa-arrow-down"></i><span>PDF</span></a>
<button class="nav__icon" id="nav-color" aria-label="Pick an accent color, and dark or light theme">
<i class="fa-solid fa-palette"></i>
<span class="nav__color-dot" id="nav-color-dot" aria-hidden="true"></span>
@@@ -66,7 +66,7 @@ const path = Astro.url.pathname;
))}
<li class="nav__drawer-sep" style={`--i:${links.length}`} aria-hidden="true"></li>
<li style={`--i:${links.length + 1}`}>
-- <a href={profile.resumePdf} class="nav__drawer-link nav__drawer-link--cta">
++ <a href={profile.resumePdf} data-resume-link class="nav__drawer-link nav__drawer-link--cta">
<span class="nav__drawer-ico nav__drawer-ico--cta"><i class="fa-solid fa-file-pdf" aria-hidden="true"></i></span>
<span class="nav__drawer-text">Download resume PDF</span>
<i class="fa-solid fa-arrow-down nav__drawer-go" aria-hidden="true"></i>
ddiff --git a/src/components/ThemePicker.astro b/src/components/ThemePicker.astro
iindex b8fa92b..b00444d 100644
---- a/src/components/ThemePicker.astro
++++ b/src/components/ThemePicker.astro
@@@ -101,7 +101,10 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
const modal = document.getElementById('tp-modal')!;
const ASKED_KEY = 'ov_theme_asked';
const COLOR_KEY = 'ov_fav_color';
-- const applyAccent = (hex: string) => { (window as any).__ovApplyAccent?.(hex); };
++ const applyAccent = (hex: string) => {
++ (window as any).__ovApplyAccent?.(hex);
++ (window as any).__ovApplyResumeLinks?.(hex);
++ };
const MODE_KEY = 'afnet-theme';
const setMode = (mode: 'dark' | 'light') => {
ddiff --git a/src/data/profile.json b/src/data/profile.json
iindex f36f809..44ce401 100644
---- a/src/data/profile.json
++++ b/src/data/profile.json
@@@ -15,7 +15,7 @@
"githubDisplay": "github.com/OpenVibers",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedinDisplay": "linkedin.com/in/fris",
-- "resumePdf": "/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf",
++ "resumePdf": "/Alex_Frison_Resume_Sep_2026_4a270599.pdf",
"availability": "Open to pretty much anything — engineering, platform, analytics, ops, sales, whatever fits · remote or Pacific Northwest",
"status": {
"now": "Contract software engineer at PowerChat, building its developer platform and payments program.",
@@@ -23,10 +23,10 @@
},
"pitch": "Full-stack engineer with 10+ years shipping production systems and a habit of owning them end to end. I designed and run OpenVibe, an open live-streaming and tools network of seven Node services behind one identity provider. At PowerChat I turned the product into an OAuth2 platform and rebuilt its payments across five processors. Before that I ran SOX-compliance analytics at Amazon, shipped autonomous research agents analysts used daily, and spent three years in sales at Verizon Wireless — district top performer, promoted to Assistant Manager, coaching 4-8 reps. I've also run my own company, GameServerStats, for 10+ years. I'm genuinely open to where that combination fits: engineering, platform, analytics, ops or something in between.",
"seeking": [
-- { "label": "Honestly, open to anything", "detail": "Title and level are negotiable — a good team and a real problem matter more" },
-- { "label": "Software engineering", "detail": "Full-stack, platform, streaming or realtime systems" },
-- { "label": "Data & analytics", "detail": "SQL, reporting and compliance tooling that people act on" },
-- { "label": "Ops, support, sales — whatever fits", "detail": "Ten years of sales and running a company count for something too" }
++ { "label": "Honestly, open to anything", "detail": "Title and level are negotiable — the team and the problem matter more" },
++ { "label": "Software engineering", "detail": "Full-stack, platform, streaming or realtime systems work" },
++ { "label": "Data & analytics", "detail": "SQL, reporting and compliance tooling people act on" },
++ { "label": "Ops, support, sales — whatever fits", "detail": "Ten years of sales and running my own company" }
],
"foundedOrg": "GameServerStats, LLC",
"lastRole": "PowerChat — Software Engineer (Contract)",
ddiff --git a/src/data/theme-pdf-manifest.json b/src/data/theme-pdf-manifest.json
iindex 8c21a64..44c1f64 100644
---- a/src/data/theme-pdf-manifest.json
++++ b/src/data/theme-pdf-manifest.json
@@@ -1,15 +1,15 @@
{
-- "amber": "/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf",
-- "blue": "/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf",
-- "cyan": "/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf",
-- "default": "/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf",
-- "green": "/Alex_Frison_Resume_Themed_green_c7e32af5.pdf",
-- "indigo": "/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf",
-- "lime": "/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf",
-- "orange": "/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf",
-- "pink": "/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf",
-- "red": "/Alex_Frison_Resume_Themed_red_c7e32af5.pdf",
-- "teal": "/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf",
-- "violet": "/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf",
-- "yellow": "/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf"
++ "amber": "/Alex_Frison_Resume_Themed_amber_4a270599.pdf",
++ "blue": "/Alex_Frison_Resume_Sep_2026_4a270599.pdf",
++ "cyan": "/Alex_Frison_Resume_Themed_cyan_4a270599.pdf",
++ "default": "/Alex_Frison_Resume_Sep_2026_4a270599.pdf",
++ "green": "/Alex_Frison_Resume_Themed_green_4a270599.pdf",
++ "indigo": "/Alex_Frison_Resume_Themed_indigo_4a270599.pdf",
++ "lime": "/Alex_Frison_Resume_Themed_lime_4a270599.pdf",
++ "orange": "/Alex_Frison_Resume_Themed_orange_4a270599.pdf",
++ "pink": "/Alex_Frison_Resume_Themed_pink_4a270599.pdf",
++ "red": "/Alex_Frison_Resume_Themed_red_4a270599.pdf",
++ "teal": "/Alex_Frison_Resume_Themed_teal_4a270599.pdf",
++ "violet": "/Alex_Frison_Resume_Themed_violet_4a270599.pdf",
++ "yellow": "/Alex_Frison_Resume_Themed_yellow_4a270599.pdf"
}
ddiff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
iindex c19a34e..4a30041 100644
---- a/src/layouts/Base.astro
++++ b/src/layouts/Base.astro
@@@ -4,6 +4,8 @@ import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
import ThemePicker from "../components/ThemePicker.astro";
import profile from "../data/profile.json";
++import themePresets from "../data/theme-presets.json";
++import themePdfManifest from "../data/theme-pdf-manifest.json";
interface Props {
title?: string;
@@@ -76,7 +78,7 @@ const imageUrl = new URL(image, site).toString();
<link rel="stylesheet" href="/assets/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/vendor/lenis/lenis.css" />
-- <script is:inline>
++ <script is:inline define:vars={{ themePresets, themePdfManifest }}>
// Theme before paint (no flash) and JS marker for reveal fallbacks
(function () {
var d = document.documentElement;
@@@ -142,6 +144,31 @@ const imageUrl = new URL(image, site).toString();
var fav = localStorage.getItem('ov_fav_color');
if (fav) applyAccent(fav);
} catch (e) {}
++
++ // ---- Resume PDF matching -----------------------------------------------------------
++ // Every "Download PDF" link (nav, footer, contact, hero, resume page) points at the
++ // build-time default (blue) file by default. Once a visitor has picked a favorite color,
++ // point those same links at the pre-generated PDF for the preset nearest that color's hue,
++ // so the file they download actually matches the site they're looking at. Defined here
++ // (data is only available server-side, at layout render time) but invoked later, once
++ // [data-resume-link] elements exist in the DOM — see the deferred script below.
++ var THEME_PRESETS = themePresets;
++ var THEME_PDF_MANIFEST = themePdfManifest;
++ function nearestPresetName(hex) {
++ var h = hexToHsl(hex)[0], best = null, bestDist = Infinity;
++ for (var i = 0; i < THEME_PRESETS.length; i++) {
++ var dh = Math.abs(hexToHsl(THEME_PRESETS[i].hex)[0] - h);
++ dh = Math.min(dh, 360 - dh);
++ if (dh < bestDist) { bestDist = dh; best = THEME_PRESETS[i].name; }
++ }
++ return best;
++ }
++ window.__ovApplyResumeLinks = function (hex) {
++ if (!/^#[0-9a-f]{6}$/i.test(hex)) return;
++ var path = THEME_PDF_MANIFEST[nearestPresetName(hex)] || THEME_PDF_MANIFEST.default;
++ if (!path) return;
++ document.querySelectorAll('[data-resume-link]').forEach(function (a) { a.setAttribute('href', path); });
++ };
})();
</script>
</head>
@@@ -155,6 +182,12 @@ const imageUrl = new URL(image, site).toString();
<ThemePicker />
<script>
++ // ---- Resume PDF links: match whatever color was picked before paint ---
++ try {
++ const fav = localStorage.getItem('ov_fav_color');
++ if (fav) (window as any).__ovApplyResumeLinks?.(fav);
++ } catch { /* */ }
++
// ---- Smooth scroll (Lenis) --------------------------------------------
const lenisScript = document.createElement('script');
lenisScript.src = '/assets/vendor/lenis/lenis.min.js';