Resume + site pass: PDF header/skills/links redesign, copy audit, hero portrait FX, theme transitions, floating scrollbar, drawer Themes/dark switch
Changes
ddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_green_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_red_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf differBBinary files a/public/assets/img/me-cut1.png and b/public/assets/img/me-cut1.png differBBinary files a/public/assets/img/me-cut2.png and b/public/assets/img/me-cut2.png differ@@@ -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 1COMPACT_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 = MARGINw = CONTENT_Wname_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 + padif draw:c.saveState()@@@ -574,42 +604,14 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floac.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 + 10if 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.0w = (CONTENT_W - gap * (cols - 1)) / cols-- pad = 9.0++ pad = 8.5icon_r = 11.0inner_w = w - 2 * padtitle_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.0num_size = 15.0pad = 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) + 12if draw:@@@ -752,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, drac.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_GAPfn = block_role_compact if compact else block_role_full-- sep = 3.0 if compact else 4.0++ sep = 2.5 if compact else 4.0for 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: boolif 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.0cw = (CONTENT_W - gap * (cols - 1)) / colsicon_col = 20.0link_badge = 16.0@@@ -884,23 +893,55 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: booldef 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_PADy = y_top - CARD_PADif 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} {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_PADreturn y_top - y@@@ -908,34 +949,45 @@ def block_education(c, profile: dict, st: Styles, y_top: float, draw: bool) -> fx, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PADy = y_top - CARD_PADif draw:-- section_header(c, x, y, "Education & links", w, "")++ section_header(c, x, y, "Education", w, ICON["education"])y -= SECTION_PILL_H + SECTION_GAPp = 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_GAPlinks = [("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.6base = y - size * 0.78-- cx = x++ n = max(len(links), 1)++ col_w = w / nif 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 + 5return 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 alwaysa *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 - BOTTOMfooter(c, profile, 2, 2, updated)c.showPage()@@@ -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 ? 1animation: 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>@@@ -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'] });@@@ -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; }@@@ -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();};@@@ -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",@@@ -1,5 +1,5 @@{-- "company": "GameServerStats, LLC",++ "company": "Own company, LLC","role": "Founder · Developer · Systems Admin","start": "2015","end": "Present",@@@ -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" },@@@ -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"}@@@ -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-ignoreconst 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-ignorewindow.__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>@@@ -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; } }
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Sep_2026_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Sep_2026_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_amber_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_amber_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_cyan_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_cyan_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_green_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_green_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_indigo_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_indigo_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_lime_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_lime_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_orange_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_orange_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_pink_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_pink_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_red_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_red_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_teal_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_teal_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_violet_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_violet_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_yellow_4a270599.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_yellow_4a270599.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_fde01332.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf differ
Binary files a/public/assets/img/me-cut1.png and b/public/assets/img/me-cut1.png differ
Binary files a/public/assets/img/me-cut1.png and b/public/assets/img/me-cut1.png differ
Binary files a/public/assets/img/me-cut2.png and b/public/assets/img/me-cut2.png differ
Binary files a/public/assets/img/me-cut2.png and b/public/assets/img/me-cut2.png differ
@@ -52,7 +52,7 @@ SELECTED_PROJECTS = [
@@ -52,7 +52,7 @@ SELECTED_PROJECTS = [
]
]
# Metrics strip: pick by a keyword found in the label, in this order.
# Metrics strip: pick by a keyword found in the label, in this order.
# Falls back to the first four metrics if fewer than four match.
# Falls back to the first four metrics if fewer than four match.
-METRIC_KEYWORDS = ["services", "routes", "payment processors", "irdrs", "years"]
+METRIC_KEYWORDS = ["shipped", "lines of production", "payment processors", "sox compliance", "years running"]
FULL_ROLES = 2 # PowerChat + OpenVibe get full treatment on page 1
FULL_ROLES = 2 # PowerChat + OpenVibe get full treatment on page 1
COMPACT_BULLETS = 2 # bullets per role on page 2
COMPACT_BULLETS = 2 # bullets per role on page 2
@@ -190,9 +190,11 @@ def metric_icon(css_class: str) -> str:
@@ -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
# 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.
# the generic "engineer" glyph so an unrecognized role degrades instead of going icon-less.
ROLE_ICON_KEYWORDS = [
ROLE_ICON_KEYWORDS = [
- ("idea", "\uf0eb"), ("business", "\uf0eb"), ("automat", "\uf085"),
+ ("idea", "\uf0eb"), ("business", "\uf0b1"), ("owner", "\uf0b1"), ("automat", "\uf085"),
("platform", "\uf233"), ("architect", "\uf233"), ("founder", "\uf135"),
("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
DEFAULT_ROLE_ICON = "\uf121" # code
@@ -330,7 +332,7 @@ class Styles:
@@ -330,7 +332,7 @@ class Styles:
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.5, 9.0, 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.3, 10.1, BODY)
+ self.skill_items = style("skills", 8.2, 9.7, BODY)
self.edu = style("edu", 8.3, 10.1, BODY)
self.edu = style("edu", 8.3, 10.1, 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)
@@ -510,47 +512,75 @@ def section_header(c, x: float, y_top: float, text: str, width: float, glyph: st
@@ -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:
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
x = MARGIN
w = CONTENT_W
w = CONTENT_W
name_size, tag_size = 20.0, 9.2
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:
if draw:
c.saveState()
c.saveState()
@@ -574,42 +604,14 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
@@ -574,42 +604,14 @@ def block_header(c, profile: dict, st: Styles, y_top: float, draw: bool) -> floa
c.setFillColor(HexColor("#dbe4ff"))
c.setFillColor(HexColor("#dbe4ff"))
c.setFont(FONT["regular"], tag_size)
c.setFont(FONT["regular"], tag_size)
c.drawString(x + pad, ty - tag_size * 0.78, profile["tagline"])
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:
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
return banner_h
@@ -628,7 +630,7 @@ def block_feature_cards(c, seeking: list[dict], st: Styles, y_top: float, draw:
@@ -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)]
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 = 9.0
+ pad = 8.5
icon_r = 11.0
icon_r = 11.0
inner_w = w - 2 * pad
inner_w = w - 2 * pad
title_w = inner_w - icon_r * 2 - 10
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)
@@ -682,9 +684,9 @@ def block_metrics(c, metrics: list[dict], st: Styles, y_top: float, draw: bool)
icon_r = 11.0
icon_r = 11.0
num_size = 15.0
num_size = 15.0
pad = 10.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
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)
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 + 12, num_size + label_h + 6) + 12
if draw:
if draw:
@@ -752,6 +754,13 @@ def role_header(c, role: dict, st: Styles, x: float, w: float, y_top: float, dra
@@ -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"])
c.drawString(x + icon_col, base, role["company"])
cw = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)
cw = pdfmetrics.stringWidth(role["company"], FONT["bold"], meta_size)
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
+ # 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)
c.linkURL(role["link"], (x + icon_col, base - 2, x + icon_col + cw, base + meta_size), relative=0, thickness=0)
if role.get("location"):
if role.get("location"):
c.setFillColor(MUTED)
c.setFillColor(MUTED)
@@ -813,7 +822,7 @@ def block_experience(c, roles: list[dict], st: Styles, y_top: float, label: str,
@@ -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, "")
section_header(c, x, y, label, w, "")
y -= SECTION_PILL_H + SECTION_GAP
y -= SECTION_PILL_H + SECTION_GAP
fn = block_role_compact if compact else block_role_full
fn = block_role_compact if compact else block_role_full
- sep = 3.0 if compact else 4.0
+ sep = 2.5 if compact else 4.0
for i, role in enumerate(roles):
for i, role in enumerate(roles):
if i:
if i:
if draw:
if draw:
@@ -834,7 +843,7 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
@@ -834,7 +843,7 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
if draw:
if draw:
section_header(c, MARGIN, y, "Selected work", CONTENT_W, ICON["link"])
section_header(c, MARGIN, y, "Selected work", CONTENT_W, ICON["link"])
y -= SECTION_PILL_H + SECTION_GAP
y -= SECTION_PILL_H + SECTION_GAP
- cols, gap, pad = 3, 8.0, 8.0
+ cols, gap, pad = 3, 7.0, 8.0
cw = (CONTENT_W - gap * (cols - 1)) / cols
cw = (CONTENT_W - gap * (cols - 1)) / cols
icon_col = 20.0
icon_col = 20.0
link_badge = 16.0
link_badge = 16.0
@@ -884,23 +893,55 @@ def block_projects(c, projects: list[dict], st: Styles, y_top: float, draw: bool
@@ -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:
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
x, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PAD
y = y_top - CARD_PAD
y = y_top - CARD_PAD
if draw:
if draw:
section_header(c, x, y, "Skills", w, "")
section_header(c, x, y, "Skills", w, "")
y -= SECTION_PILL_H + SECTION_GAP
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} {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:
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
return y_top - y
@@ -908,34 +949,45 @@ def block_education(c, profile: dict, st: Styles, y_top: float, draw: bool) -> f
@@ -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
x, w = MARGIN + CARD_PAD, CONTENT_W - 2 * CARD_PAD
y = y_top - CARD_PAD
y = y_top - CARD_PAD
if draw:
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
y -= SECTION_PILL_H + SECTION_GAP
p = para(esc(profile.get("education", "")), st.edu)
p = para(esc(profile.get("education", "")), st.edu)
h = para_height(p, w)
h = para_height(p, w)
if draw:
if draw:
draw_para(c, p, x, y, w)
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 = [
links = [
("site", profile.get("siteDisplay"), profile.get("site")),
("site", profile.get("siteDisplay"), profile.get("site")),
("github", profile.get("githubDisplay"), profile.get("github")),
("github", profile.get("githubDisplay"), profile.get("github")),
("linkedin", profile.get("linkedinDisplay"), profile.get("linkedin")),
("linkedin", profile.get("linkedinDisplay"), profile.get("linkedin")),
("email", profile.get("emailDisplay"), f"mailto:{profile['email']}" if profile.get("email") else None),
("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
base = y - size * 0.78
- cx = x
+ n = max(len(links), 1)
+ col_w = w / n
if draw:
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.setFillColor(BLUE)
c.setFont(FONT["regular"], size)
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
return y_top - y
@@ -973,7 +1025,11 @@ def content_hash(data: dict) -> str:
@@ -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
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."""
a *different* filename from the current one instead of the same name silently going stale."""
import hashlib
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]
return hashlib.sha256(blob).hexdigest()[:8]
@@ -1132,8 +1188,12 @@ def build(out_path: Path) -> dict:
@@ -1132,8 +1188,12 @@ def build(out_path: Path) -> dict:
h, drawer = measured(block_education, profile, st, y)
h, drawer = measured(block_education, profile, st, y)
card(c, MARGIN, y, CONTENT_W, h)
card(c, MARGIN, y, CONTENT_W, h)
drawer()
drawer()
+ y -= h + 2
+ h, drawer = measured(block_links, profile, st, y)
+ card(c, MARGIN, y, CONTENT_W, h)
+ drawer()
y -= h
y -= h
- check(y, "page 2 education")
+ check(y, "page 2 links")
report["page2_bottom_gap"] = y - BOTTOM
report["page2_bottom_gap"] = y - BOTTOM
footer(c, profile, 2, 2, updated)
footer(c, profile, 2, 2, updated)
c.showPage()
c.showPage()
@@ -11,8 +11,9 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -11,8 +11,9 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
<div class="container hero__grid">
<div class="container hero__grid">
<div class="hero__copy">
<div class="hero__copy">
<p class="hero__status hero__in" style="--d:0ms">
<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>
</p>
<h1 class="hero__title" id="hero-title" aria-label={name}>
<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
@@ -28,12 +29,12 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
</h1>
</h1>
<p class="hero__role" aria-live="polite">
<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>
<span class="hero__rotator" id="rotator" data-roles={JSON.stringify(profile.roles)}>{profile.roles[0]}</span>
</p>
</p>
<p class="hero__lead hero__in" style="--d:650ms">
<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>
</p>
<div class="hero__cta hero__in" style="--d:800ms">
<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
@@ -53,16 +54,18 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
</div>
</div>
<div class="hero__visual hero__in hero__in--scale" style="--d:300ms">
<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--a" aria-hidden="true"></div>
<div class="portrait__ring portrait__ring--b" 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__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-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" />
<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>
- <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 class="portrait__tag portrait__tag--br" aria-hidden="true"><span class="live-dot"></span> Open to just about anything</div>
</div>
</div>
</div>
</div>
@@ -94,12 +97,15 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -94,12 +97,15 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
.hero__copy { min-width: 0; }
.hero__copy { min-width: 0; }
.hero__status {
.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__status a { color: var(--fg); }
.hero__title {
.hero__title {
@@ -144,24 +150,59 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -144,24 +150,59 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
/* Portrait */
/* Portrait */
.hero__visual { display: grid; place-items: center; min-width: 0; }
.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__img.is-on { opacity: 1; }
.portrait__scan {
.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);
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 { 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--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; }
.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
@@ -178,7 +219,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
animation: floaty 7s ease-in-out infinite;
animation: floaty 7s ease-in-out infinite;
}
}
.portrait__tag i { color: var(--accent-2); }
.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; }
.portrait__tag--br { bottom: 7%; right: -8%; animation-delay: -3.5s; }
@keyframes floaty { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
@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
@@ -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 { min-height: auto; padding-top: calc(var(--nav-h) + 30px); }
.hero__grid { grid-template-columns: 1fr; }
.hero__grid { grid-template-columns: 1fr; }
.hero__visual { order: -1; }
.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__scroll { display: none; }
.hero__title { font-size: clamp(3rem, 15vw, 5rem); }
.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
@@ -267,16 +310,144 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
};
};
step();
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 () => {
setInterval(async () => {
if (busy || document.hidden) return;
if (busy || document.hidden) return;
busy = true; i = (i + 1) % roles.length;
busy = true; i = (i + 1) % roles.length;
+ setArticle(roles[i]);
await scramble(roles[i]); busy = false;
await scramble(roles[i]); busy = false;
}, 2800);
}, 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>
</script>
<style is:global>
<style is:global>
@@ -73,12 +73,22 @@ const path = Astro.url.pathname;
@@ -73,12 +73,22 @@ const path = Astro.url.pathname;
</a>
</a>
</li>
</li>
<li style={`--i:${links.length + 2}`}>
<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>
<i class="fa-solid fa-chevron-right nav__drawer-go" aria-hidden="true"></i>
</button>
</button>
</li>
</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>
</ul>
</div>
</div>
</header>
</header>
@@ -177,7 +187,7 @@ const path = Astro.url.pathname;
@@ -177,7 +187,7 @@ const path = Astro.url.pathname;
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;
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 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; }
@@ -200,6 +210,27 @@ const path = Astro.url.pathname;
@@ -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-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: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 { background: var(--grad); color: #fff; }
.nav__drawer-link--cta:hover { background: var(--grad); filter: brightness(1.08); transform: translateX(2px); }
.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); }
.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;
@@ -225,7 +256,20 @@ const path = Astro.url.pathname;
drawer.setAttribute('aria-hidden', String(!open));
drawer.setAttribute('aria-hidden', String(!open));
};
};
burger.addEventListener('click', () => setOpen(!drawer.classList.contains('is-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
// 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
// — 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;
@@ -240,7 +284,12 @@ const path = Astro.url.pathname;
// A little dot on the palette icon confirms the chosen accent took, without opening anything.
// A little dot on the palette icon confirms the chosen accent took, without opening anything.
const dot = document.getElementById('nav-color-dot');
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();
syncDot();
new MutationObserver(syncDot).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
new MutationObserver(syncDot).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
@@ -17,15 +17,20 @@ const statusAlsoParts = linkify(profile.status.also, LINK_TERMS);
@@ -17,15 +17,20 @@ const statusAlsoParts = linkify(profile.status.also, LINK_TERMS);
<span class="sec-num" aria-hidden="true">01</span>
<span class="sec-num" aria-hidden="true">01</span>
<div>
<div>
<div class="sec-index">Profile</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>
</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>
<div class="now__grid">
<div class="now__grid">
<article class="card spot now__pitch" data-reveal>
<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">{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">
<div class="now__cta">
<a href="#experience" class="btn btn--ghost">Experience <span class="btn__arrow">→</span></a>
<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>
<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);
@@ -61,6 +66,12 @@ const statusAlsoParts = linkify(profile.status.also, LINK_TERMS);
.now__pitch { padding: clamp(24px, 3.5vw, 40px); }
.now__pitch { padding: clamp(24px, 3.5vw, 40px); }
.now__text { font-size: 1.08rem; line-height: 1.8; color: var(--muted); }
.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__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__cta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.now__side { display: grid; gap: 18px; }
.now__side { display: grid; gap: 18px; }
.now__status-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.now__status-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
@@ -101,18 +101,22 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
@@ -101,18 +101,22 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
const modal = document.getElementById('tp-modal')!;
const modal = document.getElementById('tp-modal')!;
const ASKED_KEY = 'ov_theme_asked';
const ASKED_KEY = 'ov_theme_asked';
const COLOR_KEY = 'ov_fav_color';
const COLOR_KEY = 'ov_fav_color';
- const applyAccent = (hex: string) => {
- (window as any).__ovApplyAccent?.(hex);
+ const applyAccent = (hex: string, animate = false) => {
+ (window as any).__ovApplyAccent?.(hex, animate);
(window as any).__ovApplyResumeLinks?.(hex);
(window as any).__ovApplyResumeLinks?.(hex);
};
};
const MODE_KEY = 'afnet-theme';
const MODE_KEY = 'afnet-theme';
const setMode = (mode: 'dark' | 'light') => {
const setMode = (mode: 'dark' | 'light') => {
- 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();
syncModeButtons();
};
};
+ document.addEventListener('ov:theme', () => syncModeButtons());
const syncModeButtons = () => {
const syncModeButtons = () => {
const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
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));
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);
@@ -138,7 +142,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
setTimeout(() => { modal.hidden = true; }, 350);
setTimeout(() => { modal.hidden = true; }, 350);
};
};
const choose = (hex: string) => {
const choose = (hex: string) => {
- applyAccent(hex);
+ applyAccent(hex, true);
try { localStorage.setItem(COLOR_KEY, hex); localStorage.setItem(ASKED_KEY, '1'); } catch { /* */ }
try { localStorage.setItem(COLOR_KEY, hex); localStorage.setItem(ASKED_KEY, '1'); } catch { /* */ }
close();
close();
};
};
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"featured": true,
"featured": true,
"current": true,
"current": true,
"order": 2,
"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": [
"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": [
"stack": [
"Node.js",
"Node.js",
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
{
{
- "company": "GameServerStats, LLC",
+ "company": "Own company, LLC",
"role": "Founder · Developer · Systems Admin",
"role": "Founder · Developer · Systems Admin",
"start": "2015",
"start": "2015",
"end": "Present",
"end": "Present",
@@ -1,8 +1,15 @@
@@ -1,8 +1,15 @@
{
{
"name": "Alex Frison",
"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.",
"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",
"location": "Arlington · Seattle · Pacific Northwest",
"locationShort": "Seattle / Arlington, WA",
"locationShort": "Seattle / Arlington, WA",
"email": "[email protected]",
"email": "[email protected]",
@@ -15,30 +22,30 @@
@@ -15,30 +22,30 @@
"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_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",
"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.",
"also": "Founder and sole engineer of OpenVibe: seven services, 20+ domains, one operator."
"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": [
"seeking": [
{ "label": "Honestly, open to anything", "detail": "Title and level are negotiable — the team and the problem matter more" },
{ "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": "Software engineering", "detail": "Full-stack, platform, streaming or realtime systems work" },
{ "label": "Data & analytics", "detail": "SQL, reporting and compliance tooling people act on" },
{ "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": "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)",
"lastRole": "PowerChat — Software Engineer (Contract)",
"metrics": [
"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": [
"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_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"
}
}
@@ -121,8 +121,23 @@ const imageUrl = new URL(image, site).toString();
@@ -121,8 +121,23 @@ const imageUrl = new URL(image, site).toString();
hex = hex.replace('#', '');
hex = hex.replace('#', '');
return parseInt(hex.substr(0, 2), 16) + ', ' + parseInt(hex.substr(2, 2), 16) + ', ' + parseInt(hex.substr(4, 2), 16);
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 (!/^#[0-9a-f]{6}$/i.test(hex)) return;
+ if (animate) { withThemeTransition(function () { applyAccent(hex, false); }); return; }
var hsl = hexToHsl(hex), h = hsl[0];
var hsl = hexToHsl(hex), h = hsl[0];
// Clamp saturation/lightness so a very dark or very pale pick still reads as an accent
// 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.
// (not black text or an invisible near-white) in both themes.
@@ -194,8 +209,10 @@ const imageUrl = new URL(image, site).toString();
@@ -194,8 +209,10 @@ const imageUrl = new URL(image, site).toString();
lenisScript.onload = () => {
lenisScript.onload = () => {
// @ts-ignore
// @ts-ignore
const L = window.Lenis;
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
// @ts-ignore
window.__lenis = lenis;
window.__lenis = lenis;
const raf = (t: number) => { lenis.raf(t); requestAnimationFrame(raf); };
const raf = (t: number) => { lenis.raf(t); requestAnimationFrame(raf); };
@@ -313,16 +330,96 @@ const imageUrl = new URL(image, site).toString();
@@ -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) => {
document.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-theme-toggle]');
const t = (e.target as HTMLElement).closest('[data-theme-toggle]');
if (!t) return;
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>
</script>
</body>
</body>
</html>
</html>
@@ -214,3 +214,35 @@ strong { color: var(--fg); font-weight: 600; }
@@ -214,3 +214,35 @@ strong { color: var(--fg); font-weight: 600; }
body::before, body::after, .progress, .cursor, .nav, .foot, .marquee { display: none !important; }
body::before, body::after, .progress, .cursor, .nav, .foot, .marquee { display: none !important; }
[data-reveal] { opacity: 1 !important; transform: 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; } }