PDF page-1 rework + nav fixes: one-line name/tagline, full role row, distinct contact bar, tighter cards, 2-line metrics, inline company names; glass PDF button; drawer bleed line
Changes
ddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_76413598.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_76413598.pdf differ@@@ -321,19 +321,19 @@ 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.1, 9.8, MUTED, FONT["italic"])-- self.bullet = style("bullet", 8.2, 9.9, BODY, leftIndent=9, bulletIndent=0,++ self.body = style("body", 9.1, 11.4, BODY)++ self.summary = style("summary", 8.4, 10.3, MUTED, FONT["italic"])++ self.bullet = style("bullet", 8.8, 10.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,++ self.bullet_compact = style("bulletc", 8.6, 10.7, 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"])-- self.proj_summary = style("psum", 7.5, 9.0, BODY)++ self.proj_summary = style("psum", 7.9, 9.6, BODY)self.proj_link = style("plink", 7.3, 8.8, BLUE)-- self.skill_items = style("skills", 8.2, 9.7, BODY)-- self.edu = style("edu", 8.3, 10.1, BODY)++ self.skill_items = style("skills", 8.5, 10.3, BODY)++ self.edu = style("edu", 8.7, 10.8, BODY)self.chip = style("chip", 7.6, 9.2, BLUE_DARK, FONT["bold"])self.hero_line = style("heroline", 8.6, 11.2, white)@@@ -536,11 +536,15 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floacx += cw + gapreturn rows-- def draw_rows(rows, row_top, chip_h, font, size, icon_w, pad_x, gap, bg, bg_alpha, fg, glyph_of):++ def draw_rows(rows, row_top, chip_h, font, size, icon_w, pad_x, gap, bg, bg_alpha, fg, glyph_of, icon_fg=None, shadow=False):for row in rows:cx2 = x + padfor it, cw in row:c.saveState()++ if shadow:++ c.setFillColor(BLUE_DARK)++ c.setFillAlpha(0.22)++ c.roundRect(cx2, row_top - chip_h - 1.6, cw, chip_h, chip_h / 2, stroke=0, fill=1)c.setFillColor(bg)c.setFillAlpha(bg_alpha)c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)@@@ -549,7 +553,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floaglyph = glyph_of(it)tx = cx2 + pad_x / 2if glyph:-- icon(c, tx, ty2, glyph, size, fg)++ icon(c, tx, ty2, glyph, size, icon_fg or fg)tx += icon_wc.setFillColor(fg)c.setFont(font, size)@@@ -560,7 +564,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floarow_top -= chip_h + 5return row_top-- roles = [{"text": r, "url": None} for r in (profile.get("roles") or [])[:6]]++ roles = [{"text": r, "url": None} for r in (profile.get("roles") or [])]role_font, role_size, role_pad_x, role_icon_w, role_gap = FONT["bold"], 7.6, 20.0, 13.0, 7.0role_rows = wrap(roles, role_font, role_size, role_icon_w, role_pad_x, role_gap)role_h = role_size + 6.5@@@ -574,13 +578,18 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa{"text": profile.get("locationShort") or profile.get("location"), "icon": "location", "url": None},]contact_items = [it for it in contact_items if it["text"]]-- contact_font, contact_size, contact_pad_x, contact_icon_w, contact_gap = FONT["regular"], 8.6, 18.0, 15.0, 8.0++ contact_font, contact_size, contact_pad_x, contact_icon_w, contact_gap = FONT["bold"], 8.4, 18.0, 15.0, 8.0contact_rows = wrap(contact_items, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap)-- contact_h = contact_size + 7++ contact_h = contact_size + 8contact_total_h = len(contact_rows) * contact_h + max(0, len(contact_rows) - 1) * 6-- group_gap = 6.0-- banner_h = pad + name_size * 0.78 + 5 + tag_size * 0.78 + 13 + role_total_h + group_gap + contact_total_h + pad++ # The contact row sits in its own darker band along the bottom of the banner, as white++ # pills with dark text — the inverse of the translucent role chips above it, so the two rows++ # read as "what I am" and "how to reach me" instead of one undifferentiated pile of pills.++ band_pad = 8.0++ band_h = contact_total_h + band_pad * 2++ group_gap = 9.0++ banner_h = pad + name_size * 0.78 + 10 + role_total_h + group_gap + band_hif draw:c.saveState()@@@ -596,22 +605,33 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floaty = y_top - padif draw:++ # Name and tagline share one line; both get a soft offset shadow (reportlab has no blur,++ # so a translucent dark copy one point down-right is the closest thing).++ base = ty - name_size * 0.78++ name_w = pdfmetrics.stringWidth(profile["name"], FONT["bold"], name_size)++ c.saveState()++ c.setFillColor(BLUE_DARK); c.setFillAlpha(0.45)++ c.setFont(FONT["bold"], name_size); c.drawString(x + pad + 1.2, base - 1.4, profile["name"])++ c.setFont(FONT["regular"], tag_size); c.drawString(x + pad + name_w + 14 + 0.8, base + 0.6 - 1.0, profile["tagline"])++ c.restoreState()c.setFillColor(white)c.setFont(FONT["bold"], name_size)-- c.drawString(x + pad, ty - name_size * 0.78, profile["name"])-- ty -= name_size * 0.78 + 5-- if draw:-- c.setFillColor(HexColor("#dbe4ff"))++ c.drawString(x + pad, base, profile["name"])++ c.setFillColor(HexColor("#e4eaff"))c.setFont(FONT["regular"], tag_size)-- c.drawString(x + pad, ty - tag_size * 0.78, profile["tagline"])-- ty -= tag_size * 0.78 + 10++ c.drawString(x + pad + name_w + 14, base + 0.6, profile["tagline"])++ ty -= name_size * 0.78 + 10if draw:ty = draw_rows(role_rows, ty, role_h, role_font, role_size, role_icon_w, role_pad_x, role_gap,BLUE_SOFT, 0.4, white, lambda it: role_icon(it["text"]))-- ty -= group_gap - 5-- draw_rows(contact_rows, ty, contact_h, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap,-- white, 0.16, white, lambda it: ICON[it["icon"]])++ c.saveState()++ p = c.beginPath(); p.roundRect(x, y_top - banner_h, w, banner_h, 12); c.clipPath(p, stroke=0, fill=0)++ c.setFillColor(BLUE_DARK); c.setFillAlpha(0.22)++ c.rect(x, y_top - banner_h, w, band_h, stroke=0, fill=1)++ c.restoreState()++ draw_rows(contact_rows, y_top - banner_h + band_h - band_pad, contact_h, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap,++ white, 0.94, BLUE_DARK, lambda it: ICON[it["icon"]], icon_fg=BLUE, shadow=True)return banner_h@@@ -630,13 +650,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:rows = [seeking[i:i + cols] for i in range(0, n, cols)]gap = 8.0w = (CONTENT_W - gap * (cols - 1)) / cols-- pad = 8.5-- icon_r = 11.0++ pad = 6.5++ icon_r = 8.5inner_w = w - 2 * pad-- 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)++ title_w = inner_w - icon_r * 2 - 8++ title_st = ParagraphStyle("fctitle", fontName=FONT["bold"], fontSize=9.4, leading=1, textColor=INK)++ detail_st = ParagraphStyle("fcdetail", fontName=FONT["regular"], fontSize=7.8, leading=9.6, textColor=BODY,++ bulletFontName=FONT["regular"], bulletFontSize=7.8, bulletColor=BLUE)prepared = []for s in seeking:title_text = s["label"]@@@ -645,12 +665,12 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw: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)-- row_gap = 7.0++ row_gap = 6.0row_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)++ row_heights.append(pad + title_row_h + 7 + detail_h + pad)h = sum(row_heights) + row_gap * (len(rows) - 1)if draw:row_top = y_top@@@ -664,13 +684,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:card(c, x, row_top, w, rh, radius=12)top = row_top - padicon_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)++ icon_circle(c, x + pad + icon_r, icon_cy, icon_r, seeking_icon(s["label"]), BLUE, ICON_BG, 8.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++ c.drawString(x + pad + icon_r * 2 + 8, icon_cy - title_st.fontSize * 0.36, title_text)++ rule_y = top - title_row_h - 4hairline(c, x + pad, rule_y, x + w - pad)-- draw_para(c, detail_p, x + pad, rule_y - 8, inner_w)++ draw_para(c, detail_p, x + pad, rule_y - 4, inner_w)row_top -= rh + row_gapreturn h@@@ -681,14 +701,14 @@ def block_metrics(c, metrics: list[dict], st: Styles, y_top: float, draw: bool)separate boxed cards."""n = len(metrics)col_w = CONTENT_W / n-- icon_r = 11.0-- num_size = 15.0-- pad = 10.0-- label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=7.1, leading=8.5, textColor=MUTED)++ icon_r = 9.5++ num_size = 12.5++ pad = 9.0++ label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=7.5, leading=8.9, textColor=BODY)text_w = col_w - pad - icon_r * 2 - 8 - 6label_paras = [para(esc(m.get("short") or m["label"]), label_st) for m in metrics]label_h = max(para_height(p, text_w) for p in label_paras)-- h = max(icon_r * 2 + 12, num_size + label_h + 6) + 12++ h = max(icon_r * 2 + 10, num_size + label_h + 4) + 10if draw:card(c, MARGIN, y_top, CONTENT_W, h, radius=12)cy = y_top - h / 2@@@ -734,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dradates = f"{role.get('start', '')} – {role.get('end', '')}".strip(" –")dw = pdfmetrics.stringWidth(dates, FONT["regular"], date_size)base = y - title_size * 0.78++ title_w = pdfmetrics.stringWidth(role["role"], FONT["bold"], title_size)++ company_w = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)++ loc = f" · {role['location']}" if role.get("location") else ""++ loc_w = pdfmetrics.stringWidth(loc, FONT["regular"], meta_size)++ # Company (and location) join the title line when there's room; otherwise they take a++ # second line as before. Saves a line per role on most of the sheet.++ inline = x + icon_col + title_w + 10 + company_w + loc_w < x + w - dw - 14if draw:glyph = KIND_ICON.get(role.get("kind"), DEFAULT_ICON)icon_circle(c, x + 6, base + title_size * 0.28, 7.4, glyph, BLUE, ICON_BG, 7.2)@@@ -746,13 +773,20 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, draif role.get("current") or str(role.get("end", "")).lower() == "present":c.setFillColor(MINT)c.circle(x + w - dw - 7.5, base + 3.4, 2.1, stroke=0, fill=1)-- y = base - 2.6-- base = y - meta_size * 0.78++ if inline:++ cx0 = x + icon_col + title_w + 10++ meta_base = base + 0.4++ else:++ y = base - 2.6++ meta_base = y - meta_size * 0.78++ cx0 = x + icon_col++ base = meta_baseif draw:c.setFillColor(BLUE)c.setFont(FONT["bold"], meta_size)-- c.drawString(x + icon_col, base, role["company"])-- cw = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)++ c.drawString(cx0, base, role["company"])++ cw = company_w++ x_save, x = x, cx0 - icon_col # keep the link/underline math below relative to cx0if role.get("link"):# A thin underline is the only cue a static PDF has for "this text is a live# link" — plain bold-blue looks identical to every other (non-linked) company name.@@@ -766,7 +800,8 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, drac.setFillColor(MUTED)c.setFont(FONT["regular"], meta_size)c.drawString(x + icon_col + cw, base, f" · {role['location']}")-- y = base - 2.0++ x = x_save++ y = base - (2.0 if not inline else 3.0)return y_top - y@@@ -166,13 +166,21 @@ const path = Astro.url.pathname;}.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }++ /* Glass pill tinted by the accent, with the page's own text color on top — readable on any++ accent (a solid bright pink with white text was not) and it re-tints itself as the theme changes. */.nav__cta {-- display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px;-- background: var(--accent); color: var(--on-accent); font: 600 .82rem var(--font-body); text-decoration: none;-- box-shadow: 0 8px 22px -10px rgba(var(--accent-rgb), .9);-- transition: transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s;++ position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 9px 15px 9px 12px; border-radius: 999px;++ background: linear-gradient(180deg, rgba(var(--accent-rgb), .30), rgba(var(--accent-rgb), .16));++ border: 1px solid rgba(var(--accent-rgb), .6); color: var(--fg);++ font: 700 .8rem var(--font-body); letter-spacing: .02em; text-decoration: none;++ backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);++ box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.15), 0 0 0 3px rgba(var(--accent-rgb), .10), 0 10px 24px -12px rgba(var(--accent-rgb), .8);++ transition: transform .3s var(--ease), box-shadow .3s var(--ease), background .3s var(--ease), border-color .3s var(--ease);}-- .nav__cta:hover { text-decoration: none; transform: translateY(-1px); filter: brightness(1.08); }++ .nav__cta i { color: var(--accent); filter: brightness(1.25) saturate(1.1); font-size: .85rem; transition: transform .3s var(--ease); }++ .nav__cta:hover { text-decoration: none; transform: translateY(-1px); border-color: rgba(var(--accent-rgb), .9); background: linear-gradient(180deg, rgba(var(--accent-rgb), .42), rgba(var(--accent-rgb), .22)); box-shadow: inset 0 1px 0 rgba(255,255,255,.28), 0 0 0 4px rgba(var(--accent-rgb), .14), 0 12px 28px -12px rgba(var(--accent-rgb), .95); }++ .nav__cta:hover i { transform: translateY(2px); }++ :root[data-theme="light"] .nav__cta { background: linear-gradient(180deg, rgba(var(--accent-rgb), .22), rgba(var(--accent-rgb), .12)); }.nav__icon {position: relative; width: 40px; height: 40px; border-radius: 50%; display: inline-grid; place-items: center;background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer;@@@ -200,11 +208,14 @@ const path = Astro.url.pathname;max-width: var(--container); margin: 8px auto 0;border-radius: var(--radius-lg); overflow: hidden;background: color-mix(in oklab, var(--bg) 84%, transparent);-- border: 1px solid transparent;++ border: 1px solid transparent; background-clip: padding-box;backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);-- max-height: 0; transition: max-height .45s var(--ease), border-color .3s;++ /* Fully hidden when closed: at max-height 0 the 1px transparent border still let the dark++ background show through as a 2px line under the nav. */++ max-height: 0; visibility: hidden;++ transition: max-height .45s var(--ease), border-color .3s, visibility 0s .45s;}-- .nav__drawer.is-open { max-height: 700px; border-color: var(--border); }++ .nav__drawer.is-open { max-height: 700px; border-color: var(--border); visibility: visible; transition: max-height .45s var(--ease), border-color .3s, visibility 0s; }.nav__drawer ul { list-style: none; margin: 0; padding: 10px; display: flex; flex-direction: column; gap: 3px; }.nav__drawer li { opacity: 0; transform: translateY(8px); transition: opacity .4s var(--ease), transform .4s var(--ease); transition-delay: calc(var(--i) * 40ms); }.nav__drawer.is-open li { opacity: 1; transform: none; }@@@ -36,7 +36,7 @@"githubDisplay": "github.com/OpenVibers","linkedin": "https://www.linkedin.com/in/fris/","linkedinDisplay": "linkedin.com/in/fris",-- "resumePdf": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",++ "resumePdf": "/Alex_Frison_Resume_Sep_2026_76413598.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.",@@@ -44,22 +44,22 @@},"pitch": "I'm a software engineer and business owner who builds complete products and keeps them running: websites, platforms, payment systems, AI agents and the automation behind them. Over the last decade I've shipped 25+ sites and services, rebuilt a live payments program across five processors, led a sales team, and turned an Amazon data-analysis job into tooling other analysts used every day. Give me a problem or a rough idea and I'll deliver the whole thing: design, code, deployment and support.","seeking": [-- { "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" }++ { "label": "Honestly, open to anything", "detail": "Title and level are negotiable. The team, the problem and the chance to own something matter more than the label." },++ { "label": "Software engineering", "detail": "Full-stack web apps, platforms, APIs, payments, streaming and realtime systems, designed, shipped and kept running in production." },++ { "label": "Data & analytics", "detail": "SQL, Excel and Power Query, dashboards and compliance reporting people actually act on, plus automation that removes the busywork." },++ { "label": "Ops, support, sales — whatever fits", "detail": "Ten years running my own company and three leading a retail sales team: I can talk to customers as comfortably as to servers." }],"foundedOrg": "Own company, LLC","lastRole": "PowerChat — Software Engineer (Contract)","metrics": [-- { "num": 25, "suffix": "+", "label": "Websites, apps and services designed, built and shipped end to end", "short": "Sites, apps and services shipped end to end", "icon": "fa-solid fa-cubes-stacked" },++ { "num": 25, "suffix": "+", "label": "Websites, apps and services designed, built and shipped end to end", "short": "Sites and services shipped", "icon": "fa-solid fa-cubes-stacked" },{ "num": 500, "suffix": "k+", "label": "Lines of production code written and maintained", "short": "Lines of production code", "icon": "fa-solid fa-code" },-- { "num": 900, "suffix": "+", "label": "API endpoints and pages running in production, all built solo", "short": "Endpoints and pages live in production", "icon": "fa-solid fa-route" },++ { "num": 900, "suffix": "+", "label": "API endpoints and pages running in production, all built solo", "short": "Endpoints live in production", "icon": "fa-solid fa-route" },{ "num": 169, "suffix": "", "label": "Free web tools built and published for anyone to use", "short": "Free web tools published", "icon": "fa-solid fa-toolbox" },-- { "num": 5, "suffix": "", "label": "Payment processors merged into one billing system that can't double-charge", "short": "Payment processors unified into one billing system", "icon": "fa-solid fa-credit-card" },-- { "num": 1000, "suffix": "+", "label": "Inventory discrepancies investigated and closed for SOX compliance at Amazon", "short": "SOX-compliance inventory cases closed at Amazon", "icon": "fa-solid fa-magnifying-glass-chart" },-- { "num": 200, "suffix": "k+", "label": "Live game servers tracked by a platform I ran for a decade", "short": "Live game servers tracked by my platform", "icon": "fa-solid fa-tower-broadcast" },-- { "num": 10, "suffix": "+", "label": "Years running my own company, from the code to the taxes", "short": "Years running my own company", "icon": "fa-solid fa-building-flag" }++ { "num": 5, "suffix": "", "label": "Payment processors merged into one billing system that can't double-charge", "short": "Payment processors unified", "icon": "fa-solid fa-credit-card" },++ { "num": 1000, "suffix": "+", "label": "Inventory discrepancies investigated and closed for SOX compliance at Amazon", "short": "SOX inventory cases closed", "icon": "fa-solid fa-magnifying-glass-chart" },++ { "num": 200, "suffix": "k+", "label": "Live game servers tracked by a platform I ran for a decade", "short": "Game servers tracked live", "icon": "fa-solid fa-tower-broadcast" },++ { "num": 10, "suffix": "+", "label": "Years running my own company, from the code to the taxes", "short": "Years running my company", "icon": "fa-solid fa-building-flag" }],"network": [{ "key": "live", "name": "OpenVibe.Live", "url": "https://openvibe.live", "role": "Live streaming", "blurb": "RTMP, WHIP and WebRTC ingest, multi-destination restream, VODs, clips, chat, live captions and translation.", "stat": "529 routes · 179k LOC", "image": "/assets/img/sites/live.webp" },@@@ -1,15 +1,15 @@{-- "amber": "/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf",-- "blue": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",-- "cyan": "/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf",-- "default": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",-- "green": "/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf",-- "indigo": "/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf",-- "lime": "/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf",-- "orange": "/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf",-- "pink": "/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf",-- "red": "/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf",-- "teal": "/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf",-- "violet": "/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf",-- "yellow": "/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf"++ "amber": "/Alex_Frison_Resume_Themed_amber_76413598.pdf",++ "blue": "/Alex_Frison_Resume_Sep_2026_76413598.pdf",++ "cyan": "/Alex_Frison_Resume_Themed_cyan_76413598.pdf",++ "default": "/Alex_Frison_Resume_Sep_2026_76413598.pdf",++ "green": "/Alex_Frison_Resume_Themed_green_76413598.pdf",++ "indigo": "/Alex_Frison_Resume_Themed_indigo_76413598.pdf",++ "lime": "/Alex_Frison_Resume_Themed_lime_76413598.pdf",++ "orange": "/Alex_Frison_Resume_Themed_orange_76413598.pdf",++ "pink": "/Alex_Frison_Resume_Themed_pink_76413598.pdf",++ "red": "/Alex_Frison_Resume_Themed_red_76413598.pdf",++ "teal": "/Alex_Frison_Resume_Themed_teal_76413598.pdf",++ "violet": "/Alex_Frison_Resume_Themed_violet_76413598.pdf",++ "yellow": "/Alex_Frison_Resume_Themed_yellow_76413598.pdf"}
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_76413598.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.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_76413598.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_76413598.pdf differ
@@ -321,19 +321,19 @@ def style(name: str, size: float, leading: float, color, font: str | None = None
@@ -321,19 +321,19 @@ 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.summary = style("summary", 8.1, 9.8, MUTED, FONT["italic"])
- self.bullet = style("bullet", 8.2, 9.9, BODY, leftIndent=9, bulletIndent=0,
+ self.body = style("body", 9.1, 11.4, BODY)
+ self.summary = style("summary", 8.4, 10.3, MUTED, FONT["italic"])
+ self.bullet = style("bullet", 8.8, 10.9, BODY, leftIndent=9, bulletIndent=0,
bulletFontName=FONT["regular"], bulletFontSize=8.2, bulletColor=BLUE)
bulletFontName=FONT["regular"], bulletFontSize=8.2, bulletColor=BLUE)
- self.bullet_compact = style("bulletc", 8.2, 10.0, BODY, leftIndent=9, bulletIndent=0,
+ self.bullet_compact = style("bulletc", 8.6, 10.7, BODY, leftIndent=9, bulletIndent=0,
bulletFontName=FONT["regular"], bulletFontSize=8.2, bulletColor=BLUE)
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"])
- self.proj_summary = style("psum", 7.5, 9.0, BODY)
+ self.proj_summary = style("psum", 7.9, 9.6, BODY)
self.proj_link = style("plink", 7.3, 8.8, BLUE)
self.proj_link = style("plink", 7.3, 8.8, BLUE)
- self.skill_items = style("skills", 8.2, 9.7, BODY)
- self.edu = style("edu", 8.3, 10.1, BODY)
+ self.skill_items = style("skills", 8.5, 10.3, BODY)
+ self.edu = style("edu", 8.7, 10.8, BODY)
self.chip = style("chip", 7.6, 9.2, BLUE_DARK, FONT["bold"])
self.chip = style("chip", 7.6, 9.2, BLUE_DARK, FONT["bold"])
self.hero_line = style("heroline", 8.6, 11.2, white)
self.hero_line = style("heroline", 8.6, 11.2, white)
@@ -536,11 +536,15 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -536,11 +536,15 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
cx += cw + gap
cx += cw + gap
return rows
return rows
- def draw_rows(rows, row_top, chip_h, font, size, icon_w, pad_x, gap, bg, bg_alpha, fg, glyph_of):
+ def draw_rows(rows, row_top, chip_h, font, size, icon_w, pad_x, gap, bg, bg_alpha, fg, glyph_of, icon_fg=None, shadow=False):
for row in rows:
for row in rows:
cx2 = x + pad
cx2 = x + pad
for it, cw in row:
for it, cw in row:
c.saveState()
c.saveState()
+ if shadow:
+ c.setFillColor(BLUE_DARK)
+ c.setFillAlpha(0.22)
+ c.roundRect(cx2, row_top - chip_h - 1.6, cw, chip_h, chip_h / 2, stroke=0, fill=1)
c.setFillColor(bg)
c.setFillColor(bg)
c.setFillAlpha(bg_alpha)
c.setFillAlpha(bg_alpha)
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)
@@ -549,7 +553,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -549,7 +553,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
glyph = glyph_of(it)
glyph = glyph_of(it)
tx = cx2 + pad_x / 2
tx = cx2 + pad_x / 2
if glyph:
if glyph:
- icon(c, tx, ty2, glyph, size, fg)
+ icon(c, tx, ty2, glyph, size, icon_fg or fg)
tx += icon_w
tx += icon_w
c.setFillColor(fg)
c.setFillColor(fg)
c.setFont(font, size)
c.setFont(font, size)
@@ -560,7 +564,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -560,7 +564,7 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
row_top -= chip_h + 5
row_top -= chip_h + 5
return row_top
return row_top
- roles = [{"text": r, "url": None} for r in (profile.get("roles") or [])[:6]]
+ roles = [{"text": r, "url": None} for r in (profile.get("roles") or [])]
role_font, role_size, role_pad_x, role_icon_w, role_gap = FONT["bold"], 7.6, 20.0, 13.0, 7.0
role_font, role_size, role_pad_x, role_icon_w, role_gap = FONT["bold"], 7.6, 20.0, 13.0, 7.0
role_rows = wrap(roles, role_font, role_size, role_icon_w, role_pad_x, role_gap)
role_rows = wrap(roles, role_font, role_size, role_icon_w, role_pad_x, role_gap)
role_h = role_size + 6.5
role_h = role_size + 6.5
@@ -574,13 +578,18 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -574,13 +578,18 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
{"text": profile.get("locationShort") or profile.get("location"), "icon": "location", "url": None},
{"text": profile.get("locationShort") or profile.get("location"), "icon": "location", "url": None},
]
]
contact_items = [it for it in contact_items if it["text"]]
contact_items = [it for it in contact_items if it["text"]]
- contact_font, contact_size, contact_pad_x, contact_icon_w, contact_gap = FONT["regular"], 8.6, 18.0, 15.0, 8.0
+ contact_font, contact_size, contact_pad_x, contact_icon_w, contact_gap = FONT["bold"], 8.4, 18.0, 15.0, 8.0
contact_rows = wrap(contact_items, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap)
contact_rows = wrap(contact_items, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap)
- contact_h = contact_size + 7
+ contact_h = contact_size + 8
contact_total_h = len(contact_rows) * contact_h + max(0, len(contact_rows) - 1) * 6
contact_total_h = len(contact_rows) * contact_h + max(0, len(contact_rows) - 1) * 6
- group_gap = 6.0
- banner_h = pad + name_size * 0.78 + 5 + tag_size * 0.78 + 13 + role_total_h + group_gap + contact_total_h + pad
+ # The contact row sits in its own darker band along the bottom of the banner, as white
+ # pills with dark text — the inverse of the translucent role chips above it, so the two rows
+ # read as "what I am" and "how to reach me" instead of one undifferentiated pile of pills.
+ band_pad = 8.0
+ band_h = contact_total_h + band_pad * 2
+ group_gap = 9.0
+ banner_h = pad + name_size * 0.78 + 10 + role_total_h + group_gap + band_h
if draw:
if draw:
c.saveState()
c.saveState()
@@ -596,22 +605,33 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -596,22 +605,33 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
ty = y_top - pad
ty = y_top - pad
if draw:
if draw:
+ # Name and tagline share one line; both get a soft offset shadow (reportlab has no blur,
+ # so a translucent dark copy one point down-right is the closest thing).
+ base = ty - name_size * 0.78
+ name_w = pdfmetrics.stringWidth(profile["name"], FONT["bold"], name_size)
+ c.saveState()
+ c.setFillColor(BLUE_DARK); c.setFillAlpha(0.45)
+ c.setFont(FONT["bold"], name_size); c.drawString(x + pad + 1.2, base - 1.4, profile["name"])
+ c.setFont(FONT["regular"], tag_size); c.drawString(x + pad + name_w + 14 + 0.8, base + 0.6 - 1.0, profile["tagline"])
+ c.restoreState()
c.setFillColor(white)
c.setFillColor(white)
c.setFont(FONT["bold"], name_size)
c.setFont(FONT["bold"], name_size)
- c.drawString(x + pad, ty - name_size * 0.78, profile["name"])
- ty -= name_size * 0.78 + 5
- if draw:
- c.setFillColor(HexColor("#dbe4ff"))
+ c.drawString(x + pad, base, profile["name"])
+ c.setFillColor(HexColor("#e4eaff"))
c.setFont(FONT["regular"], tag_size)
c.setFont(FONT["regular"], tag_size)
- c.drawString(x + pad, ty - tag_size * 0.78, profile["tagline"])
- ty -= tag_size * 0.78 + 10
+ c.drawString(x + pad + name_w + 14, base + 0.6, profile["tagline"])
+ ty -= name_size * 0.78 + 10
if draw:
if draw:
ty = draw_rows(role_rows, ty, role_h, role_font, role_size, role_icon_w, role_pad_x, role_gap,
ty = draw_rows(role_rows, ty, role_h, role_font, role_size, role_icon_w, role_pad_x, role_gap,
BLUE_SOFT, 0.4, white, lambda it: role_icon(it["text"]))
BLUE_SOFT, 0.4, white, lambda it: role_icon(it["text"]))
- ty -= group_gap - 5
- draw_rows(contact_rows, ty, contact_h, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap,
- white, 0.16, white, lambda it: ICON[it["icon"]])
+ c.saveState()
+ p = c.beginPath(); p.roundRect(x, y_top - banner_h, w, banner_h, 12); c.clipPath(p, stroke=0, fill=0)
+ c.setFillColor(BLUE_DARK); c.setFillAlpha(0.22)
+ c.rect(x, y_top - banner_h, w, band_h, stroke=0, fill=1)
+ c.restoreState()
+ draw_rows(contact_rows, y_top - banner_h + band_h - band_pad, contact_h, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap,
+ white, 0.94, BLUE_DARK, lambda it: ICON[it["icon"]], icon_fg=BLUE, shadow=True)
return banner_h
return banner_h
@@ -630,13 +650,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
@@ -630,13 +650,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
rows = [seeking[i:i + cols] for i in range(0, n, cols)]
rows = [seeking[i:i + cols] for i in range(0, n, cols)]
gap = 8.0
gap = 8.0
w = (CONTENT_W - gap * (cols - 1)) / cols
w = (CONTENT_W - gap * (cols - 1)) / cols
- pad = 8.5
- icon_r = 11.0
+ pad = 6.5
+ icon_r = 8.5
inner_w = w - 2 * pad
inner_w = w - 2 * pad
- 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)
+ title_w = inner_w - icon_r * 2 - 8
+ title_st = ParagraphStyle("fctitle", fontName=FONT["bold"], fontSize=9.4, leading=1, textColor=INK)
+ detail_st = ParagraphStyle("fcdetail", fontName=FONT["regular"], fontSize=7.8, leading=9.6, textColor=BODY,
+ bulletFontName=FONT["regular"], bulletFontSize=7.8, bulletColor=BLUE)
prepared = []
prepared = []
for s in seeking:
for s in seeking:
title_text = s["label"]
title_text = s["label"]
@@ -645,12 +665,12 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
@@ -645,12 +665,12 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
detail_p = para(esc(s.get("detail", "")), 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)
- row_gap = 7.0
+ row_gap = 6.0
row_heights = []
row_heights = []
for r in range(len(rows)):
for r in range(len(rows)):
chunk = prepared[r * cols:(r + 1) * cols]
chunk = prepared[r * cols:(r + 1) * cols]
detail_h = max(para_height(p, inner_w) for _, p in chunk)
detail_h = max(para_height(p, inner_w) for _, p in chunk)
- row_heights.append(pad + title_row_h + 9 + detail_h + pad)
+ row_heights.append(pad + title_row_h + 7 + detail_h + pad)
h = sum(row_heights) + row_gap * (len(rows) - 1)
h = sum(row_heights) + row_gap * (len(rows) - 1)
if draw:
if draw:
row_top = y_top
row_top = y_top
@@ -664,13 +684,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
@@ -664,13 +684,13 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
card(c, x, row_top, w, rh, radius=12)
card(c, x, row_top, w, rh, radius=12)
top = row_top - pad
top = row_top - pad
icon_cy = top - title_row_h / 2
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)
+ icon_circle(c, x + pad + icon_r, icon_cy, icon_r, seeking_icon(s["label"]), BLUE, ICON_BG, 8.6)
c.setFillColor(INK)
c.setFillColor(INK)
c.setFont(title_st.fontName, title_st.fontSize)
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
+ c.drawString(x + pad + icon_r * 2 + 8, icon_cy - title_st.fontSize * 0.36, title_text)
+ rule_y = top - title_row_h - 4
hairline(c, x + pad, rule_y, x + w - pad)
hairline(c, x + pad, rule_y, x + w - pad)
- draw_para(c, detail_p, x + pad, rule_y - 8, inner_w)
+ draw_para(c, detail_p, x + pad, rule_y - 4, inner_w)
row_top -= rh + row_gap
row_top -= rh + row_gap
return h
return h
@@ -681,14 +701,14 @@ def block_metrics(c, metrics: list[dict], st: Styles, y_top: float, draw: bool)
@@ -681,14 +701,14 @@ def block_metrics(c, metrics: list[dict], st: Styles, y_top: float, draw: bool)
separate boxed cards."""
separate boxed cards."""
n = len(metrics)
n = len(metrics)
col_w = CONTENT_W / n
col_w = CONTENT_W / n
- icon_r = 11.0
- num_size = 15.0
- pad = 10.0
- label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=7.1, leading=8.5, textColor=MUTED)
+ icon_r = 9.5
+ num_size = 12.5
+ pad = 9.0
+ label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=7.5, leading=8.9, textColor=BODY)
text_w = col_w - pad - icon_r * 2 - 8 - 6
text_w = col_w - pad - icon_r * 2 - 8 - 6
label_paras = [para(esc(m.get("short") or m["label"]), label_st) for m in metrics]
label_paras = [para(esc(m.get("short") or m["label"]), label_st) for m in metrics]
label_h = max(para_height(p, text_w) for p in label_paras)
label_h = max(para_height(p, text_w) for p in label_paras)
- h = max(icon_r * 2 + 12, num_size + label_h + 6) + 12
+ h = max(icon_r * 2 + 10, num_size + label_h + 4) + 10
if draw:
if draw:
card(c, MARGIN, y_top, CONTENT_W, h, radius=12)
card(c, MARGIN, y_top, CONTENT_W, h, radius=12)
cy = y_top - h / 2
cy = y_top - h / 2
@@ -734,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
@@ -734,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
dates = f"{role.get('start', '')} – {role.get('end', '')}".strip(" –")
dates = f"{role.get('start', '')} – {role.get('end', '')}".strip(" –")
dw = pdfmetrics.stringWidth(dates, FONT["regular"], date_size)
dw = pdfmetrics.stringWidth(dates, FONT["regular"], date_size)
base = y - title_size * 0.78
base = y - title_size * 0.78
+ title_w = pdfmetrics.stringWidth(role["role"], FONT["bold"], title_size)
+ company_w = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)
+ loc = f" · {role['location']}" if role.get("location") else ""
+ loc_w = pdfmetrics.stringWidth(loc, FONT["regular"], meta_size)
+ # Company (and location) join the title line when there's room; otherwise they take a
+ # second line as before. Saves a line per role on most of the sheet.
+ inline = x + icon_col + title_w + 10 + company_w + loc_w < x + w - dw - 14
if draw:
if draw:
glyph = KIND_ICON.get(role.get("kind"), DEFAULT_ICON)
glyph = KIND_ICON.get(role.get("kind"), DEFAULT_ICON)
icon_circle(c, x + 6, base + title_size * 0.28, 7.4, glyph, BLUE, ICON_BG, 7.2)
icon_circle(c, x + 6, base + title_size * 0.28, 7.4, glyph, BLUE, ICON_BG, 7.2)
@@ -746,13 +773,20 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
@@ -746,13 +773,20 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
if role.get("current") or str(role.get("end", "")).lower() == "present":
if role.get("current") or str(role.get("end", "")).lower() == "present":
c.setFillColor(MINT)
c.setFillColor(MINT)
c.circle(x + w - dw - 7.5, base + 3.4, 2.1, stroke=0, fill=1)
c.circle(x + w - dw - 7.5, base + 3.4, 2.1, stroke=0, fill=1)
- y = base - 2.6
- base = y - meta_size * 0.78
+ if inline:
+ cx0 = x + icon_col + title_w + 10
+ meta_base = base + 0.4
+ else:
+ y = base - 2.6
+ meta_base = y - meta_size * 0.78
+ cx0 = x + icon_col
+ base = meta_base
if draw:
if draw:
c.setFillColor(BLUE)
c.setFillColor(BLUE)
c.setFont(FONT["bold"], meta_size)
c.setFont(FONT["bold"], meta_size)
- c.drawString(x + icon_col, base, role["company"])
- cw = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)
+ c.drawString(cx0, base, role["company"])
+ cw = company_w
+ x_save, x = x, cx0 - icon_col # keep the link/underline math below relative to cx0
if role.get("link"):
if role.get("link"):
# A thin underline is the only cue a static PDF has for "this text is a live
# A thin underline is the only cue a static PDF has for "this text is a live
# link" — plain bold-blue looks identical to every other (non-linked) company name.
# link" — plain bold-blue looks identical to every other (non-linked) company name.
@@ -766,7 +800,8 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
@@ -766,7 +800,8 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
c.setFillColor(MUTED)
c.setFillColor(MUTED)
c.setFont(FONT["regular"], meta_size)
c.setFont(FONT["regular"], meta_size)
c.drawString(x + icon_col + cw, base, f" · {role['location']}")
c.drawString(x + icon_col + cw, base, f" · {role['location']}")
- y = base - 2.0
+ x = x_save
+ y = base - (2.0 if not inline else 3.0)
return y_top - y
return y_top - y
@@ -166,13 +166,21 @@ const path = Astro.url.pathname;
@@ -166,13 +166,21 @@ const path = Astro.url.pathname;
}
}
.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }
+ /* Glass pill tinted by the accent, with the page's own text color on top — readable on any
+ accent (a solid bright pink with white text was not) and it re-tints itself as the theme changes. */
.nav__cta {
.nav__cta {
- display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px;
- background: var(--accent); color: var(--on-accent); font: 600 .82rem var(--font-body); text-decoration: none;
- box-shadow: 0 8px 22px -10px rgba(var(--accent-rgb), .9);
- transition: transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s;
+ position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 9px 15px 9px 12px; border-radius: 999px;
+ background: linear-gradient(180deg, rgba(var(--accent-rgb), .30), rgba(var(--accent-rgb), .16));
+ border: 1px solid rgba(var(--accent-rgb), .6); color: var(--fg);
+ font: 700 .8rem var(--font-body); letter-spacing: .02em; text-decoration: none;
+ backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.15), 0 0 0 3px rgba(var(--accent-rgb), .10), 0 10px 24px -12px rgba(var(--accent-rgb), .8);
+ transition: transform .3s var(--ease), box-shadow .3s var(--ease), background .3s var(--ease), border-color .3s var(--ease);
}
}
- .nav__cta:hover { text-decoration: none; transform: translateY(-1px); filter: brightness(1.08); }
+ .nav__cta i { color: var(--accent); filter: brightness(1.25) saturate(1.1); font-size: .85rem; transition: transform .3s var(--ease); }
+ .nav__cta:hover { text-decoration: none; transform: translateY(-1px); border-color: rgba(var(--accent-rgb), .9); background: linear-gradient(180deg, rgba(var(--accent-rgb), .42), rgba(var(--accent-rgb), .22)); box-shadow: inset 0 1px 0 rgba(255,255,255,.28), 0 0 0 4px rgba(var(--accent-rgb), .14), 0 12px 28px -12px rgba(var(--accent-rgb), .95); }
+ .nav__cta:hover i { transform: translateY(2px); }
+ :root[data-theme="light"] .nav__cta { background: linear-gradient(180deg, rgba(var(--accent-rgb), .22), rgba(var(--accent-rgb), .12)); }
.nav__icon {
.nav__icon {
position: relative; width: 40px; height: 40px; border-radius: 50%; display: inline-grid; place-items: center;
position: relative; width: 40px; height: 40px; border-radius: 50%; display: inline-grid; place-items: center;
background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer;
background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer;
@@ -200,11 +208,14 @@ const path = Astro.url.pathname;
@@ -200,11 +208,14 @@ const path = Astro.url.pathname;
max-width: var(--container); margin: 8px auto 0;
max-width: var(--container); margin: 8px auto 0;
border-radius: var(--radius-lg); overflow: hidden;
border-radius: var(--radius-lg); overflow: hidden;
background: color-mix(in oklab, var(--bg) 84%, transparent);
background: color-mix(in oklab, var(--bg) 84%, transparent);
- border: 1px solid transparent;
+ border: 1px solid transparent; background-clip: padding-box;
backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
- max-height: 0; transition: max-height .45s var(--ease), border-color .3s;
+ /* Fully hidden when closed: at max-height 0 the 1px transparent border still let the dark
+ background show through as a 2px line under the nav. */
+ max-height: 0; visibility: hidden;
+ transition: max-height .45s var(--ease), border-color .3s, visibility 0s .45s;
}
}
- .nav__drawer.is-open { max-height: 700px; border-color: var(--border); }
+ .nav__drawer.is-open { max-height: 700px; border-color: var(--border); visibility: visible; transition: max-height .45s var(--ease), border-color .3s, visibility 0s; }
.nav__drawer ul { list-style: none; margin: 0; padding: 10px; display: flex; flex-direction: column; gap: 3px; }
.nav__drawer ul { list-style: none; margin: 0; padding: 10px; display: flex; flex-direction: column; gap: 3px; }
.nav__drawer li { opacity: 0; transform: translateY(8px); transition: opacity .4s var(--ease), transform .4s var(--ease); transition-delay: calc(var(--i) * 40ms); }
.nav__drawer li { opacity: 0; transform: translateY(8px); transition: opacity .4s var(--ease), transform .4s var(--ease); transition-delay: calc(var(--i) * 40ms); }
.nav__drawer.is-open li { opacity: 1; transform: none; }
.nav__drawer.is-open li { opacity: 1; transform: none; }
@@ -36,7 +36,7 @@
@@ -36,7 +36,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_1c4cdbb2.pdf",
+ "resumePdf": "/Alex_Frison_Resume_Sep_2026_76413598.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.",
@@ -44,22 +44,22 @@
@@ -44,22 +44,22 @@
},
},
"pitch": "I'm a software engineer and business owner who builds complete products and keeps them running: websites, platforms, payment systems, AI agents and the automation behind them. Over the last decade I've shipped 25+ sites and services, rebuilt a live payments program across five processors, led a sales team, and turned an Amazon data-analysis job into tooling other analysts used every day. Give me a problem or a rough idea and I'll deliver the whole thing: design, code, deployment and support.",
"pitch": "I'm a software engineer and business owner who builds complete products and keeps them running: websites, platforms, payment systems, AI agents and the automation behind them. Over the last decade I've shipped 25+ sites and services, rebuilt a live payments program across five processors, led a sales team, and turned an Amazon data-analysis job into tooling other analysts used every day. Give me a problem or a rough idea and I'll deliver the whole thing: design, code, deployment and support.",
"seeking": [
"seeking": [
- { "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" }
+ { "label": "Honestly, open to anything", "detail": "Title and level are negotiable. The team, the problem and the chance to own something matter more than the label." },
+ { "label": "Software engineering", "detail": "Full-stack web apps, platforms, APIs, payments, streaming and realtime systems, designed, shipped and kept running in production." },
+ { "label": "Data & analytics", "detail": "SQL, Excel and Power Query, dashboards and compliance reporting people actually act on, plus automation that removes the busywork." },
+ { "label": "Ops, support, sales — whatever fits", "detail": "Ten years running my own company and three leading a retail sales team: I can talk to customers as comfortably as to servers." }
],
],
"foundedOrg": "Own company, LLC",
"foundedOrg": "Own company, LLC",
"lastRole": "PowerChat — Software Engineer (Contract)",
"lastRole": "PowerChat — Software Engineer (Contract)",
"metrics": [
"metrics": [
- { "num": 25, "suffix": "+", "label": "Websites, apps and services designed, built and shipped end to end", "short": "Sites, apps and services shipped end to end", "icon": "fa-solid fa-cubes-stacked" },
+ { "num": 25, "suffix": "+", "label": "Websites, apps and services designed, built and shipped end to end", "short": "Sites and services shipped", "icon": "fa-solid fa-cubes-stacked" },
{ "num": 500, "suffix": "k+", "label": "Lines of production code written and maintained", "short": "Lines of production code", "icon": "fa-solid fa-code" },
{ "num": 500, "suffix": "k+", "label": "Lines of production code written and maintained", "short": "Lines of production code", "icon": "fa-solid fa-code" },
- { "num": 900, "suffix": "+", "label": "API endpoints and pages running in production, all built solo", "short": "Endpoints and pages live in production", "icon": "fa-solid fa-route" },
+ { "num": 900, "suffix": "+", "label": "API endpoints and pages running in production, all built solo", "short": "Endpoints live in production", "icon": "fa-solid fa-route" },
{ "num": 169, "suffix": "", "label": "Free web tools built and published for anyone to use", "short": "Free web tools published", "icon": "fa-solid fa-toolbox" },
{ "num": 169, "suffix": "", "label": "Free web tools built and published for anyone to use", "short": "Free web tools published", "icon": "fa-solid fa-toolbox" },
- { "num": 5, "suffix": "", "label": "Payment processors merged into one billing system that can't double-charge", "short": "Payment processors unified into one billing system", "icon": "fa-solid fa-credit-card" },
- { "num": 1000, "suffix": "+", "label": "Inventory discrepancies investigated and closed for SOX compliance at Amazon", "short": "SOX-compliance inventory cases closed at Amazon", "icon": "fa-solid fa-magnifying-glass-chart" },
- { "num": 200, "suffix": "k+", "label": "Live game servers tracked by a platform I ran for a decade", "short": "Live game servers tracked by my platform", "icon": "fa-solid fa-tower-broadcast" },
- { "num": 10, "suffix": "+", "label": "Years running my own company, from the code to the taxes", "short": "Years running my own company", "icon": "fa-solid fa-building-flag" }
+ { "num": 5, "suffix": "", "label": "Payment processors merged into one billing system that can't double-charge", "short": "Payment processors unified", "icon": "fa-solid fa-credit-card" },
+ { "num": 1000, "suffix": "+", "label": "Inventory discrepancies investigated and closed for SOX compliance at Amazon", "short": "SOX inventory cases closed", "icon": "fa-solid fa-magnifying-glass-chart" },
+ { "num": 200, "suffix": "k+", "label": "Live game servers tracked by a platform I ran for a decade", "short": "Game servers tracked live", "icon": "fa-solid fa-tower-broadcast" },
+ { "num": 10, "suffix": "+", "label": "Years running my own company, from the code to the taxes", "short": "Years running my company", "icon": "fa-solid fa-building-flag" }
],
],
"network": [
"network": [
{ "key": "live", "name": "OpenVibe.Live", "url": "https://openvibe.live", "role": "Live streaming", "blurb": "RTMP, WHIP and WebRTC ingest, multi-destination restream, VODs, clips, chat, live captions and translation.", "stat": "529 routes · 179k LOC", "image": "/assets/img/sites/live.webp" },
{ "key": "live", "name": "OpenVibe.Live", "url": "https://openvibe.live", "role": "Live streaming", "blurb": "RTMP, WHIP and WebRTC ingest, multi-destination restream, VODs, clips, chat, live captions and translation.", "stat": "529 routes · 179k LOC", "image": "/assets/img/sites/live.webp" },
@@ -1,15 +1,15 @@
@@ -1,15 +1,15 @@
{
{
- "amber": "/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf",
- "blue": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",
- "cyan": "/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf",
- "default": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",
- "green": "/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf",
- "indigo": "/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf",
- "lime": "/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf",
- "orange": "/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf",
- "pink": "/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf",
- "red": "/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf",
- "teal": "/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf",
- "violet": "/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf",
- "yellow": "/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf"
+ "amber": "/Alex_Frison_Resume_Themed_amber_76413598.pdf",
+ "blue": "/Alex_Frison_Resume_Sep_2026_76413598.pdf",
+ "cyan": "/Alex_Frison_Resume_Themed_cyan_76413598.pdf",
+ "default": "/Alex_Frison_Resume_Sep_2026_76413598.pdf",
+ "green": "/Alex_Frison_Resume_Themed_green_76413598.pdf",
+ "indigo": "/Alex_Frison_Resume_Themed_indigo_76413598.pdf",
+ "lime": "/Alex_Frison_Resume_Themed_lime_76413598.pdf",
+ "orange": "/Alex_Frison_Resume_Themed_orange_76413598.pdf",
+ "pink": "/Alex_Frison_Resume_Themed_pink_76413598.pdf",
+ "red": "/Alex_Frison_Resume_Themed_red_76413598.pdf",
+ "teal": "/Alex_Frison_Resume_Themed_teal_76413598.pdf",
+ "violet": "/Alex_Frison_Resume_Themed_violet_76413598.pdf",
+ "yellow": "/Alex_Frison_Resume_Themed_yellow_76413598.pdf"
}
}