openvibers/afresume Commits

Resume + site pass: PDF header/skills/links redesign, copy audit, hero portrait FX, theme transitions, floating scrollbar, drawer Themes/dark switch

OP OpenVibers committed 20 Sept 2026 · bb5eb9f435 · 37 files, +624 −193

PDF (resume/build_resume.py)
- Header: contact info is now a row of individual pills under the role
  chips, both spanning the full banner width (bigger type, no cramped
  right column, no hardcoded blue icon circles in non-blue themes).
- Skills: two-column matrix with a label line per group; Education and
  Links split into their own cards; page 2 fills the sheet.
- Linked company names (PowerChat, OpenVibe) are underlined so the
  existing link annotations are discoverable.
- Metrics strip uses metrics[].short; content hash no longer includes
  profile.resumePdf, so rebuilding without content changes keeps the
  same filename.

Copy (profile.json, experience/*.json, Now.astro, Hero.astro)
- Tagline adds data analyst; GameServerStats is no longer named in bio
  or resume copy ("my own company"); hero roles lead with employer-
  facing labels (Business Owner instead of Founder of OpenVibe, a/an
  handled); metrics generalized away from OpenVibe-specific counts;
  Amazon described as robotics-floor automation + ICQA/SOX analysis;
  OpenVibe experience bullets rewritten for non-technical readers;
  Profile section rewritten with a short pitch and five highlights.

Site
- Hero status pill split into wrap-safe segments; portrait rebuilt:
  halo-free cut-outs, glass plinth, canvas contour + glow hugging the
  PNG's alpha edge with silhouette ripples and pointer parallax.
- Accent and light/dark changes animate (View Transitions + property
  transitions); Lenis smooth scroll on touch; native scrollbar replaced
  by a floating, auto-hiding, draggable overlay bar.
- Mobile drawer: "Themes" opens the picker; a real dark-mode switch
  that does not close the drawer.

Changes

ddiff --git a/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf b/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf
ddeleted file mode 100644
iindex da35485..0000000
BBinary files a/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf b/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf
nnew file mode 100644
iindex 0000000..d9ba3ca
BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf b/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf
ddeleted file mode 100644
iindex 22f4047..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf b/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf
nnew file mode 100644
iindex 0000000..ae9473f
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf b/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf
ddeleted file mode 100644
iindex 9ef1316..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf b/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf
nnew file mode 100644
iindex 0000000..a291f97
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_4a270599.pdf b/public/Alex_Frison_Resume_Themed_green_4a270599.pdf
ddeleted file mode 100644
iindex 9f8d3f0..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_green_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_fde01332.pdf b/public/Alex_Frison_Resume_Themed_green_fde01332.pdf
nnew file mode 100644
iindex 0000000..676535c
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf b/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf
ddeleted file mode 100644
iindex d7757d7..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf b/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf
nnew file mode 100644
iindex 0000000..0657161
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf b/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf
ddeleted file mode 100644
iindex f46a47c..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf b/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf
nnew file mode 100644
iindex 0000000..893191c
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf b/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf
ddeleted file mode 100644
iindex 8bef843..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf b/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf
nnew file mode 100644
iindex 0000000..7869ff5
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf b/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf
ddeleted file mode 100644
iindex 7185c8a..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf b/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf
nnew file mode 100644
iindex 0000000..ebdfe48
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_4a270599.pdf b/public/Alex_Frison_Resume_Themed_red_4a270599.pdf
ddeleted file mode 100644
iindex 7faf28c..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_red_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_fde01332.pdf b/public/Alex_Frison_Resume_Themed_red_fde01332.pdf
nnew file mode 100644
iindex 0000000..9784e1d
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf b/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf
ddeleted file mode 100644
iindex 844739b..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf b/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf
nnew file mode 100644
iindex 0000000..0d5df37
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf b/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf
ddeleted file mode 100644
iindex d878c7b..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf b/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf
nnew file mode 100644
iindex 0000000..f7284a3
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf b/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf
ddeleted file mode 100644
iindex ef6ad06..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf b/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf
nnew file mode 100644
iindex 0000000..d4357b0
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf differ
ddiff --git a/public/assets/img/me-cut1.png b/public/assets/img/me-cut1.png
iindex 04356bb..1d22d88 100755
BBinary files a/public/assets/img/me-cut1.png and b/public/assets/img/me-cut1.png differ
ddiff --git a/public/assets/img/me-cut2.png b/public/assets/img/me-cut2.png
iindex 9aea2e4..259049e 100755
BBinary files a/public/assets/img/me-cut2.png and b/public/assets/img/me-cut2.png differ
ddiff --git a/resume/build_resume.py b/resume/build_resume.py
iindex fc3e96d..1fd8435 100644
---- a/resume/build_resume.py
++++ b/resume/build_resume.py
@@@ -52,7 +52,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"]
++METRIC_KEYWORDS = ["shipped", "lines of production", "payment processors", "sox compliance", "years running"]
FULL_ROLES = 2 # PowerChat + OpenVibe get full treatment on page 1
COMPACT_BULLETS = 2 # bullets per role on page 2
@@@ -190,9 +190,11 @@ def metric_icon(css_class: str) -> str:
# 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"),
++ ("idea", "\uf0eb"), ("business", "\uf0b1"), ("owner", "\uf0b1"), ("automat", "\uf085"),
("platform", "\uf233"), ("architect", "\uf233"), ("founder", "\uf135"),
-- ("ai ", "\uf2db"), ("engineer", "\uf121"),
++ ("analyst", "\uf201"), ("data", "\uf201"), ("ai ", "\uf2db"), ("product", "\uf1b2"),
++ ("team", "\uf0c0"), ("leader", "\uf0c0"), ("problem", "\uf0ad"), ("full-stack", "\uf0ac"),
++ ("developer", "\uf121"), ("engineer", "\uf121"),
]
DEFAULT_ROLE_ICON = "\uf121" # code
@@@ -330,7 +332,7 @@ class Styles:
self.proj_title = style("ptitle", 8.5, 10.2, INK, FONT["bold"])
self.proj_summary = style("psum", 7.5, 9.0, BODY)
self.proj_link = style("plink", 7.3, 8.8, BLUE)
-- self.skill_items = style("skills", 8.3, 10.1, BODY)
++ self.skill_items = style("skills", 8.2, 9.7, BODY)
self.edu = style("edu", 8.3, 10.1, BODY)
self.chip = style("chip", 7.6, 9.2, BLUE_DARK, FONT["bold"])
self.hero_line = style("heroline", 8.6, 11.2, white)
@@@ -510,47 +512,75 @@ def section_header(c, x: float, y_top: float, text: str, width: float, glyph: st
# --------------------------------------------------------------------------
def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> float:
-- """A full-width blue banner: name + tagline on the left, a contact panel on the
-- right — the site's hero, compressed onto paper. Compact on purpose: the feature-card
-- row right under it (block_feature_cards) is where the "what I do" chips live now."""
++ """A full-width colored banner: name + tagline, then two chip rows spanning the whole
++ width — "what I do" (role chips) and "how to reach me" (contact chips) — instead of
++ squeezing contact info into a narrow right-side column, which is cramped, is stuck a
++ fixed height regardless of how much text wraps, and had a hardcoded icon-circle color
++ that didn't move with the chosen theme. One wrap/draw helper serves both rows, so
++ neither can disagree between its measured and drawn width."""
x = MARGIN
w = CONTENT_W
name_size, tag_size = 20.0, 9.2
-- pad = 12.0
--
-- items = [
-- ("email", profile.get("emailDisplay") or profile.get("email"), f"mailto:{profile['email']}" if profile.get("email") else None),
-- ("phone", profile.get("phoneDisplay") or profile.get("phone"), f"tel:{profile['phone']}" if profile.get("phone") else None),
-- ("site", profile.get("siteDisplay"), profile.get("site")),
-- ("linkedin", profile.get("linkedinDisplay"), profile.get("linkedin")),
-- ("location", profile.get("locationShort") or profile.get("location"), None),
++ pad = 10.5
++ inner_w = w - pad * 2
++
++ def wrap(items, font, size, icon_w, pad_x, gap):
++ rows: list[list[tuple[dict, float]]] = [[]]
++ cx = 0.0
++ for it in items:
++ cw = pdfmetrics.stringWidth(it["text"], font, size) + pad_x + icon_w
++ if cx > 0 and cx + cw > inner_w:
++ rows.append([])
++ cx = 0.0
++ rows[-1].append((it, cw))
++ cx += cw + gap
++ return rows
++
++ def draw_rows(rows, row_top, chip_h, font, size, icon_w, pad_x, gap, bg, bg_alpha, fg, glyph_of):
++ for row in rows:
++ cx2 = x + pad
++ for it, cw in row:
++ c.saveState()
++ c.setFillColor(bg)
++ c.setFillAlpha(bg_alpha)
++ c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)
++ c.restoreState()
++ ty2 = row_top - chip_h / 2 - size * 0.36
++ glyph = glyph_of(it)
++ tx = cx2 + pad_x / 2
++ if glyph:
++ icon(c, tx, ty2, glyph, size, fg)
++ tx += icon_w
++ c.setFillColor(fg)
++ c.setFont(font, size)
++ c.drawString(tx, ty2, it["text"])
++ if it.get("url"):
++ c.linkURL(it["url"], (cx2, row_top - chip_h, cx2 + cw, row_top), relative=0, thickness=0)
++ cx2 += cw + gap
++ row_top -= chip_h + 5
++ return row_top
++
++ roles = [{"text": r, "url": None} for r in (profile.get("roles") or [])[:6]]
++ 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_h = role_size + 6.5
++ role_total_h = len(role_rows) * role_h + max(0, len(role_rows) - 1) * 5
++
++ contact_items = [
++ {"text": profile.get("emailDisplay") or profile.get("email"), "icon": "email", "url": f"mailto:{profile['email']}" if profile.get("email") else None},
++ {"text": profile.get("phoneDisplay") or profile.get("phone"), "icon": "phone", "url": f"tel:{profile['phone']}" if profile.get("phone") else None},
++ {"text": profile.get("siteDisplay"), "icon": "site", "url": profile.get("site")},
++ {"text": profile.get("linkedinDisplay"), "icon": "linkedin", "url": profile.get("linkedin")},
++ {"text": profile.get("locationShort") or profile.get("location"), "icon": "location", "url": None},
]
-- items = [it for it in items if it[1]]
-- panel_w = 178.0
-- row_h = 13.0
-- panel_h = pad * 0.5 + len(items) * row_h
--
-- # Role chips, wrapped into rows against the space left of the contact panel — computed once,
-- # geometrically, and reused for both the height calc and the draw pass, so (unlike the
-- # feature cards above) there is no separate "measure" vs "draw" width that could disagree.
-- roles = (profile.get("roles") or [])[:6]
-- chip_font, chip_size, chip_pad_x, chip_gap = FONT["bold"], 7.6, 20.0, 7.0
-- chip_icon_w = 13.0 # icon glyph + gap before the label
-- chips_w = w - pad * 2 - panel_w - 14
-- chip_rows: list[list[tuple[str, float]]] = [[]]
-- cx = 0.0
-- for r in roles:
-- cw = pdfmetrics.stringWidth(r, chip_font, chip_size) + chip_pad_x + chip_icon_w
-- if cx > 0 and cx + cw > chips_w:
-- chip_rows.append([])
-- cx = 0.0
-- chip_rows[-1].append((r, cw))
-- cx += cw + chip_gap
-- chip_h = chip_size + 8
-- chips_total_h = len(chip_rows) * chip_h + max(0, len(chip_rows) - 1) * 4
--
-- banner_h = pad + name_size * 0.78 + 5 + tag_size * 0.78 + 12 + chips_total_h + pad
-- banner_h = max(banner_h, pad * 1.6 + panel_h)
++ 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_rows = wrap(contact_items, contact_font, contact_size, contact_icon_w, contact_pad_x, contact_gap)
++ contact_h = contact_size + 7
++ 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
if draw:
c.saveState()
@@@ -574,42 +604,14 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
c.setFillColor(HexColor("#dbe4ff"))
c.setFont(FONT["regular"], tag_size)
c.drawString(x + pad, ty - tag_size * 0.78, profile["tagline"])
-- ty -= tag_size * 0.78 + 12
++ ty -= tag_size * 0.78 + 10
if draw:
-- row_top = ty
-- for row in chip_rows:
-- cx2 = x + pad
-- for text, cw in row:
-- c.saveState()
-- c.setFillColor(BLUE_SOFT)
-- c.setFillAlpha(0.4)
-- c.roundRect(cx2, row_top - chip_h, cw, chip_h, chip_h / 2, stroke=0, fill=1)
-- c.restoreState()
-- icon(c, cx2 + chip_pad_x / 2, row_top - chip_h / 2 - chip_size * 0.36, role_icon(text), chip_size, white)
-- c.setFillColor(white)
-- c.setFont(chip_font, chip_size)
-- c.drawString(cx2 + chip_pad_x / 2 + chip_icon_w, row_top - chip_h / 2 - chip_size * 0.36, text)
-- cx2 += cw + chip_gap
-- row_top -= chip_h + 5
--
-- px = x + w - panel_w - pad
-- py_top = y_top - (banner_h - panel_h) / 2
-- if draw:
-- c.saveState()
-- c.setFillColor(white)
-- c.setFillAlpha(0.14)
-- c.roundRect(px, py_top - panel_h, panel_w, panel_h, 9, stroke=0, fill=1)
-- c.restoreState()
-- base = py_top - row_h * 0.72
-- for key, text, url in items:
-- icon_circle(c, px + 12, base + 2.4, 7.6, ICON[key], white, HexColor("#5578e8"), 6.8)
-- c.setFillColor(white)
-- c.setFont(FONT["regular"], 7.6)
-- c.drawString(px + 24, base, text)
-- if url:
-- c.linkURL(url, (px, base - 4, px + panel_w, base + 10), relative=0, thickness=0)
-- base -= row_h
++ 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"]])
return banner_h
@@@ -628,7 +630,7 @@ 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.0
w = (CONTENT_W - gap * (cols - 1)) / cols
-- pad = 9.0
++ pad = 8.5
icon_r = 11.0
inner_w = w - 2 * pad
title_w = inner_w - icon_r * 2 - 10
@@@ -682,9 +684,9 @@ def block_metrics(c, metrics: list[dict], st: Styles, y_top: float, draw: bool)
icon_r = 11.0
num_size = 15.0
pad = 10.0
-- label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=6.9, leading=8.2, textColor=MUTED)
++ label_st = ParagraphStyle("mlabel2", fontName=FONT["regular"], fontSize=7.1, leading=8.5, textColor=MUTED)
text_w = col_w - pad - icon_r * 2 - 8 - 6
-- label_paras = [para(esc(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)
h = max(icon_r * 2 + 12, num_size + label_h + 6) + 12
if draw:
@@@ -752,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
c.drawString(x + icon_col, base, role["company"])
cw = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)
if 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.
++ c.saveState()
++ c.setStrokeColor(BLUE)
++ c.setLineWidth(0.6)
++ c.line(x + icon_col, base - 1.4, x + icon_col + cw, base - 1.4)
++ c.restoreState()
c.linkURL(role["link"], (x + icon_col, base - 2, x + icon_col + cw, base + meta_size), relative=0, thickness=0)
if role.get("location"):
c.setFillColor(MUTED)
@@@ -813,7 +822,7 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
section_header(c, x, y, label, w, "")
y -= SECTION_PILL_H + SECTION_GAP
fn = block_role_compact if compact else block_role_full
-- sep = 3.0 if compact else 4.0
++ sep = 2.5 if compact else 4.0
for i, role in enumerate(roles):
if i:
if draw:
@@@ -834,7 +843,7 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
if draw:
section_header(c, MARGIN, y, "Selected work", CONTENT_W, ICON["link"])
y -= SECTION_PILL_H + SECTION_GAP
-- cols, gap, pad = 3, 8.0, 8.0
++ cols, gap, pad = 3, 7.0, 8.0
cw = (CONTENT_W - gap * (cols - 1)) / cols
icon_col = 20.0
link_badge = 16.0
@@@ -884,23 +893,55 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
def block_skills(c, skills: list[dict], st: Styles, y_top: float, draw: bool) -> float:
++ """A two-column matrix — each group still gets its own label line above its item list
++ (a clear break, not one run-on paragraph with the label inlined at the front), but two
++ columns roughly halve the section's height versus stacking every group in one column,
++ and read like an actual skills matrix instead of a long scroll of text."""
x, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PAD
y = y_top - CARD_PAD
if draw:
section_header(c, x, y, "Skills", w, "")
y -= SECTION_PILL_H + SECTION_GAP
-- row_gap = 2.6
-- for i, g in enumerate(skills):
-- items = [it["name"] for it in g.get("items", []) if it.get("name")]
-- label = f'<font name="{FONT["bold"]}" size="6.8" color="#22399c">{esc(g["group"].upper())}</font>'
-- p = para(f"{label}&nbsp;&nbsp;&nbsp;{esc(' · '.join(items))}", st.skill_items)
-- h = para_height(p, w)
-- if draw:
++ label_size = 7.2
++ label_gap = 2.0
++ group_gap = 6.0
++ col_gap = 20.0
++ col_w = (w - col_gap) / 2
++ mid = (len(skills) + 1) // 2
++ columns = [skills[:mid], skills[mid:]]
++
++ def col_height(groups: list[dict]) -> float:
++ ch = 0.0
++ for i, g in enumerate(groups):
if i:
-- hairline(c, x, y + row_gap / 2 + 0.5, x + w, BORDER, 0.4)
-- draw_para(c, p, x, y, w)
-- y -= h + row_gap
-- y -= CARD_PAD - row_gap
++ ch += group_gap
++ items = [it["name"] for it in g.get("items", []) if it.get("name")]
++ ch += label_size * 0.78 + label_gap
++ ch += para_height(para(esc(" · ".join(items)), st.skill_items), col_w)
++ return ch
++
++ heights = [col_height(g) for g in columns]
++ total_h = max(heights) if heights else 0.0
++
++ if draw:
++ for ci, groups in enumerate(columns):
++ cx = x + ci * (col_w + col_gap)
++ cy = y
++ for i, g in enumerate(groups):
++ if i:
++ cy -= group_gap
++ items = [it["name"] for it in g.get("items", []) if it.get("name")]
++ c.setFillColor(BLUE_DARK)
++ c.setFont(FONT["bold"], label_size)
++ c.drawString(cx, cy - label_size * 0.78, g["group"].upper())
++ cy -= label_size * 0.78 + label_gap
++ p = para(esc(" · ".join(items)), st.skill_items)
++ ph = para_height(p, col_w)
++ draw_para(c, p, cx, cy, col_w)
++ cy -= ph
++ if len(columns) > 1 and columns[1]:
++ vline(c, x + col_w + col_gap / 2, y - total_h, y, BORDER, 0.5)
++ y -= total_h + CARD_PAD
return y_top - y
@@@ -908,34 +949,45 @@ def block_education(c, profile: dict, st: Styles, y_top: float, draw: bool) -> f
x, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PAD
y = y_top - CARD_PAD
if draw:
-- section_header(c, x, y, "Education & links", w, "")
++ section_header(c, x, y, "Education", w, ICON["education"])
y -= SECTION_PILL_H + SECTION_GAP
p = para(esc(profile.get("education", "")), st.edu)
h = para_height(p, w)
if draw:
draw_para(c, p, x, y, w)
-- y -= h + 4
++ y -= h + CARD_PAD - 3
++ return y_top - y
++
++
++def block_links(c, profile: dict, st: Styles, y_top: float, draw: bool) -> float:
++ """Site, GitHub, LinkedIn and email as a row of their own — pulled out of Education,
++ which they had nothing to do with beyond both being "stuff at the bottom of the page"."""
++ x, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PAD
++ y = y_top - CARD_PAD
++ if draw:
++ section_header(c, x, y, "Links", w, ICON["link"])
++ y -= SECTION_PILL_H + SECTION_GAP
links = [
("site", profile.get("siteDisplay"), profile.get("site")),
("github", profile.get("githubDisplay"), profile.get("github")),
("linkedin", profile.get("linkedinDisplay"), profile.get("linkedin")),
("email", profile.get("emailDisplay"), f"mailto:{profile['email']}" if profile.get("email") else None),
]
-- size, isize = 8.1, 7.6
++ links = [l for l in links if l[1] and l[2]]
++ size = 8.6
base = y - size * 0.78
-- cx = x
++ n = max(len(links), 1)
++ col_w = w / n
if draw:
-- for key, text, url in links:
-- if not (text and url):
-- continue
-- icon_circle(c, cx + 6.5, base + 3.0, 7.0, ICON[key], BLUE, ICON_BG, 7.0)
++ for i, (key, text, url) in enumerate(links):
++ cx = x + i * col_w
++ icon_circle(c, cx + 7.5, base + 3.2, 8.0, ICON[key], BLUE, ICON_BG, 7.6)
c.setFillColor(BLUE)
c.setFont(FONT["regular"], size)
-- c.drawString(cx + 15, base, text)
-- tw = 15 + pdfmetrics.stringWidth(text, FONT["regular"], size)
-- c.linkURL(url, (cx, base - 2, cx + tw, base + size), relative=0, thickness=0)
-- cx += tw + 16
-- y = base - 2 - CARD_PAD
++ c.drawString(cx + 18, base, text)
++ tw = 18 + pdfmetrics.stringWidth(text, FONT["regular"], size)
++ c.linkURL(url, (cx, base - 3, cx + tw, base + size + 2), relative=0, thickness=0)
++ y = base - 3 - CARD_PAD + 5
return y_top - y
@@@ -973,7 +1025,11 @@ def content_hash(data: dict) -> str:
only changes when the content actually does, so a stale cached or downloaded copy is always
a *different* filename from the current one instead of the same name silently going stale."""
import hashlib
-- blob = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
++ # profile.resumePdf is *written* by this script (sync_resume_pdf_path) and names the previous
++ # build's hash, so it must not feed the next one — otherwise every run gets a new filename.
++ hashed = dict(data)
++ hashed["profile"] = {k: v for k, v in data["profile"].items() if k != "resumePdf"}
++ blob = json.dumps(hashed, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(blob).hexdigest()[:8]
@@@ -1132,8 +1188,12 @@ def build(out_path: Path) -> dict:
h, drawer = measured(block_education, profile, st, y)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
++ y -= h + 2
++ h, drawer = measured(block_links, profile, st, y)
++ card(c, MARGIN, y, CONTENT_W, h)
++ drawer()
y -= h
-- check(y, "page 2 education")
++ check(y, "page 2 links")
report["page2_bottom_gap"] = y - BOTTOM
footer(c, profile, 2, 2, updated)
c.showPage()
ddiff --git a/src/components/Hero.astro b/src/components/Hero.astro
iindex 880624b..9a7df55 100644
---- a/src/components/Hero.astro
++++ b/src/components/Hero.astro
@@@ -11,8 +11,9 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
<div class="container hero__grid">
<div class="hero__copy">
<p class="hero__status hero__in" style="--d:0ms">
-- <span class="live-dot" aria-hidden="true"></span>
-- <span>Now: contract engineer at <a href="https://powerchat.live" target="_blank" rel="noopener">PowerChat</a> · building <a href="#network">OpenVibe</a></span>
++ <span class="hero__status-seg hero__status-seg--now"><span class="live-dot" aria-hidden="true"></span>Now</span>
++ <span class="hero__status-seg">contract engineer at <a href="https://powerchat.live" target="_blank" rel="noopener">PowerChat</a></span>
++ <span class="hero__status-seg">building <a href="#network">OpenVibe</a></span>
</p>
<h1 class="hero__title" id="hero-title" aria-label={name}>
@@@ -28,12 +29,12 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
</h1>
<p class="hero__role" aria-live="polite">
-- <span class="hero__role-static">I'm a</span>
++ <span class="hero__role-static" id="rotator-article">I'm a</span>
<span class="hero__rotator" id="rotator" data-roles={JSON.stringify(profile.roles)}>{profile.roles[0]}</span>
</p>
<p class="hero__lead hero__in" style="--d:650ms">
-- {profile.headline} I build autonomous AI agents and automation that actually ship — time on an Amazon robotics floor taught me how real systems break, and I've been building the software and the agents to fix that ever since. Bring me a rough idea and I can turn it into a product, or a business: I founded and run the <strong><a href="https://openvibe.network" target="_blank" rel="noopener">OpenVibe network</a></strong> solo — seven services, 20+ domains — and I'm currently rebuilding <strong><a href="https://powerchat.live" target="_blank" rel="noopener">PowerChat</a></strong>'s developer platform and payments.
++ {profile.headline} I build autonomous AI agents and automation that actually ship — my time at Amazon, first keeping an automated robotics floor running and then as an ICQA data analyst chasing inventory discrepancies for SOX compliance, taught me how real systems break, and I've been building the software and the agents to fix that ever since. Bring me a rough idea and I can turn it into a product, or a business: I founded and run the <strong><a href="https://openvibe.network" target="_blank" rel="noopener">OpenVibe network</a></strong> solo — seven services, 20+ domains — and I'm currently rebuilding <strong><a href="https://powerchat.live" target="_blank" rel="noopener">PowerChat</a></strong>'s developer platform and payments.
</p>
<div class="hero__cta hero__in" style="--d:800ms">
@@@ -53,16 +54,18 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
</div>
<div class="hero__visual hero__in hero__in--scale" style="--d:300ms">
-- <div class="portrait" data-tilt>
++ <div class="portrait" data-tilt id="portrait">
++ <div class="portrait__halo" aria-hidden="true"></div>
<div class="portrait__ring portrait__ring--a" aria-hidden="true"></div>
<div class="portrait__ring portrait__ring--b" aria-hidden="true"></div>
<div class="portrait__orbit" aria-hidden="true"><span></span></div>
-- <div class="portrait__frame">
++ <div class="portrait__plinth" aria-hidden="true"><span class="portrait__grid"></span><span class="portrait__glint"></span><span class="portrait__scan"></span></div>
++ <canvas class="portrait__fx" id="portrait-fx" aria-hidden="true"></canvas>
++ <div class="portrait__shots">
<img src="/assets/img/me-cut1.png" alt="Portrait of Alex Frison" class="portrait__img is-on" id="portrait-a" width="889" height="1177" fetchpriority="high" />
<img src="/assets/img/me-cut2.png" alt="" class="portrait__img" id="portrait-b" width="829" height="1074" loading="lazy" />
-- <div class="portrait__scan" aria-hidden="true"></div>
</div>
-- <div class="portrait__tag portrait__tag--tl" aria-hidden="true"><i class="fa-solid fa-location-dot"></i> {profile.locationShort}</div>
++ <div class="portrait__tag portrait__tag--tl" aria-hidden="true"><i class="fa-solid fa-location-dot"></i> <span class="portrait__tag-l">{profile.locationShort}</span><span class="portrait__tag-s">{profile.locationShort.split(" / ")[0]}, {profile.locationShort.split(", ").pop()}</span></div>
<div class="portrait__tag portrait__tag--br" aria-hidden="true"><span class="live-dot"></span> Open to just about anything</div>
</div>
</div>
@@@ -94,12 +97,15 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
.hero__copy { min-width: 0; }
.hero__status {
-- display: inline-flex; align-items: center; gap: 10px; margin: 0 0 22px;
-- padding: 8px 14px 8px 12px; border-radius: 999px;
-- background: var(--surface); border: 1px solid var(--border);
-- font-family: var(--font-mono); font-size: .78rem; color: var(--muted); letter-spacing: .01em;
-- backdrop-filter: blur(10px);
++ display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 22px;
++ padding: 0; font-family: var(--font-mono); font-size: .76rem; color: var(--muted); letter-spacing: .01em;
}
++ .hero__status-seg {
++ display: inline-flex; align-items: center; gap: 9px; padding: 7px 13px; border-radius: 999px;
++ background: var(--surface); border: 1px solid var(--border); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
++ white-space: nowrap; line-height: 1.2;
++ }
++ .hero__status-seg--now { color: var(--fg); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: .68rem; border-color: rgba(var(--accent-3-rgb), .4); }
.hero__status a { color: var(--fg); }
.hero__title {
@@@ -144,24 +150,59 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
/* Portrait */
.hero__visual { display: grid; place-items: center; min-width: 0; }
-- .portrait { position: relative; width: min(400px, 78vw); aspect-ratio: 4 / 5; transition: transform .5s var(--ease); transform-style: preserve-3d; }
-- .portrait__frame {
-- position: absolute; inset: 0; border-radius: 34% 34% 28px 28px / 26% 26% 28px 28px;
-- overflow: hidden; background: var(--grad-soft); border: 1px solid var(--border-strong);
-- box-shadow: var(--shadow-card), inset 0 1px 0 rgba(255,255,255,.08);
++ /* The portrait is a cut-out PNG, so nothing here is a rectangle around it: a glass plinth
++ sits behind the lower body, the head rises above it, and a canvas (see script) draws a
++ gradient contour + glow that hugs the actual alpha edge of the photo, sends silhouette-
++ shaped ripples outward, and follows the site's accent live. */
++ .portrait { position: relative; width: min(400px, 78vw); aspect-ratio: 4 / 5; transition: transform .5s var(--ease); transform-style: preserve-3d; --px: 0; --py: 0; }
++ .portrait__halo {
++ position: absolute; inset: 8% -6% -8%; z-index: -1; border-radius: 50%; pointer-events: none;
++ background: radial-gradient(50% 42% at 50% 62%, rgba(var(--accent-rgb), .55), rgba(var(--violet-rgb, var(--accent-rgb)), .25) 45%, transparent 72%);
++ filter: blur(28px); animation: haloBreathe 9s ease-in-out infinite;
++ }
++ @keyframes haloBreathe { 50% { transform: scale(1.08) translateY(2%); opacity: .8; } }
++ .portrait__plinth {
++ position: absolute; left: 3%; right: 3%; top: 24%; bottom: 0; z-index: 0; overflow: hidden;
++ border-radius: 30px; background: var(--grad-soft); border: 1px solid var(--border-strong);
++ box-shadow: var(--shadow-card), inset 0 1px 0 rgba(255,255,255,.1), inset 0 -40px 60px -40px rgba(var(--accent-rgb), .35);
++ transform: translate(calc(var(--px) * -5px), calc(var(--py) * -4px)); transition: transform .4s var(--ease);
++ }
++ .portrait__plinth::before {
++ content: ""; position: absolute; inset: 0;
++ background: radial-gradient(75% 55% at 50% 100%, rgba(var(--accent-rgb), .38), transparent 70%);
++ }
++ .portrait__grid {
++ position: absolute; inset: 0; opacity: .35;
++ background-image: linear-gradient(rgba(var(--accent-rgb), .18) 1px, transparent 1px), linear-gradient(90deg, rgba(var(--accent-rgb), .18) 1px, transparent 1px);
++ background-size: 26px 26px; background-position: center bottom;
++ mask-image: radial-gradient(80% 90% at 50% 100%, #000 20%, transparent 80%); -webkit-mask-image: radial-gradient(80% 90% at 50% 100%, #000 20%, transparent 80%);
++ }
++ .portrait__glint {
++ position: absolute; top: 0; bottom: 0; width: 40%; left: -60%;
++ background: linear-gradient(100deg, transparent 0%, rgba(255,255,255,.10) 45%, rgba(255,255,255,.18) 50%, rgba(255,255,255,.10) 55%, transparent 100%);
++ transform: skewX(-12deg); animation: glint 9s var(--ease-io) infinite;
}
-- .portrait__frame::before {
-- content: ""; position: absolute; inset: 0; z-index: 0;
-- background: radial-gradient(70% 60% at 50% 100%, rgba(var(--accent-rgb), .35), transparent 70%);
++ @keyframes glint { 0%, 55% { left: -60%; } 85%, 100% { left: 130%; } }
++ .portrait__fx { position: absolute; inset: -9%; width: 118%; height: 118%; z-index: 1; pointer-events: none;
++ mask-image: linear-gradient(to bottom, #000 80%, transparent 96%); -webkit-mask-image: linear-gradient(to bottom, #000 80%, transparent 96%); }
++ .portrait__shots {
++ position: absolute; inset: 0; z-index: 2; pointer-events: none;
++ transform: translate(calc(var(--px) * 7px), calc(var(--py) * 5px)); transition: transform .4s var(--ease);
++ mask-image: linear-gradient(to bottom, #000 78%, transparent 99%); -webkit-mask-image: linear-gradient(to bottom, #000 78%, transparent 99%);
++ }
++ .portrait__img {
++ position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: bottom center;
++ opacity: 0; transition: opacity 1.2s var(--ease);
++ filter: drop-shadow(0 22px 26px rgba(0,0,0,.45)) drop-shadow(0 0 1px rgba(var(--accent-rgb), .35));
}
-- .portrait__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; opacity: 0; transition: opacity 1s var(--ease); z-index: 1; }
.portrait__img.is-on { opacity: 1; }
.portrait__scan {
-- position: absolute; left: -10%; right: -10%; top: -20%; height: 18%; z-index: 2; pointer-events: none;
++ position: absolute; left: 0; right: 0; top: -20%; height: 16%; pointer-events: none;
background: linear-gradient(180deg, transparent, rgba(var(--accent-3-rgb), .16), transparent);
-- animation: scan 7s var(--ease-io) infinite;
++ animation: scan 8s var(--ease-io) infinite;
}
-- @keyframes scan { 0%, 20% { top: -20%; } 60%, 100% { top: 110%; } }
++ .portrait__tag-s { display: none; }
++ @keyframes scan { 0%, 25% { top: -20%; } 65%, 100% { top: 110%; } }
.portrait__ring { position: absolute; inset: -8%; border-radius: 50%; border: 1px solid rgba(var(--accent-rgb), .25); z-index: -1; }
.portrait__ring--a { animation: spin 26s linear infinite; border-style: dashed; }
.portrait__ring--b { inset: -16%; border-color: rgba(var(--accent-2-rgb), .18); animation: spin 40s linear infinite reverse; }
@@@ -178,7 +219,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
animation: floaty 7s ease-in-out infinite;
}
.portrait__tag i { color: var(--accent-2); }
-- .portrait__tag--tl { top: 6%; left: -10%; }
++ .portrait__tag--tl { top: 30%; left: -14%; }
.portrait__tag--br { bottom: 7%; right: -8%; animation-delay: -3.5s; }
@keyframes floaty { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
@@@ -198,9 +239,11 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
.hero { min-height: auto; padding-top: calc(var(--nav-h) + 30px); }
.hero__grid { grid-template-columns: 1fr; }
.hero__visual { order: -1; }
-- .portrait { width: min(280px, 70vw); }
-- .portrait__tag--tl { left: -4%; }
-- .portrait__tag--br { right: -4%; }
++ .portrait { width: min(290px, 72vw); margin-top: 10px; }
++ /* On narrow screens the tags hang off the plinth's edges instead of sitting over the face. */
++ .portrait__tag--tl { top: 25%; left: -6%; font-size: .66rem; padding: 6px 10px; }
++ .portrait__tag-l { display: none; } .portrait__tag-s { display: inline; }
++ .portrait__tag--br { bottom: 4%; right: -6%; font-size: .66rem; padding: 6px 10px; }
.hero__scroll { display: none; }
.hero__title { font-size: clamp(3rem, 15vw, 5rem); }
}
@@@ -267,16 +310,144 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
};
step();
});
++ const article = document.getElementById('rotator-article');
++ const setArticle = (role: string) => { if (article) article.textContent = /^[aeiou]/i.test(role) ? "I'm an" : "I'm a"; };
++ setArticle(roles[0] || '');
setInterval(async () => {
if (busy || document.hidden) return;
busy = true; i = (i + 1) % roles.length;
++ setArticle(roles[i]);
await scramble(roles[i]); busy = false;
}, 2800);
}
-- // ---- Portrait crossfade ---------------------------------------------------
-- const a = document.getElementById('portrait-a'), b = document.getElementById('portrait-b');
-- if (a && b) { let on = true; setInterval(() => { on = !on; a.classList.toggle('is-on', on); b.classList.toggle('is-on', !on); }, 7000); }
++ // ---- Portrait: crossfade + edge-hugging contour FX -------------------------
++ // The contour is computed from the PNG's real alpha: the silhouette is stamped around a small
++ // circle to dilate it, the original is punched back out, and what's left is a ring that hugs
++ // every edge of the cut-out. Each frame that ring is filled with a slowly turning conic
++ // gradient (accent → violet → ember → mint, plus one bright "comet" sector) and a blurred copy
++ // becomes the glow. Silhouette-shaped ripples expand outward every few seconds and on hover.
++ const a = document.getElementById('portrait-a') as HTMLImageElement | null;
++ const b = document.getElementById('portrait-b') as HTMLImageElement | null;
++ const fx = document.getElementById('portrait-fx') as HTMLCanvasElement | null;
++ const portrait = document.getElementById('portrait') as HTMLElement | null;
++ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
++ let onA = true;
++ if (a && b) setInterval(() => { onA = !onA; a.classList.toggle('is-on', onA); b.classList.toggle('is-on', !onA); }, 8000);
++
++ if (fx && portrait && a && b) {
++ const ctx = fx.getContext('2d')!;
++ type Ring = { sharp: HTMLCanvasElement; glow: HTMLCanvasElement };
++ let rings: (Ring | null)[] = [null, null];
++ let W = 0, H = 0, weightA = 1;
++ const dpr = Math.min(devicePixelRatio || 1, 1.5);
++ const tmp = document.createElement('canvas'), tctx = tmp.getContext('2d')!;
++ const echoes: number[] = [];
++ let colors = { accent: '#4f7cff', violet: '#8b5cf6', ember: '#f97316', mint: '#22c55e' };
++ const readColors = () => {
++ const cs = getComputedStyle(document.documentElement);
++ const v = (n: string, fb: string) => (cs.getPropertyValue(n).trim() || fb);
++ colors = { accent: v('--accent', colors.accent), violet: v('--violet', colors.violet), ember: v('--accent-2', colors.ember), mint: v('--accent-3', colors.mint) };
++ };
++ readColors();
++ new MutationObserver(readColors).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme'] });
++
++ // Where the <img> (object-fit: contain, bottom center) lands inside the portrait box, in canvas px.
++ const imgRect = (img: HTMLImageElement) => {
++ const pr = portrait.getBoundingClientRect(), fr = fx.getBoundingClientRect();
++ const bw = pr.width, bh = pr.height;
++ const sc = Math.min(bw / img.naturalWidth, bh / img.naturalHeight);
++ const dw = img.naturalWidth * sc, dh = img.naturalHeight * sc;
++ const x = (pr.left - fr.left) + (bw - dw) / 2, y = (pr.top - fr.top) + (bh - dh);
++ return { x: x * dpr, y: y * dpr, w: dw * dpr, h: dh * dpr };
++ };
++ const buildRing = (img: HTMLImageElement): Ring => {
++ const r = imgRect(img);
++ const rad = Math.max(2, W * 0.011);
++ const sharp = document.createElement('canvas'); sharp.width = W; sharp.height = H;
++ const sctx = sharp.getContext('2d')!;
++ for (let k = 0; k < 24; k++) {
++ const ang = (k / 24) * Math.PI * 2;
++ sctx.drawImage(img, r.x + Math.cos(ang) * rad, r.y + Math.sin(ang) * rad, r.w, r.h);
++ }
++ sctx.globalCompositeOperation = 'destination-out';
++ sctx.drawImage(img, r.x, r.y, r.w, r.h);
++ const glow = document.createElement('canvas'); glow.width = W; glow.height = H;
++ const gctx = glow.getContext('2d')!;
++ gctx.filter = `blur(${Math.max(6, W * 0.028)}px)`;
++ gctx.drawImage(sharp, 0, 0);
++ gctx.filter = 'none';
++ gctx.drawImage(sharp, 0, 0);
++ return { sharp, glow };
++ };
++ const rebuild = () => {
++ const fr = fx.getBoundingClientRect();
++ W = Math.round(fr.width * dpr); H = Math.round(fr.height * dpr);
++ if (!W || !H) return;
++ fx.width = W; fx.height = H; tmp.width = W; tmp.height = H;
++ rings = [a, b].map((img) => (img.complete && img.naturalWidth ? buildRing(img) : null));
++ };
++ const fillRing = (ring: HTMLCanvasElement, angle: number, alpha: number, cx: number, cy: number) => {
++ tctx.globalCompositeOperation = 'source-over';
++ tctx.clearRect(0, 0, W, H);
++ const g = tctx.createConicGradient(angle, cx, cy);
++ g.addColorStop(0, colors.accent); g.addColorStop(.22, colors.violet); g.addColorStop(.45, colors.ember);
++ g.addColorStop(.62, '#ffffff'); g.addColorStop(.66, colors.mint); g.addColorStop(.85, colors.accent); g.addColorStop(1, colors.accent);
++ tctx.fillStyle = g; tctx.fillRect(0, 0, W, H);
++ tctx.globalCompositeOperation = 'destination-in';
++ tctx.drawImage(ring, 0, 0);
++ ctx.globalAlpha = alpha;
++ ctx.drawImage(tmp, 0, 0);
++ ctx.globalAlpha = 1;
++ };
++ let last = 0, nextEcho = performance.now() + 2500, visible = true, raf = 0;
++ const frame = (t: number) => {
++ raf = requestAnimationFrame(frame);
++ if (!visible || document.hidden || !W) return;
++ if (t - last < 1000 / 45) return; // ~45fps is plenty for a slow sweep
++ last = t;
++ weightA += ((onA ? 1 : 0) - weightA) * 0.06;
++ ctx.clearRect(0, 0, W, H);
++ const cx = W / 2, cy = H * 0.55, ang = (t / 9000) * Math.PI * 2;
++ if (t > nextEcho) { echoes.push(t); nextEcho = t + 6500 + Math.random() * 2500; }
++ rings.forEach((ring, i) => {
++ if (!ring) return;
++ const w = i === 0 ? weightA : 1 - weightA;
++ if (w < 0.02) return;
++ fillRing(ring.glow, ang, 0.55 * w, cx, cy);
++ fillRing(ring.sharp, ang, 0.95 * w, cx, cy);
++ for (let e = echoes.length - 1; e >= 0; e--) {
++ const p = (t - echoes[e]) / 1600;
++ if (p >= 1) { echoes.splice(e, 1); continue; }
++ const sc = 1 + p * 0.16, k = (1 - p) * (1 - p) * 0.5 * w;
++ ctx.save(); ctx.globalAlpha = k; ctx.translate(cx, cy * 1.35); ctx.scale(sc, sc); ctx.translate(-cx, -cy * 1.35);
++ ctx.drawImage(tmp, 0, 0); ctx.restore();
++ }
++ });
++ };
++ const start = async () => {
++ await Promise.all([a, b].map((img) => (img.complete ? Promise.resolve() : new Promise<void>((res) => { img.onload = () => res(); img.onerror = () => res(); }))));
++ rebuild();
++ if (reduced) { frame(performance.now()); cancelAnimationFrame(raf); return; }
++ raf = requestAnimationFrame(frame);
++ };
++ start();
++ let rt = 0;
++ addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(rebuild, 150); });
++ new IntersectionObserver((en) => { visible = en[0]?.isIntersecting ?? true; }).observe(fx);
++ portrait.addEventListener('pointerenter', () => { echoes.push(performance.now()); nextEcho = performance.now() + 7000; });
++ portrait.addEventListener('pointerdown', () => echoes.push(performance.now()));
++
++ // Depth: photo and plinth drift in opposite directions under the pointer (desktop only).
++ if (matchMedia('(pointer: fine)').matches) {
++ portrait.addEventListener('pointermove', (e) => {
++ const r = portrait.getBoundingClientRect();
++ portrait.style.setProperty('--px', String(((e.clientX - r.left) / r.width - 0.5) * 2));
++ portrait.style.setProperty('--py', String(((e.clientY - r.top) / r.height - 0.5) * 2));
++ });
++ portrait.addEventListener('pointerleave', () => { portrait.style.setProperty('--px', '0'); portrait.style.setProperty('--py', '0'); });
++ }
++ }
</script>
<style is:global>
ddiff --git a/src/components/Nav.astro b/src/components/Nav.astro
iindex 8c93996..023b7e0 100644
---- a/src/components/Nav.astro
++++ b/src/components/Nav.astro
@@@ -73,12 +73,22 @@ const path = Astro.url.pathname;
</a>
</li>
<li style={`--i:${links.length + 2}`}>
-- <button type="button" class="nav__drawer-link" data-theme-toggle>
-- <span class="nav__drawer-ico"><i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i></span>
-- <span class="nav__drawer-text">Light / dark theme</span>
++ <button type="button" class="nav__drawer-link" data-open-themes>
++ <span class="nav__drawer-ico"><i class="fa-solid fa-palette" aria-hidden="true"></i></span>
++ <span class="nav__drawer-text">Themes</span>
++ <span class="nav__drawer-swatch" id="nav-drawer-swatch" aria-hidden="true"></span>
<i class="fa-solid fa-chevron-right nav__drawer-go" aria-hidden="true"></i>
</button>
</li>
++ <li style={`--i:${links.length + 3}`} data-keep-open>
++ <div class="nav__drawer-link nav__drawer-link--row">
++ <span class="nav__drawer-ico"><i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i></span>
++ <span class="nav__drawer-text">Dark mode</span>
++ <button type="button" class="nav__switch" role="switch" aria-checked="true" data-theme-toggle aria-label="Toggle dark mode">
++ <span class="nav__switch-knob"><i class="fa-solid fa-moon" aria-hidden="true"></i><i class="fa-solid fa-sun" aria-hidden="true"></i></span>
++ </button>
++ </div>
++ </li>
</ul>
</div>
</header>
@@@ -177,7 +187,7 @@ const path = Astro.url.pathname;
backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
max-height: 0; transition: max-height .45s var(--ease), border-color .3s;
}
-- .nav__drawer.is-open { max-height: 620px; border-color: var(--border); }
++ .nav__drawer.is-open { max-height: 700px; border-color: var(--border); }
.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; }
@@@ -200,6 +210,27 @@ const path = Astro.url.pathname;
.nav__drawer-go { flex: none; font-size: .72rem; color: var(--dim); opacity: 0; transform: translateX(-3px); transition: opacity .2s var(--ease), transform .2s var(--ease); }
.nav__drawer-link:hover .nav__drawer-go { opacity: 1; transform: none; }
++ .nav__drawer-link--row { cursor: default; }
++ .nav__drawer-link--row:hover { transform: none; }
++ .nav__drawer-swatch { width: 14px; height: 14px; border-radius: 50%; flex: none; background: var(--accent); box-shadow: 0 0 0 2px var(--surface-solid), 0 0 0 3px var(--border-strong), 0 0 12px rgba(var(--accent-rgb), .6); margin-right: 4px; }
++ .nav__switch {
++ position: relative; flex: none; width: 46px; height: 26px; border-radius: 999px; padding: 0; cursor: pointer;
++ background: var(--surface-2); border: 1px solid var(--border-strong);
++ transition: background .35s var(--ease), border-color .35s var(--ease), box-shadow .35s var(--ease);
++ }
++ .nav__switch[aria-checked="true"] { background: var(--grad); border-color: transparent; box-shadow: 0 6px 16px -8px rgba(var(--accent-rgb), .8); }
++ .nav__switch-knob {
++ position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff;
++ display: grid; place-items: center; color: var(--accent); font-size: .68rem;
++ transition: transform .4s cubic-bezier(.34,1.56,.64,1), color .35s;
++ box-shadow: 0 2px 6px rgba(0,0,0,.3);
++ }
++ .nav__switch-knob i { grid-area: 1 / 1; transition: opacity .3s var(--ease), transform .4s var(--ease); }
++ .nav__switch-knob .fa-sun { opacity: 0; transform: rotate(-90deg) scale(.6); color: #f59e0b; }
++ .nav__switch[aria-checked="true"] .nav__switch-knob { transform: translateX(20px); }
++ .nav__switch[aria-checked="false"] .nav__switch-knob .fa-moon { opacity: 0; transform: rotate(90deg) scale(.6); }
++ .nav__switch[aria-checked="false"] .nav__switch-knob .fa-sun { opacity: 1; transform: none; }
++ .nav__switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav__drawer-link--cta { background: var(--grad); color: #fff; }
.nav__drawer-link--cta:hover { background: var(--grad); filter: brightness(1.08); transform: translateX(2px); }
.nav__drawer-ico--cta { background: rgba(255,255,255,.2); color: #fff; border-color: rgba(255,255,255,.3); }
@@@ -225,7 +256,20 @@ const path = Astro.url.pathname;
drawer.setAttribute('aria-hidden', String(!open));
};
burger.addEventListener('click', () => setOpen(!drawer.classList.contains('is-open')));
-- drawer.querySelectorAll('a, button').forEach((el) => el.addEventListener('click', () => setOpen(false)));
++ // Every drawer link/button closes the drawer, except rows that opt out with data-keep-open —
++ // the dark-mode switch lives in one, so you can flip it back and forth without the menu
++ // collapsing on every tap.
++ drawer.querySelectorAll<HTMLElement>('a, button').forEach((el) => {
++ if (el.closest('[data-keep-open]')) return;
++ el.addEventListener('click', () => setOpen(false));
++ });
++ drawer.querySelector('[data-open-themes]')?.addEventListener('click', () => (window as any).__ovOpenThemePicker?.());
++
++ // The drawer's switch mirrors whatever the document's theme is, however it was changed.
++ const themeSwitch = drawer.querySelector<HTMLButtonElement>('.nav__switch');
++ const syncSwitch = () => themeSwitch?.setAttribute('aria-checked', String(document.documentElement.dataset.theme !== 'light'));
++ syncSwitch();
++ new MutationObserver(syncSwitch).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Any open panel — this drawer or another site dropdown that opts in with data-dismiss-outside
// — closes when a click lands outside both its trigger and its own box, so nothing lingers
@@@ -240,7 +284,12 @@ const path = Astro.url.pathname;
// A little dot on the palette icon confirms the chosen accent took, without opening anything.
const dot = document.getElementById('nav-color-dot');
-- const syncDot = () => { if (dot) dot.style.background = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(); };
++ const drawerSwatch = document.getElementById('nav-drawer-swatch');
++ const syncDot = () => {
++ const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
++ if (dot) dot.style.background = accent;
++ if (drawerSwatch) drawerSwatch.style.background = accent;
++ };
syncDot();
new MutationObserver(syncDot).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
ddiff --git a/src/components/Now.astro b/src/components/Now.astro
iindex 9bc9766..955e05b 100644
---- a/src/components/Now.astro
++++ b/src/components/Now.astro
@@@ -17,15 +17,20 @@ const statusAlsoParts = linkify(profile.status.also, LINK_TERMS);
<span class="sec-num" aria-hidden="true">01</span>
<div>
<div class="sec-index">Profile</div>
-- <h2 class="sec-title" id="now-title">Full-stack, then <em>full-ops</em>.</h2>
++ <h2 class="sec-title" id="now-title">I build it, ship it and <em>keep it running</em>.</h2>
</div>
-- <p class="sec-lead">{profile.tagline} I do not hand things off: I design them, ship them, deploy them and answer for them in production.</p>
++ <p class="sec-lead">One person who can take an idea from a whiteboard to paying customers, and still be the one answering for it a year later.</p>
</div>
<div class="now__grid">
<article class="card spot now__pitch" data-reveal>
<p class="now__text">{pitchParts.map((p) => typeof p === "string" ? p : <a href={p.href} target={p.external ? "_blank" : undefined} rel={p.external ? "noopener" : undefined}>{p.text}</a>)}</p>
-- <p class="now__text">Ten years of running my own company taught me to care about the whole loop: the SQL, the socket, the nginx config, the invoice and the person on the other end. I like small teams, hard problems and code that stays up.</p>
++ <ul class="now__hl" aria-label="What I bring">
++ {profile.highlights.map((h) => (
++ <li><i class={h.icon} aria-hidden="true"></i><div><strong>{h.title}</strong><span>{h.text}</span></div></li>
++ ))}
++ </ul>
++ <p class="now__text">Ten years of running my own company taught me to care about the whole loop: the code, the servers, the invoice and the person on the other end. I like small teams, hard problems and software that stays up.</p>
<div class="now__cta">
<a href="#experience" class="btn btn--ghost">Experience <span class="btn__arrow">→</span></a>
<a href="/portfolio" class="btn btn--ghost">All projects <span class="btn__arrow">→</span></a>
@@@ -61,6 +66,12 @@ const statusAlsoParts = linkify(profile.status.also, LINK_TERMS);
.now__pitch { padding: clamp(24px, 3.5vw, 40px); }
.now__text { font-size: 1.08rem; line-height: 1.8; color: var(--muted); }
.now__text:first-child { color: var(--fg); font-size: clamp(1.1rem, 1.6vw, 1.3rem); font-weight: 500; line-height: 1.6; letter-spacing: -.005em; }
++ .now__hl { list-style: none; margin: 22px 0 18px; padding: 0; display: grid; gap: 10px; }
++ .now__hl li { display: flex; gap: 14px; align-items: flex-start; padding: 12px 14px; border-radius: 14px; background: var(--surface); border: 1px solid var(--border); transition: border-color .3s var(--ease), transform .3s var(--ease); }
++ .now__hl li:hover { border-color: rgba(var(--accent-rgb), .5); transform: translateX(4px); }
++ .now__hl i { flex: none; width: 36px; height: 36px; border-radius: 10px; display: inline-grid; place-items: center; background: rgba(var(--accent-rgb), .12); color: var(--accent); font-size: .95rem; }
++ .now__hl strong { display: block; color: var(--fg); font-size: .98rem; margin-bottom: 2px; }
++ .now__hl span { color: var(--muted); font-size: .9rem; line-height: 1.55; }
.now__cta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.now__side { display: grid; gap: 18px; }
.now__status-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
ddiff --git a/src/components/ThemePicker.astro b/src/components/ThemePicker.astro
iindex b00444d..d366043 100644
---- a/src/components/ThemePicker.astro
++++ b/src/components/ThemePicker.astro
@@@ -101,18 +101,22 @@ 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, animate = false) => {
++ (window as any).__ovApplyAccent?.(hex, animate);
(window as any).__ovApplyResumeLinks?.(hex);
};
const MODE_KEY = 'afnet-theme';
const setMode = (mode: 'dark' | 'light') => {
-- document.documentElement.dataset.theme = mode;
-- try { localStorage.setItem(MODE_KEY, mode); } catch { /* */ }
-- document.querySelector('meta[name="theme-color"]')?.setAttribute('content', mode === 'light' ? '#f4f6fb' : '#07090f');
++ const setter = (window as any).__ovSetTheme;
++ if (setter) setter(mode);
++ else {
++ document.documentElement.dataset.theme = mode;
++ try { localStorage.setItem(MODE_KEY, mode); } catch { /* */ }
++ }
syncModeButtons();
};
++ document.addEventListener('ov:theme', () => syncModeButtons());
const syncModeButtons = () => {
const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
modal.querySelectorAll<HTMLButtonElement>('.tp-mode-btn').forEach((b) => b.classList.toggle('is-active', b.dataset.mode === current));
@@@ -138,7 +142,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
setTimeout(() => { modal.hidden = true; }, 350);
};
const choose = (hex: string) => {
-- applyAccent(hex);
++ applyAccent(hex, true);
try { localStorage.setItem(COLOR_KEY, hex); localStorage.setItem(ASKED_KEY, '1'); } catch { /* */ }
close();
};
ddiff --git a/src/content/experience/02-openvibe.json b/src/content/experience/02-openvibe.json
iindex d5560b3..4e34904 100644
---- a/src/content/experience/02-openvibe.json
++++ b/src/content/experience/02-openvibe.json
@@@ -9,11 +9,11 @@
"featured": true,
"current": true,
"order": 2,
-- "summary": "An open, community-run live-streaming and tools network. Started in 2022 as a streaming site, re-architected in 2026 into seven services behind one identity provider. I design, build, deploy and operate all of it.",
++ "summary": "An open, community-run live-streaming network I own end to end. Started in 2022 as a single streaming site; by 2026 it had grown into seven connected apps sharing one login, and I'm still the only engineer on it.",
"bullets": [
-- "Architected the network as seven Node services with formal inter-service contracts: OpenVibe.Live (streaming), .Network (OIDC/OAuth2 identity, OpenCoins wallet, notifications, themes), .Media (multi-tenant media API), .Tools (169-tool suite), .Games (Scraplandia), .Community (pastes) and .Sites (13 front-page domains). About 363k lines, 900+ HTTP routes, one operator.",
-- "Built the streaming core: RTMP, WHIP and WebRTC-SFU ingest (mediasoup, werift), ffmpeg restream to Twitch, YouTube, Kick and SRT, VOD and clip pipelines with local → Backblaze B2 → Cloudflare R2 tiering, chat and moderation, a three-currency economy, PowerChat and RobotStreamer integrations, local whisper.cpp captions with LLM translation in about 21 languages, and AI clip and moment jobs on a provider-agnostic, budget-metered LLM client.",
-- "Ran it like a product team: systemd socket-activated zero-downtime deploys (measured 90 of 90 requests served through a restart), a deploy script that classifies each change and supports rollback, a headless-Chrome performance harness with interleaved A/B runs (home page CLS 0.83 → 0.03, warm FCP 1066 → 550 ms), and a dry-run migration suite plus cutover runbook that moved users, coins and 22 concurrent players with no downtime."
++ "Designed and built an entire network of seven apps that work together under one account: live streaming, sign-in and a virtual-currency wallet, media storage, a 169-tool toolbox, a browser game and a community hub. About 363,000 lines of code and 900+ web endpoints — all built, deployed and kept running by me alone.",
++ "Built the live-streaming engine from the ground up: anyone can broadcast from a browser or an app, and it automatically re-broadcasts to Twitch, YouTube, Kick and more at the same time, saves the recording and auto-generates short clips. It also produces live captions and translates them into about 21 languages in real time, and uses AI to spot and label the best moments in every stream.",
++ "Ran it like a real product: I built a deploy pipeline that updates the live site with zero downtime viewers ever notice, cut the homepage's load time roughly in half and eliminated the jarring layout jumps it used to have, and — when it came time to move everything to new infrastructure — moved every user, every account balance and every active player over with zero downtime and zero data loss."
],
"stack": [
"Node.js",
ddiff --git a/src/content/experience/06-gameserverstats.json b/src/content/experience/06-gameserverstats.json
iindex 1e0c171..6ed4bdf 100644
---- a/src/content/experience/06-gameserverstats.json
++++ b/src/content/experience/06-gameserverstats.json
@@@ -1,5 +1,5 @@
{
-- "company": "GameServerStats, LLC",
++ "company": "Own company, LLC",
"role": "Founder · Developer · Systems Admin",
"start": "2015",
"end": "Present",
ddiff --git a/src/data/profile.json b/src/data/profile.json
iindex 44ce401..7870d2c 100644
---- a/src/data/profile.json
++++ b/src/data/profile.json
@@@ -1,8 +1,15 @@
{
"name": "Alex Frison",
-- "tagline": "Software engineer, platform architect and founder of the OpenVibe network.",
++ "tagline": "Software engineer, data analyst and founder of OpenVibe.",
"headline": "I build the whole stack, then run it.",
-- "roles": ["Software Engineer", "AI Agent Builder", "Founder of OpenVibe", "Automation Engineer", "Platform Architect", "Idea-to-Business Builder"],
++ "highlights": [
++ { "icon": "fa-solid fa-layer-group", "title": "Ships the whole product", "text": "Design, code, infrastructure, deployment and support, with no hand-off in between." },
++ { "icon": "fa-solid fa-credit-card", "title": "Moves money safely", "text": "Rebuilt PowerChat's payments across five processors, with ledgers that make double-charging impossible." },
++ { "icon": "fa-solid fa-robot", "title": "Builds AI that does real work", "text": "Autonomous agents that researched and wrote compliance reports Amazon analysts used every day." },
++ { "icon": "fa-solid fa-server", "title": "Runs it in production", "text": "25+ sites and services with zero-downtime deploys, operated by one person." },
++ { "icon": "fa-solid fa-handshake", "title": "Understands the business side", "text": "Ten years running my own company and three years leading a retail sales team." }
++ ],
++ "roles": ["Software Engineer", "Full-Stack Developer", "AI Agent Builder", "Data Analyst", "Business Owner", "Automation Engineer", "Platform Architect", "Product Builder", "Team Leader", "Problem Solver"],
"location": "Arlington · Seattle · Pacific Northwest",
"locationShort": "Seattle / Arlington, WA",
"email": "[email protected]",
@@@ -15,30 +22,30 @@
"githubDisplay": "github.com/OpenVibers",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedinDisplay": "linkedin.com/in/fris",
-- "resumePdf": "/Alex_Frison_Resume_Sep_2026_4a270599.pdf",
++ "resumePdf": "/Alex_Frison_Resume_Sep_2026_fde01332.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.",
"also": "Founder and sole engineer of OpenVibe: seven services, 20+ domains, one operator."
},
-- "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": "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" }
],
-- "foundedOrg": "GameServerStats, LLC",
++ "foundedOrg": "Own company, LLC",
"lastRole": "PowerChat — Software Engineer (Contract)",
"metrics": [
-- { "num": 7, "suffix": "", "label": "OpenVibe services designed, built and operated solo", "icon": "fa-solid fa-cubes-stacked" },
-- { "num": 900, "suffix": "+", "label": "HTTP routes across the OpenVibe network", "icon": "fa-solid fa-route" },
-- { "num": 363, "suffix": "k", "label": "Lines of code in the OpenVibe repos", "icon": "fa-solid fa-code" },
-- { "num": 169, "suffix": "", "label": "Tools catalogued on OpenVibe.Tools", "icon": "fa-solid fa-toolbox" },
-- { "num": 5, "suffix": "", "label": "Payment processors unified at PowerChat", "icon": "fa-solid fa-credit-card" },
-- { "num": 1000, "suffix": "+", "label": "SOX-compliance IRDRs researched at Amazon", "icon": "fa-solid fa-magnifying-glass-chart" },
-- { "num": 200, "suffix": "k+", "label": "Live game servers tracked by GameServerStats", "icon": "fa-solid fa-tower-broadcast" },
-- { "num": 10, "suffix": "+", "label": "Years running my own company", "icon": "fa-solid fa-building-flag" }
++ { "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": 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": 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" }
],
"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" },
ddiff --git a/src/data/theme-pdf-manifest.json b/src/data/theme-pdf-manifest.json
iindex 44c1f64..e10535c 100644
---- a/src/data/theme-pdf-manifest.json
++++ b/src/data/theme-pdf-manifest.json
@@@ -1,15 +1,15 @@
{
-- "amber": "/Alex_Frison_Resume_Themed_amber_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"
++ "amber": "/Alex_Frison_Resume_Themed_amber_fde01332.pdf",
++ "blue": "/Alex_Frison_Resume_Sep_2026_fde01332.pdf",
++ "cyan": "/Alex_Frison_Resume_Themed_cyan_fde01332.pdf",
++ "default": "/Alex_Frison_Resume_Sep_2026_fde01332.pdf",
++ "green": "/Alex_Frison_Resume_Themed_green_fde01332.pdf",
++ "indigo": "/Alex_Frison_Resume_Themed_indigo_fde01332.pdf",
++ "lime": "/Alex_Frison_Resume_Themed_lime_fde01332.pdf",
++ "orange": "/Alex_Frison_Resume_Themed_orange_fde01332.pdf",
++ "pink": "/Alex_Frison_Resume_Themed_pink_fde01332.pdf",
++ "red": "/Alex_Frison_Resume_Themed_red_fde01332.pdf",
++ "teal": "/Alex_Frison_Resume_Themed_teal_fde01332.pdf",
++ "violet": "/Alex_Frison_Resume_Themed_violet_fde01332.pdf",
++ "yellow": "/Alex_Frison_Resume_Themed_yellow_fde01332.pdf"
}
ddiff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
iindex 4a30041..bcdd36d 100644
---- a/src/layouts/Base.astro
++++ b/src/layouts/Base.astro
@@@ -121,8 +121,23 @@ const imageUrl = new URL(image, site).toString();
hex = hex.replace('#', '');
return parseInt(hex.substr(0, 2), 16) + ', ' + parseInt(hex.substr(2, 2), 16) + ', ' + parseInt(hex.substr(4, 2), 16);
}
-- function applyAccent(hex) {
++ // Any palette change — accent or light/dark — briefly puts the document in a state
++ // where every color-ish property transitions, so the swap reads as a smooth wash across
++ // the page instead of a hard cut. The class is removed after the transition window.
++ var themingTimer = 0;
++ function withThemeTransition(fn) {
++ try {
++ if (matchMedia('(prefers-reduced-motion: reduce)').matches) { fn(); return; }
++ } catch (e) {}
++ d.classList.add('is-theming');
++ fn();
++ clearTimeout(themingTimer);
++ themingTimer = setTimeout(function () { d.classList.remove('is-theming'); }, 720);
++ }
++ window.__ovWithThemeTransition = withThemeTransition;
++ function applyAccent(hex, animate) {
if (!/^#[0-9a-f]{6}$/i.test(hex)) return;
++ if (animate) { withThemeTransition(function () { applyAccent(hex, false); }); return; }
var hsl = hexToHsl(hex), h = hsl[0];
// Clamp saturation/lightness so a very dark or very pale pick still reads as an accent
// (not black text or an invisible near-white) in both themes.
@@@ -194,8 +209,10 @@ const imageUrl = new URL(image, site).toString();
lenisScript.onload = () => {
// @ts-ignore
const L = window.Lenis;
-- if (!L || matchMedia('(pointer: coarse)').matches) return;
-- const lenis = new L({ lerp: 0.11, wheelMultiplier: 0.95, smoothWheel: true });
++ if (!L) return;
++ // syncTouch keeps native-feeling touch scrolling but runs it through the same lerp, so
++ // phones get the same eased scroll as trackpads instead of an entirely different feel.
++ const lenis = new L({ lerp: 0.11, wheelMultiplier: 0.95, smoothWheel: true, syncTouch: true, touchMultiplier: 1.15 });
// @ts-ignore
window.__lenis = lenis;
const raf = (t: number) => { lenis.raf(t); requestAnimationFrame(raf); };
@@@ -313,16 +330,96 @@ const imageUrl = new URL(image, site).toString();
});
}
-- // ---- Theme toggle --------------------------------------------------------
++ // ---- Theme (light/dark) ---------------------------------------------------
++ // One entry point for every light/dark switch on the page (nav icon, drawer switch, the
++ // picker's mode buttons). Uses the View Transitions API for a real cross-fade of the whole
++ // page where available, and the .is-theming property transitions everywhere else.
++ const setTheme = (next: 'light' | 'dark') => {
++ const root = document.documentElement;
++ if (root.dataset.theme === next) return;
++ const apply = () => {
++ root.dataset.theme = next;
++ try { localStorage.setItem('afnet-theme', next); } catch {}
++ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', next === 'light' ? '#f4f6fb' : '#07090f');
++ document.dispatchEvent(new CustomEvent('ov:theme', { detail: { theme: next } }));
++ };
++ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const svt = (document as any).startViewTransition;
++ if (!reduced && typeof svt === 'function') {
++ root.classList.add('is-theme-vt');
++ const vt = svt.call(document, apply);
++ vt.finished.finally(() => root.classList.remove('is-theme-vt'));
++ } else {
++ (window as any).__ovWithThemeTransition ? (window as any).__ovWithThemeTransition(apply) : apply();
++ }
++ };
++ (window as any).__ovSetTheme = setTheme;
document.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-theme-toggle]');
if (!t) return;
-- const root = document.documentElement;
-- const next = root.dataset.theme === 'light' ? 'dark' : 'light';
-- root.dataset.theme = next;
-- try { localStorage.setItem('afnet-theme', next); } catch {}
-- document.querySelector('meta[name="theme-color"]')?.setAttribute('content', next === 'light' ? '#f4f6fb' : '#07090f');
++ setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
++ });
++
++ // ---- Floating scrollbar ---------------------------------------------------
++ // The native sbar is hidden (global.css); this overlay one floats above the content, takes no
++ // layout space, shows itself while you scroll or approach the right edge, and fades out
++ // once input goes quiet. Drag the thumb or click the track like a normal scrollbar.
++ const sbar = document.createElement('div');
++ sbar.className = 'ovbar';
++ sbar.setAttribute('aria-hidden', 'true');
++ sbar.innerHTML = '<div class="ovbar__track"><div class="ovbar__thumb"></div></div>';
++ document.body.appendChild(sbar);
++ const track = sbar.firstElementChild as HTMLElement;
++ const thumb = track.firstElementChild as HTMLElement;
++ let hideTimer = 0, dragging = false, dragOffset = 0;
++ const docH = () => document.documentElement.scrollHeight;
++ const layout = () => {
++ const vh = innerHeight, dh = docH();
++ if (dh <= vh + 2) { sbar.classList.add('is-unneeded'); return; }
++ sbar.classList.remove('is-unneeded');
++ const th = track.clientHeight;
++ const size = Math.max(36, (vh / dh) * th);
++ const y = (scrollY / (dh - vh)) * (th - size);
++ thumb.style.height = `${size}px`;
++ thumb.style.transform = `translateY(${Math.min(Math.max(y, 0), th - size)}px)`;
++ };
++ const show = () => {
++ sbar.classList.add('is-on');
++ clearTimeout(hideTimer);
++ if (!dragging) hideTimer = setTimeout(() => sbar.classList.remove('is-on'), 1200);
++ };
++ const scrollToRatio = (r: number, smooth: boolean) => {
++ const target = r * (docH() - innerHeight);
++ const lenis = (window as any).__lenis;
++ if (lenis) lenis.scrollTo(target, { immediate: !smooth, duration: 0.6 });
++ else scrollTo({ top: target, behavior: smooth ? 'smooth' : 'auto' });
++ };
++ addEventListener('scroll', () => { layout(); show(); }, { passive: true });
++ addEventListener('resize', layout);
++ addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse' && innerWidth - e.clientX < 40) show(); }, { passive: true });
++ addEventListener('touchstart', show, { passive: true });
++ thumb.addEventListener('pointerdown', (e) => {
++ dragging = true; sbar.classList.add('is-dragging');
++ dragOffset = e.clientY - thumb.getBoundingClientRect().top;
++ thumb.setPointerCapture(e.pointerId);
++ e.preventDefault();
++ });
++ thumb.addEventListener('pointermove', (e) => {
++ if (!dragging) return;
++ const tr = track.getBoundingClientRect();
++ const range = tr.height - thumb.offsetHeight;
++ scrollToRatio(Math.min(Math.max((e.clientY - tr.top - dragOffset) / range, 0), 1), false);
++ });
++ const endDrag = () => { if (!dragging) return; dragging = false; sbar.classList.remove('is-dragging'); show(); };
++ thumb.addEventListener('pointerup', endDrag);
++ thumb.addEventListener('pointercancel', endDrag);
++ track.addEventListener('pointerdown', (e) => {
++ if (e.target === thumb) return;
++ const tr = track.getBoundingClientRect();
++ scrollToRatio(Math.min(Math.max((e.clientY - tr.top - thumb.offsetHeight / 2) / (tr.height - thumb.offsetHeight), 0), 1), true);
});
++ new ResizeObserver(layout).observe(document.body);
++ layout();
</script>
</body>
</html>
ddiff --git a/src/styles/global.css b/src/styles/global.css
iindex ebe1ff2..a18628c 100644
---- a/src/styles/global.css
++++ b/src/styles/global.css
@@@ -214,3 +214,35 @@ strong { color: var(--fg); font-weight: 600; }
body::before, body::after, .progress, .cursor, .nav, .foot, .marquee { display: none !important; }
[data-reveal] { opacity: 1 !important; transform: none !important; }
}
++
++/* ---- Theme transitions -------------------------------------------------- */
++/* Set by Base.astro for ~700ms around any accent or light/dark change. */
++html.is-theming, html.is-theming *, html.is-theming *::before, html.is-theming *::after {
++ transition: background-color .6s ease, color .6s ease, border-color .6s ease, box-shadow .6s ease,
++ fill .6s ease, stroke .6s ease, outline-color .6s ease, opacity .6s ease !important;
++}
++::view-transition-old(root), ::view-transition-new(root) { animation-duration: .55s; animation-timing-function: cubic-bezier(.22,1,.36,1); }
++
++/* ---- Floating scrollbar --------------------------------------------------- */
++html { scrollbar-width: none; }
++html::-webkit-scrollbar { display: none; width: 0; height: 0; }
++.ovbar {
++ position: fixed; top: 10px; right: 5px; bottom: 10px; width: 14px; z-index: 140;
++ opacity: 0; transition: opacity .45s var(--ease); pointer-events: none;
++}
++.ovbar.is-on { opacity: 1; pointer-events: auto; }
++.ovbar.is-unneeded { display: none; }
++.ovbar__track {
++ position: absolute; inset: 0 3px; border-radius: 999px;
++ background: rgba(var(--accent-rgb), .08);
++ transition: inset .25s var(--ease), background .25s var(--ease);
++}
++.ovbar:hover .ovbar__track, .ovbar.is-dragging .ovbar__track { inset: 0 1px; background: rgba(var(--accent-rgb), .14); }
++.ovbar__thumb {
++ position: absolute; left: 0; right: 0; top: 0; border-radius: 999px; cursor: grab; touch-action: none;
++ background: var(--grad); box-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 6px 18px -6px rgba(var(--accent-rgb), .7);
++ will-change: transform;
++}
++.ovbar.is-dragging .ovbar__thumb { cursor: grabbing; filter: brightness(1.12); }
++@media (pointer: coarse) { .ovbar { width: 8px; right: 3px; } .ovbar__track { inset: 0 1px; } }
++@media (prefers-reduced-motion: reduce) { .ovbar { transition: none; } }