Resume PDF: overhaul header/feature-cards/projects, add role-chip icons, fix orange-number bug; wire Download PDF to the visitor's chosen theme
Changes
nnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf and /dev/null differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf and /dev/null differ@@@ -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 1COMPACT_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.0CONTENT_W = PAGE_W - 2 * MARGINTOP = PAGE_H - MARGINBOTTOM = 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 = Noneclass 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 labelchips_w = w - pad * 2 - panel_w - 14chip_rows: list[list[tuple[str, float]]] = [[]]cx = 0.0for 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_wif cx > 0 and cx + cw > chips_w:chip_rows.append([])cx = 0.0chip_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) * 4banner_h = pad + name_size * 0.78 + 5 + tag_size * 0.78 + 12 + chips_total_h + padbanner_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) -> floac.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_gaprow_top -= chip_h + 5@@@ -595,51 +615,61 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floadef 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.0icon_r = 11.0inner_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_gapreturn 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.4y -= 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_GAPfn = block_role_compact if compact else block_role_full-- sep = 4.0 if compact else 5.0++ sep = 3.0 if compact else 4.0for 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_topif 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.0cw = (CONTENT_W - gap * (cols - 1)) / colsicon_col = 20.0++ link_badge = 16.0inner = cw - 2 * pad-- title_w = inner - icon_col-- max_lines = 3++ title_w = inner - icon_col - link_badge++ max_lines = 2prepared = []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: boolprepared.append((title, summary, url, pr.get("category")))title_h = st.proj_title.leadingsum_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 + padrows = (len(prepared) + cols - 1) // colsif 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 - padglyph = 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.2c.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 + 6draw_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) * gapreturn y_top - y, truncated@@@ -1063,7 +1097,7 @@ def build(out_path: Path) -> dict:page_background(c)y = TOPy -= block_header(c, profile, st, y, draw=True)-- y -= 6++ y -= 5y -= block_feature_cards(c, profile.get("seeking", []), st, y, draw=True)y -= 6y -= 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 + 2h, truncated = block_projects(c, projects, st, y, draw=True)report["truncated"] = truncated-- y -= h + 4++ y -= h + 2h, drawer = measured(block_skills, skills, st, y)card(c, MARGIN, y, CONTENT_W, h)drawer()-- y -= h + 4++ y -= h + 2h, 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")@@@ -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>@@@ -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>@@@ -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>@@@ -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>@@@ -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') => {@@@ -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)",@@@ -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"}@@@ -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';
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Sep_2026_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_amber_c7e32af5.pdf and /dev/null differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_blue_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_cyan_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_green_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_indigo_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_lime_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_orange_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_pink_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_red_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_teal_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_violet_c7e32af5.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_yellow_c7e32af5.pdf and /dev/null differ
@@ -53,7 +53,7 @@ SELECTED_PROJECTS = [
@@ -53,7 +53,7 @@ SELECTED_PROJECTS = [
# Metrics strip: pick by a keyword found in the label, in this order.
# 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.
# Falls back to the first four metrics if fewer than four match.
METRIC_KEYWORDS = ["services", "routes", "payment processors", "irdrs", "years"]
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
COMPACT_BULLETS = 2 # bullets per role on page 2
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
@@ -79,11 +79,11 @@ ICON_BG = HexColor("#dbe3ff")
@@ -79,11 +79,11 @@ ICON_BG = HexColor("#dbe3ff")
# Page geometry
# Page geometry
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
PAGE_W, PAGE_H = letter
PAGE_W, PAGE_H = letter
-MARGIN = 28.0
+MARGIN = 23.0
CONTENT_W = PAGE_W - 2 * MARGIN
CONTENT_W = PAGE_W - 2 * MARGIN
TOP = PAGE_H - MARGIN
TOP = PAGE_H - MARGIN
BOTTOM = MARGIN
BOTTOM = MARGIN
-CARD_PAD = 6.5
+CARD_PAD = 6.0
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
# Fonts
# Fonts
@@ -187,6 +187,24 @@ def metric_icon(css_class: str) -> str:
@@ -187,6 +187,24 @@ def metric_icon(css_class: str) -> str:
return DEFAULT_ICON
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.
# profile.json `seeking[].label` → icon for the feature-card row, matched by keyword.
SEEKING_ICON_KEYWORDS = [
SEEKING_ICON_KEYWORDS = [
("anything", ""), ("engineer", ""), ("analytic", ""), ("data", ""),
("anything", ""), ("engineer", ""), ("analytic", ""), ("data", ""),
@@ -302,11 +320,11 @@ def style(name: str, size: float, leading: float, color, font: str | None = None
@@ -302,11 +320,11 @@ def style(name: str, size: float, leading: float, color, font: str | None = None
class Styles:
class Styles:
def __init__(self) -> None:
def __init__(self) -> None:
self.body = style("body", 8.7, 10.8, BODY)
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.stack = style("stack", 6.9, 8.3, MUTED)
self.metric_label = style("mlabel", 7.0, 8.5, MUTED)
self.metric_label = style("mlabel", 7.0, 8.5, MUTED)
self.proj_title = style("ptitle", 8.5, 10.2, INK, FONT["bold"])
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
@@ -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.
# feature cards above) there is no separate "measure" vs "draw" width that could disagree.
roles = (profile.get("roles") or [])[:6]
roles = (profile.get("roles") or [])[:6]
chip_font, chip_size, chip_pad_x, chip_gap = FONT["bold"], 7.6, 20.0, 7.0
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
chips_w = w - pad * 2 - panel_w - 14
chip_rows: list[list[tuple[str, float]]] = [[]]
chip_rows: list[list[tuple[str, float]]] = [[]]
cx = 0.0
cx = 0.0
for r in roles:
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:
if cx > 0 and cx + cw > chips_w:
chip_rows.append([])
chip_rows.append([])
cx = 0.0
cx = 0.0
chip_rows[-1].append((r, cw))
chip_rows[-1].append((r, cw))
cx += cw + chip_gap
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 = 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)
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
@@ -567,9 +586,10 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
c.setFillAlpha(0.4)
c.setFillAlpha(0.4)
c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)
c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)
c.restoreState()
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.setFillColor(white)
c.setFont(chip_font, chip_size)
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
cx2 += cw + chip_gap
row_top -= chip_h + 5
row_top -= chip_h + 5
@@ -595,51 +615,61 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -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:
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
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
icon_r = 11.0
inner_w = w - 2 * pad
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 = []
prepared = []
for s in seeking:
for s in seeking:
title_text = s["label"]
title_text = s["label"]
while pdfmetrics.stringWidth(title_text, title_st.fontName, title_st.fontSize) > title_w and len(title_text) > 4:
while pdfmetrics.stringWidth(title_text, title_st.fontName, title_st.fontSize) > title_w and len(title_text) > 4:
title_text = title_text[:-2].rstrip(",;: —–-") + "…"
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))
prepared.append((title_text, detail_p))
title_row_h = max(icon_r * 2, title_st.fontSize * 1.2)
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:
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
return h
@@ -758,7 +788,7 @@ def block_role_full(c, role: dict, st: Styles, x: float, w: float, y_top: float,
@@ -758,7 +788,7 @@ def block_role_full(c, role: dict, st: Styles, x: float, w: float, y_top: float,
if draw:
if draw:
draw_para(c, p, xi, y, wi)
draw_para(c, p, xi, y, wi)
y -= h
y -= h
- y -= 1.8
+ y -= 1.4
y -= stack_line(c, role, st, x, w, y, draw)
y -= stack_line(c, role, st, x, w, y, draw)
return y_top - y
return y_top - y
@@ -783,7 +813,7 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
@@ -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, "")
section_header(c, x, y, label, w, "")
y -= SECTION_PILL_H + SECTION_GAP
y -= SECTION_PILL_H + SECTION_GAP
fn = block_role_compact if compact else block_role_full
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):
for i, role in enumerate(roles):
if i:
if i:
if draw:
if draw:
@@ -795,18 +825,22 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
@@ -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]]:
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] = []
truncated: list[str] = []
y = y_top
y = y_top
if draw:
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
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
cw = (CONTENT_W - gap * (cols - 1)) / cols
icon_col = 20.0
icon_col = 20.0
+ link_badge = 16.0
inner = cw - 2 * pad
inner = cw - 2 * pad
- title_w = inner - icon_col
- max_lines = 3
+ title_w = inner - icon_col - link_badge
+ max_lines = 2
prepared = []
prepared = []
for pr in projects:
for pr in projects:
title = pr.get("title", "")
title = pr.get("title", "")
@@ -817,34 +851,34 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
@@ -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")))
prepared.append((title, summary, url, pr.get("category")))
title_h = st.proj_title.leading
title_h = st.proj_title.leading
sum_h = st.proj_summary.leading * max_lines
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
rows = (len(prepared) + cols - 1) // cols
if draw:
if draw:
for i, (title, summary, url, cat) in enumerate(prepared):
for i, (title, summary, url, cat) in enumerate(prepared):
r, col = divmod(i, cols)
r, col = divmod(i, cols)
x = MARGIN + col * (cw + gap)
x = MARGIN + col * (cw + gap)
top = y - r * (ch + 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
ty = top - pad
glyph = CATEGORY_ICON.get(cat, DEFAULT_ICON)
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)
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
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
tsize -= 0.2
c.setFillColor(INK)
c.setFillColor(INK)
c.setFont(FONT["bold"], tsize)
c.setFont(FONT["bold"], tsize)
c.drawString(x + pad + icon_col, ty - tsize * 0.78, title)
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)
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
y -= rows * ch + (rows - 1) * gap
return y_top - y, truncated
return y_top - y, truncated
@@ -1063,7 +1097,7 @@ def build(out_path: Path) -> dict:
@@ -1063,7 +1097,7 @@ def build(out_path: Path) -> dict:
page_background(c)
page_background(c)
y = TOP
y = TOP
y -= block_header(c, profile, st, y, draw=True)
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 -= block_feature_cards(c, profile.get("seeking", []), st, y, draw=True)
y -= 6
y -= 6
y -= block_metrics(c, pick_metrics(profile["metrics"]), st, y, draw=True)
y -= block_metrics(c, pick_metrics(profile["metrics"]), st, y, draw=True)
@@ -1087,14 +1121,14 @@ def build(out_path: Path) -> dict:
@@ -1087,14 +1121,14 @@ def build(out_path: Path) -> dict:
h, drawer = measured(block_experience, experience[FULL_ROLES:], st, y, "Experience (continued)", True)
h, drawer = measured(block_experience, experience[FULL_ROLES:], st, y, "Experience (continued)", True)
card(c, MARGIN, y, CONTENT_W, h)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
drawer()
- y -= h + 4
+ y -= h + 2
h, truncated = block_projects(c, projects, st, y, draw=True)
h, truncated = block_projects(c, projects, st, y, draw=True)
report["truncated"] = truncated
report["truncated"] = truncated
- y -= h + 4
+ y -= h + 2
h, drawer = measured(block_skills, skills, st, y)
h, drawer = measured(block_skills, skills, st, y)
card(c, MARGIN, y, CONTENT_W, h)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
drawer()
- y -= h + 4
+ y -= h + 2
h, drawer = measured(block_education, profile, st, y)
h, drawer = measured(block_education, profile, st, y)
card(c, MARGIN, y, CONTENT_W, h)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
drawer()
@@ -1127,26 +1161,26 @@ def main() -> None:
@@ -1127,26 +1161,26 @@ def main() -> None:
warnings: list[str] = []
warnings: list[str] = []
keep: set[Path] = set()
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"))
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:
for p in presets:
configure_palette(p["hex"])
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(
(ROOT / "src/data/theme-pdf-manifest.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
@@ -20,7 +20,7 @@ import profile from "../data/profile.json";
@@ -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.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={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="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>
</ul>
</div>
</div>
</div>
</div>
@@ -14,7 +14,7 @@ const year = new Date().getFullYear();
@@ -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.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={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="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>
</ul>
</div>
</div>
<div class="foot__line" aria-hidden="true"></div>
<div class="foot__line" aria-hidden="true"></div>
@@ -38,7 +38,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -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">
<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="#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>
<a href="#contact" class="btn btn--ghost" data-magnetic><i class="fa-solid fa-paper-plane"></i> Get in touch</a>
</div>
</div>
@@ -39,7 +39,7 @@ const path = Astro.url.pathname;
@@ -39,7 +39,7 @@ const path = Astro.url.pathname;
</nav>
</nav>
<div class="nav__end">
<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">
<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>
<i class="fa-solid fa-palette"></i>
<span class="nav__color-dot" id="nav-color-dot" aria-hidden="true"></span>
<span class="nav__color-dot" id="nav-color-dot" aria-hidden="true"></span>
@@ -66,7 +66,7 @@ const path = Astro.url.pathname;
@@ -66,7 +66,7 @@ const path = Astro.url.pathname;
))}
))}
<li class="nav__drawer-sep" style={`--i:${links.length}`} aria-hidden="true"></li>
<li class="nav__drawer-sep" style={`--i:${links.length}`} aria-hidden="true"></li>
<li style={`--i:${links.length + 1}`}>
<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-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>
<span class="nav__drawer-text">Download resume PDF</span>
<i class="fa-solid fa-arrow-down nav__drawer-go" aria-hidden="true"></i>
<i class="fa-solid fa-arrow-down nav__drawer-go" aria-hidden="true"></i>
@@ -101,7 +101,10 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
@@ -101,7 +101,10 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
const modal = document.getElementById('tp-modal')!;
const modal = document.getElementById('tp-modal')!;
const ASKED_KEY = 'ov_theme_asked';
const ASKED_KEY = 'ov_theme_asked';
const COLOR_KEY = 'ov_fav_color';
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 MODE_KEY = 'afnet-theme';
const setMode = (mode: 'dark' | 'light') => {
const setMode = (mode: 'dark' | 'light') => {
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"githubDisplay": "github.com/OpenVibers",
"githubDisplay": "github.com/OpenVibers",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedinDisplay": "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",
"availability": "Open to pretty much anything — engineering, platform, analytics, ops, sales, whatever fits · remote or Pacific Northwest",
"status": {
"status": {
"now": "Contract software engineer at PowerChat, building its developer platform and payments program.",
"now": "Contract software engineer at PowerChat, building its developer platform and payments program.",
@@ -23,10 +23,10 @@
@@ -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.",
"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": [
"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",
"foundedOrg": "GameServerStats, LLC",
"lastRole": "PowerChat — Software Engineer (Contract)",
"lastRole": "PowerChat — Software Engineer (Contract)",
@@ -1,15 +1,15 @@
@@ -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"
}
}
@@ -4,6 +4,8 @@ import Nav from "../components/Nav.astro";
@@ -4,6 +4,8 @@ import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
import Footer from "../components/Footer.astro";
import ThemePicker from "../components/ThemePicker.astro";
import ThemePicker from "../components/ThemePicker.astro";
import profile from "../data/profile.json";
import profile from "../data/profile.json";
+import themePresets from "../data/theme-presets.json";
+import themePdfManifest from "../data/theme-pdf-manifest.json";
interface Props {
interface Props {
title?: string;
title?: string;
@@ -76,7 +78,7 @@ const imageUrl = new URL(image, site).toString();
@@ -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/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/vendor/lenis/lenis.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
// Theme before paint (no flash) and JS marker for reveal fallbacks
(function () {
(function () {
var d = document.documentElement;
var d = document.documentElement;
@@ -142,6 +144,31 @@ const imageUrl = new URL(image, site).toString();
@@ -142,6 +144,31 @@ const imageUrl = new URL(image, site).toString();
var fav = localStorage.getItem('ov_fav_color');
var fav = localStorage.getItem('ov_fav_color');
if (fav) applyAccent(fav);
if (fav) applyAccent(fav);
} catch (e) {}
} 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>
</script>
</head>
</head>
@@ -155,6 +182,12 @@ const imageUrl = new URL(image, site).toString();
@@ -155,6 +182,12 @@ const imageUrl = new URL(image, site).toString();
<ThemePicker />
<ThemePicker />
<script>
<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) --------------------------------------------
// ---- Smooth scroll (Lenis) --------------------------------------------
const lenisScript = document.createElement('script');
const lenisScript = document.createElement('script');
lenisScript.src = '/assets/vendor/lenis/lenis.min.js';
lenisScript.src = '/assets/vendor/lenis/lenis.min.js';