Site polish pass: readable buttons on any accent, calmer palette, link hover previews, section chip in the nav, drawer active state, hero tags grid, rotator caret, native touch scroll
Changes
nnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_green_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_red_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf differddeleted file mode 100644BBinary files a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf and /dev/null differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/alexfrison.net.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/github.com.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_home.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_hartools.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_live-translation.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/local_resume.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.chat.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.community.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.games.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.live.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.media.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.network.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/openvibe.tools.jpg differnnew file mode 100644BBinary files /dev/null and b/public/assets/img/previews/powerchat.live.jpg differnnew file mode 100644@@@ -0,0 +1,52 @@++// Pre-renders the hover-preview thumbnails for every external site and internal page the site++// links to, so hovering a link never fetches a live page. Run after `npm run build` (internal++// pages are shot from dist/ served locally). Output: public/assets/img/previews/*.jpg and++// src/data/link-previews.json (key → { img, label }).++import { chromium } from '/home/workstation/OpenVibers/OpenVibe.Games/node_modules/playwright/index.mjs';++import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';++import { spawn } from 'node:child_process';++++const ROOT = new URL('..', import.meta.url).pathname;++const profile = JSON.parse(readFileSync(`${ROOT}src/data/profile.json`, 'utf8'));++const OUT = `${ROOT}public/assets/img/previews`;++const W = 1280, H = 800, SCALE = 0.5; // 640x400 output++++const external = [++ ...profile.network.map((n) => n.url),++ 'https://powerchat.live',++ 'https://github.com/OpenVibers',++ 'https://alexfrison.net',++];++const internal = ['/', '/resume/', '/portfolio/', ...readdirSync(`${ROOT}dist/portfolio`).filter((d) => existsSync(`${ROOT}dist/portfolio/${d}/index.html`)).map((d) => `/portfolio/${d}/`)];++++const server = spawn('python3', ['-m', 'http.server', '4399'], { cwd: `${ROOT}dist`, stdio: 'ignore' });++await new Promise((r) => setTimeout(r, 800));++++const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });++const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: 'dark',++ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' });++const map = {};++const shoot = async (url, key, label, file) => {++ const page = await ctx.newPage();++ try {++ await page.addInitScript(() => { try { localStorage.setItem('ov_theme_asked', '1'); } catch {} });++ await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 }).catch(() => {});++ await page.waitForTimeout(url.startsWith('http://localhost') ? 2500 : 1500);++ await page.screenshot({ path: `${OUT}/${file}.jpg`, type: 'jpeg', quality: 74, clip: { x: 0, y: 0, width: W, height: H } });++ map[key] = { img: `/assets/img/previews/${file}.jpg`, label };++ console.log('ok', key);++ } catch (e) { console.log('FAIL', key, e.message); }++ await page.close();++};++for (const u of external) {++ const host = new URL(u).host.replace(/^www\./, '');++ await shoot(u, host, host, host.replace(/[^a-z0-9.-]/gi, '_'));++}++for (const p of internal) {++ const label = p === '/' ? 'alexfrison.net' : `alexfrison.net${p.replace(/\/$/, '')}`;++ await shoot(`http://localhost:4399${p}`, `path:${p.replace(/\/$/, '') || '/'}`, label, 'local' + (p.replace(/\/$/, '') || '/home').replace(/\//g, '_'));++}++await browser.close();++server.kill();++writeFileSync(`${ROOT}src/data/link-previews.json`, JSON.stringify(map, null, 2) + '\n');++console.log('wrote', Object.keys(map).length, 'previews');@@@ -43,14 +43,14 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1<a href="#contact" class="btn btn--ghost" data-magnetic><i class="fa-solid fa-paper-plane"></i> Get in touch</a></div>-- <ul class="hero__tags hero__in" style="--d:950ms">-- <li class="chip"><i class="fa-solid fa-robot"></i> AI agents</li>-- <li class="chip"><i class="fa-solid fa-gears"></i> Automation</li>-- <li class="chip"><i class="fa-solid fa-microchip"></i> Robotics</li>-- <li class="chip"><i class="fa-solid fa-tower-broadcast"></i> Streaming infra</li>-- <li class="chip chip--ember"><i class="fa-solid fa-credit-card"></i> Payments</li>-- <li class="chip chip--mint"><i class="fa-solid fa-server"></i> Ops & deploys</li>-- </ul>++ <div class="hero__tags hero__in" style="--d:950ms">++ <div class="hero__tags-label"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> What I build and do</div>++ <ul class="hero__tags-grid" aria-label="Areas of work">++ {profile.strengths.map((t, i) => (++ <li class="hero__tag" style={`--i:${i}`}><span class="hero__tag-ico"><i class={t.icon} aria-hidden="true"></i></span><span>{t.label}</span></li>++ ))}++ </ul>++ </div></div><div class="hero__visual hero__in hero__in--scale" style="--d:300ms">@@@ -136,17 +136,49 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1min-height: 2.5em;}.hero__role-static { color: var(--dim); font-weight: 500; }-- .hero__rotator { position: relative; color: var(--accent); padding-bottom: .12em; }++ .hero__rotator {++ position: relative; color: var(--accent); padding-bottom: .14em;++ text-shadow: 0 0 22px rgba(var(--accent-rgb), .35);++ }++ /* The rule under the word is a slow-moving gradient sheen; the caret is a thin glowing bar++ that sits inline after the last glyph (not an underscore fighting the rule for the same++ baseline). While the scramble runs the caret goes solid, like a terminal mid-output. */.hero__rotator::before {-- content: ""; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%;-- background: var(--grad); border-radius: 2px; opacity: .55;++ content: ""; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; border-radius: 2px;++ background: linear-gradient(90deg, var(--accent), var(--violet), var(--accent-2), var(--accent-3), var(--accent));++ background-size: 300% 100%; opacity: .6; animation: ruleSheen 6s linear infinite;++ mask-image: linear-gradient(90deg, #000 85%, transparent); -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent);}-- .hero__rotator::after { content: "_"; margin-left: 2px; color: var(--accent-2); animation: caret 1s steps(2) infinite; }-- @keyframes caret { 50% { opacity: 0; } }++ @keyframes ruleSheen { to { background-position: -300% 0; } }++ .hero__rotator::after {++ content: ""; display: inline-block; width: .085em; height: .82em; margin-left: .14em; vertical-align: -.04em; border-radius: 2px;++ background: linear-gradient(180deg, var(--accent-2), var(--accent)); box-shadow: 0 0 14px rgba(var(--accent-2-rgb), .8);++ animation: caret 1.15s ease-in-out infinite;++ }++ .hero__rotator.is-scrambling::after { animation: none; opacity: 1; }++ .hero__rotator.is-scrambling { animation: glitchShift .18s steps(2) infinite; }++ @keyframes caret { 0%, 100% { opacity: 1; } 50% { opacity: .15; } }++ @keyframes glitchShift { 0% { text-shadow: -1px 0 rgba(0, 220, 255, .55), 1px 0 rgba(255, 60, 120, .55), 0 0 22px rgba(var(--accent-rgb), .35); } 100% { text-shadow: 1px 0 rgba(0, 220, 255, .55), -1px 0 rgba(255, 60, 120, .55), 0 0 22px rgba(var(--accent-rgb), .35); } }.hero__lead { max-width: 58ch; font-size: clamp(1rem, 1.3vw, 1.12rem); color: var(--muted); }.hero__cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 1.6rem 0 1.4rem; }-- .hero__tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++ .hero__tags { max-width: 620px; }++ .hero__tags-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font: 500 .72rem var(--font-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--dim); }++ .hero__tags-label i { color: var(--accent); }++ .hero__tags-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }++ .hero__tag {++ display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; min-height: 44px;++ background: var(--surface); border: 1px solid var(--border); color: var(--fg);++ font: 500 .82rem/1.2 var(--font-body); letter-spacing: -.005em;++ opacity: 0; animation: tagIn .6s var(--ease) forwards; animation-delay: calc(1000ms + var(--i) * 45ms);++ transition: border-color .3s var(--ease), background .3s var(--ease), transform .3s var(--ease);++ }++ .hero__tag:hover { border-color: rgba(var(--accent-rgb), .5); background: var(--surface-2); transform: translateY(-2px); }++ @keyframes tagIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }++ .hero__tag-ico { flex: none; width: 28px; height: 28px; border-radius: 9px; display: inline-grid; place-items: center; font-size: .78rem; background: rgba(var(--accent-rgb), .14); color: var(--accent); }++ .hero__tag:nth-child(3n+2) .hero__tag-ico { background: rgba(var(--accent-2-rgb), .14); color: var(--accent-2); }++ .hero__tag:nth-child(3n) .hero__tag-ico { background: rgba(var(--accent-3-rgb), .14); color: var(--accent-3); }++ @media (max-width: 520px) { .hero__tags-grid { grid-template-columns: 1fr 1fr; } .hero__tag { font-size: .78rem; padding: 7px 9px; min-height: 42px; } }/* Portrait */.hero__visual { display: grid; place-items: center; min-width: 0; }@@@ -294,6 +326,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1const glyphs = '!<>-_\\/[]{}—=+*^?#0123456789';let i = 0, busy = false;const scramble = (to: string) => new Promise<void>((res) => {++ rot.classList.add('is-scrambling');const from = rot.textContent || '';const len = Math.max(from.length, to.length);const q = Array.from({ length: len }, (_, k) => ({ from: from[k] || '', to: to[k] || '', start: Math.floor(Math.random() * 14), end: Math.floor(Math.random() * 14) + 14, ch: '' }));@@@ -306,7 +339,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1else out += s.from;}rot.innerHTML = out;-- if (done === q.length) res(); else { frame++; requestAnimationFrame(step); }++ if (done === q.length) { rot.classList.remove('is-scrambling'); res(); } else { frame++; requestAnimationFrame(step); }};step();});@@@ -451,5 +484,6 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1</script><style is:global>-- .hero__rotator .scr { color: var(--accent-2); opacity: .8; }++ .hero__rotator .scr { display: inline-block; color: var(--accent-2); opacity: .85; animation: scrJit .09s steps(2) infinite; }++ @keyframes scrJit { 0% { transform: translateY(-1px) skewX(-6deg); opacity: .6; } 100% { transform: translateY(1px) skewX(4deg); opacity: .95; } }</style>@@@ -28,6 +28,7 @@ const path = Astro.url.pathname;</span><span class="nav__name">Alex Frison</span></a>++ <span class="nav__where" id="nav-where" aria-live="polite"><i class="fa-solid fa-house" aria-hidden="true"></i><span class="nav__where-txt">Home</span></span><nav class="nav__links" aria-label="Primary"><ul>@@@ -57,7 +58,7 @@ const path = Astro.url.pathname;<ul>{links.map((l, i) => (<li style={`--i:${i}`}>-- <a href={l.href} class="nav__drawer-link" aria-current={path.startsWith(`/${l.id}`) ? "page" : undefined}>++ <a href={l.href} class="nav__drawer-link" data-nav={l.id} aria-current={path.startsWith(`/${l.id}`) ? "page" : undefined}><span class="nav__drawer-ico"><i class={l.icon} aria-hidden="true"></i></span><span class="nav__drawer-text">{l.label}</span><i class="fa-solid fa-chevron-right nav__drawer-go" aria-hidden="true"></i>@@@ -130,6 +131,22 @@ const path = Astro.url.pathname;}@media (prefers-reduced-motion: reduce) { .nav__mark-comet { animation: none; } }.nav__name { font-size: .98rem; }++ /* Current-section readout: swaps icon + label as you scroll into each section. */++ .nav__where {++ display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px 0 10px; border-radius: 999px; flex: none;++ background: rgba(var(--accent-rgb), .1); border: 1px solid rgba(var(--accent-rgb), .25);++ font: 500 .72rem var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--fg);++ opacity: 0; transform: translateX(-6px); transition: opacity .35s var(--ease), transform .35s var(--ease); overflow: hidden;++ }++ .nav__where i { color: var(--accent); font-size: .8rem; }++ .nav.is-scrolled .nav__where { opacity: 1; transform: none; }++ .nav__where.is-swap .nav__where-txt, .nav__where.is-swap i { animation: whereIn .45s var(--ease); }++ @keyframes whereIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }++ @media (max-width: 920px) { .nav__where { margin-left: auto; } }++ /* Phones: the drawer has its own dark-mode switch, so the standalone toggle goes; the section++ chip drops its label below 520px so the PDF button, palette and burger always fit. */++ @media (max-width: 560px) { .nav__inner { gap: 8px; padding: 0 8px 0 10px; } .nav__icon[data-theme-toggle] { display: none; } }++ @media (max-width: 520px) { .nav__where .nav__where-txt { display: none; } .nav__where { padding: 0 10px; } }.nav__links { flex: 1; display: flex; justify-content: center; }.nav__links ul { position: relative; list-style: none; margin: 0; padding: 4px; display: flex; gap: 2px; border-radius: 999px; }@@@ -151,7 +168,7 @@ const path = Astro.url.pathname;.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }.nav__cta {display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px;-- background: var(--accent); color: #fff; font: 600 .82rem var(--font-body); text-decoration: none;++ background: var(--accent); color: var(--on-accent); font: 600 .82rem var(--font-body); text-decoration: none;box-shadow: 0 8px 22px -10px rgba(var(--accent-rgb), .9);transition: transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s;}@@@ -194,13 +211,18 @@ const path = Astro.url.pathname;.nav__drawer-sep { height: 1px; margin: 6px 10px; background: var(--border); flex: none; }.nav__drawer-link {-- display: flex; align-items: center; gap: 13px; width: 100%; padding: 10px 12px; border-radius: 14px;++ position: relative; display: flex; align-items: center; gap: 13px; width: 100%; padding: 10px 12px; border-radius: 14px;color: var(--fg); font: 600 1rem var(--font-display); text-decoration: none;background: transparent; border: 1px solid transparent; cursor: pointer; text-align: left;transition: background .2s var(--ease), border-color .2s var(--ease), transform .2s var(--ease);}.nav__drawer-link:hover, .nav__drawer-link:active { background: var(--surface-2); border-color: var(--border); text-decoration: none; transform: translateX(2px); }.nav__drawer-link[aria-current="page"] { background: var(--surface); border-color: var(--border-strong); }++ .nav__drawer-link::before { content: ""; position: absolute; left: 0; top: 22%; bottom: 22%; width: 3px; border-radius: 3px; background: var(--grad); transform: scaleY(0); transition: transform .35s var(--ease); }++ .nav__drawer-link.is-active { background: rgba(var(--accent-rgb), .1); border-color: rgba(var(--accent-rgb), .35); }++ .nav__drawer-link.is-active::before { transform: scaleY(1); }++ .nav__drawer-link.is-active .nav__drawer-ico { background: var(--grad); color: var(--on-accent); border-color: transparent; box-shadow: 0 8px 20px -10px rgba(var(--accent-rgb), .9); }++ .nav__drawer-link.is-active .nav__drawer-go { opacity: 1; transform: none; color: var(--accent); }.nav__drawer-ico {flex: none; width: 38px; height: 38px; border-radius: 11px; display: inline-grid; place-items: center;background: rgba(var(--accent-rgb), .12); color: var(--accent); font-size: .95rem;@@@ -231,10 +253,10 @@ const path = Astro.url.pathname;.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: var(--on-accent); }.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-link--cta .nav__drawer-go { color: #fff; opacity: .8; }++ .nav__drawer-ico--cta { background: rgba(255,255,255,.2); color: var(--on-accent); border-color: rgba(255,255,255,.3); }++ .nav__drawer-link--cta .nav__drawer-go { color: var(--on-accent); opacity: .8; }@media (max-width: 900px) { .nav__links a { padding: 8px 10px; font-size: .84rem; } }@media (max-width: 760px) {@@@ -296,6 +318,20 @@ const path = Astro.url.pathname;// Sliding pill follows the active sectionconst list = nav.querySelector<HTMLUListElement>('.nav__links ul');const anchors = Array.from(nav.querySelectorAll<HTMLAnchorElement>('.nav__links a[data-nav]'));++ const drawerAnchors = Array.from(drawer.querySelectorAll<HTMLAnchorElement>('a[data-nav]'));++ const where = document.getElementById('nav-where');++ const whereIcon = where?.querySelector('i'), whereTxt = where?.querySelector('.nav__where-txt');++ // Sections that aren't nav items still get a readout of their own.++ const extraWhere: Record<string, [string, string]> = { about: ['Profile', 'fa-solid fa-user'], metrics: ['At a glance', 'fa-solid fa-chart-simple'] };++ const setWhere = (id: string) => {++ if (!where || !whereIcon || !whereTxt) return;++ const src = anchors.find((a) => a.dataset.nav === id);++ const icon = src ? src.querySelector('i')!.className : (extraWhere[id]?.[1] || 'fa-solid fa-house');++ const label = src ? (src.textContent || '').trim() : (extraWhere[id]?.[0] || 'Home');++ if (whereTxt.textContent === label) return;++ whereIcon.className = icon; whereTxt.textContent = label;++ where.classList.remove('is-swap'); void where.offsetWidth; where.classList.add('is-swap');++ };const movePill = (a: HTMLAnchorElement | null) => {if (!list) return;if (!a) { list.style.setProperty('--po', '0'); return; }@@@ -307,7 +343,7 @@ const path = Astro.url.pathname;anchors.forEach((a) => a.addEventListener('mouseenter', () => movePill(a)));list?.addEventListener('mouseleave', () => movePill(anchors.find((a) => a.classList.contains('is-active')) || null));-- const sections = ['top', ...anchors.map((a) => a.dataset.nav!)].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];++ const sections = ['top', 'metrics', 'about', ...anchors.map((a) => a.dataset.nav!)].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];if (sections.length) {let lastSection = '';const markPulse = document.querySelector('#nav-mark .nav__mark-pulse');@@@ -316,6 +352,8 @@ const path = Astro.url.pathname;if (!vis) return;const id = vis.target.id === 'top' ? '' : vis.target.id;anchors.forEach((a) => a.classList.toggle('is-active', a.dataset.nav === id));++ drawerAnchors.forEach((a) => a.classList.toggle('is-active', a.dataset.nav === id));++ setWhere(id);movePill(anchors.find((a) => a.dataset.nav === id) || null);// The brand mark gives a quiet pulse each time you cross into a new section — a small// sign the page is tracking where you are, not just a static logo.@@@ -64,14 +64,14 @@ const edges = [<div class="arch card" data-reveal><div class="arch__copy">-- <div class="eyebrow">How it fits together</div>-- <h3>Identity in the middle, contracts between everything.</h3>-- <p>OpenVibe.Network is the OIDC provider and wallet. Every other service verifies RS256 tokens offline against its cached key set, calls Media over a signed multi-tenant API, and renders the same shared navbar, themes and notifications. Contracts are written down before code moves, and legacy domains 301 into the new network so no link ever died.</p>++ <div class="eyebrow"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> How it fits together</div>++ <h3>One login, one wallet, seven products that feel like one.</h3>++ <p>Sign in once and every OpenVibe site knows who you are, what you own and what you look like. The identity service in the middle handles accounts, security and the shared virtual currency; every other product plugs into it through clear, documented APIs. That's the same playbook I bring to any company: secure single sign-on, payments and APIs designed so new products can be added without breaking the old ones.</p><ul class="arch__facts">-- <li><i class="fa-solid fa-check"></i> Zero-downtime deploys via systemd socket activation</li>-- <li><i class="fa-solid fa-check"></i> Local → B2 → R2 media tiering with presigned playback</li>-- <li><i class="fa-solid fa-check"></i> Budget-metered, provider-agnostic AI jobs</li>-- <li><i class="fa-solid fa-check"></i> Dry-run-first migrations, three-window cutover runbook</li>++ <li><i class="fa-solid fa-rotate"></i> Updates ship with zero downtime, so users never see an outage</li>++ <li><i class="fa-solid fa-cloud-arrow-up"></i> Media storage tiers automatically from fast local disks to cheap cloud storage</li>++ <li><i class="fa-solid fa-robot"></i> AI features run on a budget-capped, vendor-neutral layer (OpenAI, Anthropic or local models)</li>++ <li><i class="fa-solid fa-shield-halved"></i> Every migration is rehearsed as a dry run before it touches real users</li></ul></div><svg class="arch__svg" viewBox="0 0 800 420" role="img" aria-label="Diagram: OpenVibe.Network at the center connected to Live, Media, Tools, Games, Community and 13 static sites; Live also talks to Media.">@@@ -141,7 +141,8 @@ const edges = [@media (max-width: 560px) { .bento { grid-template-columns: 1fr; } .tile--big, .tile--community, .tile--sites { grid-column: span 1; } .tile--big { min-height: 320px; } }/* Architecture */-- .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); }++ .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); margin-top: clamp(16px, 3vw, 28px); }++ .arch .eyebrow { display: inline-flex; align-items: center; gap: 8px; }.arch__copy h3 { font-size: clamp(1.3rem, 2.2vw, 1.8rem); margin-bottom: .6em; }.arch__facts { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }.arch__facts li { display: flex; gap: 10px; align-items: flex-start; color: var(--muted); font-size: .9rem; }@@@ -146,7 +146,7 @@ const cats = Array.from(new Set(projects.map((p) => p.data.category)));}.work__all-ico {flex: none; width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center;-- background: var(--grad); color: #fff; font-size: 1.25rem; box-shadow: var(--shadow-glow);++ background: var(--grad); color: var(--on-accent); font-size: 1.25rem; box-shadow: var(--shadow-glow);animation: workAllPulse 2.6s var(--ease) infinite;}@keyframes workAllPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } }@@@ -59,7 +59,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);.tp-close:hover { color: var(--fg); border-color: var(--border-strong); }.tp-icon {width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; margin-bottom: 16px;-- background: var(--grad); color: #fff; font-size: 1.3rem; box-shadow: var(--shadow-glow);++ background: var(--grad); color: var(--on-accent); font-size: 1.3rem; box-shadow: var(--shadow-glow);}.tp-panel h2 { font-size: 1.4rem; margin: 0 0 8px; }.tp-lead { color: var(--muted); font-size: .92rem; margin: 0 0 18px; line-height: 1.6; }@@@ -71,7 +71,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);font: 600 .88rem var(--font-body); cursor: pointer; transition: all .25s var(--ease);}.tp-mode-btn:hover { color: var(--fg); border-color: var(--accent); }-- .tp-mode-btn.is-active { background: var(--grad); color: #fff; border-color: transparent; box-shadow: var(--shadow-glow); }++ .tp-mode-btn.is-active { background: var(--grad); color: var(--on-accent); border-color: transparent; box-shadow: var(--shadow-glow); }.tp-swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 18px; }.tp-swatch {nnew file mode 100644@@@ -0,0 +1,154 @@++{++ "openvibe.live": {++ "img": "/assets/img/previews/openvibe.live.jpg",++ "label": "openvibe.live"++ },++ "openvibe.network": {++ "img": "/assets/img/previews/openvibe.network.jpg",++ "label": "openvibe.network"++ },++ "openvibe.media": {++ "img": "/assets/img/previews/openvibe.media.jpg",++ "label": "openvibe.media"++ },++ "openvibe.tools": {++ "img": "/assets/img/previews/openvibe.tools.jpg",++ "label": "openvibe.tools"++ },++ "openvibe.games": {++ "img": "/assets/img/previews/openvibe.games.jpg",++ "label": "openvibe.games"++ },++ "openvibe.community": {++ "img": "/assets/img/previews/openvibe.community.jpg",++ "label": "openvibe.community"++ },++ "openvibe.chat": {++ "img": "/assets/img/previews/openvibe.chat.jpg",++ "label": "openvibe.chat"++ },++ "powerchat.live": {++ "img": "/assets/img/previews/powerchat.live.jpg",++ "label": "powerchat.live"++ },++ "github.com": {++ "img": "/assets/img/previews/github.com.jpg",++ "label": "github.com"++ },++ "alexfrison.net": {++ "img": "/assets/img/previews/alexfrison.net.jpg",++ "label": "alexfrison.net"++ },++ "path:/": {++ "img": "/assets/img/previews/local_home.jpg",++ "label": "alexfrison.net"++ },++ "path:/resume": {++ "img": "/assets/img/previews/local_resume.jpg",++ "label": "alexfrison.net/resume"++ },++ "path:/portfolio": {++ "img": "/assets/img/previews/local_portfolio.jpg",++ "label": "alexfrison.net/portfolio"++ },++ "path:/portfolio/amazon-sql-copilot": {++ "img": "/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg",++ "label": "alexfrison.net/portfolio/amazon-sql-copilot"++ },++ "path:/portfolio/amazon-userscripts": {++ "img": "/assets/img/previews/local_portfolio_amazon-userscripts.jpg",++ "label": "alexfrison.net/portfolio/amazon-userscripts"++ },++ "path:/portfolio/gameserverlists": {++ "img": "/assets/img/previews/local_portfolio_gameserverlists.jpg",++ "label": "alexfrison.net/portfolio/gameserverlists"++ },++ "path:/portfolio/gameserverstats": {++ "img": "/assets/img/previews/local_portfolio_gameserverstats.jpg",++ "label": "alexfrison.net/portfolio/gameserverstats"++ },++ "path:/portfolio/hartools": {++ "img": "/assets/img/previews/local_portfolio_hartools.jpg",++ "label": "alexfrison.net/portfolio/hartools"++ },++ "path:/portfolio/inbound-pull-tool": {++ "img": "/assets/img/previews/local_portfolio_inbound-pull-tool.jpg",++ "label": "alexfrison.net/portfolio/inbound-pull-tool"++ },++ "path:/portfolio/irdr-autonomous-bot": {++ "img": "/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg",++ "label": "alexfrison.net/portfolio/irdr-autonomous-bot"++ },++ "path:/portfolio/irdr-quality-recap": {++ "img": "/assets/img/previews/local_portfolio_irdr-quality-recap.jpg",++ "label": "alexfrison.net/portfolio/irdr-quality-recap"++ },++ "path:/portfolio/live-translation": {++ "img": "/assets/img/previews/local_portfolio_live-translation.jpg",++ "label": "alexfrison.net/portfolio/live-translation"++ },++ "path:/portfolio/lyric-video-toolkit": {++ "img": "/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg",++ "label": "alexfrison.net/portfolio/lyric-video-toolkit"++ },++ "path:/portfolio/mp-select-mini": {++ "img": "/assets/img/previews/local_portfolio_mp-select-mini.jpg",++ "label": "alexfrison.net/portfolio/mp-select-mini"++ },++ "path:/portfolio/openvibe-community": {++ "img": "/assets/img/previews/local_portfolio_openvibe-community.jpg",++ "label": "alexfrison.net/portfolio/openvibe-community"++ },++ "path:/portfolio/openvibe-cutover": {++ "img": "/assets/img/previews/local_portfolio_openvibe-cutover.jpg",++ "label": "alexfrison.net/portfolio/openvibe-cutover"++ },++ "path:/portfolio/openvibe-games": {++ "img": "/assets/img/previews/local_portfolio_openvibe-games.jpg",++ "label": "alexfrison.net/portfolio/openvibe-games"++ },++ "path:/portfolio/openvibe-live": {++ "img": "/assets/img/previews/local_portfolio_openvibe-live.jpg",++ "label": "alexfrison.net/portfolio/openvibe-live"++ },++ "path:/portfolio/openvibe-media": {++ "img": "/assets/img/previews/local_portfolio_openvibe-media.jpg",++ "label": "alexfrison.net/portfolio/openvibe-media"++ },++ "path:/portfolio/openvibe-network": {++ "img": "/assets/img/previews/local_portfolio_openvibe-network.jpg",++ "label": "alexfrison.net/portfolio/openvibe-network"++ },++ "path:/portfolio/openvibe-sites": {++ "img": "/assets/img/previews/local_portfolio_openvibe-sites.jpg",++ "label": "alexfrison.net/portfolio/openvibe-sites"++ },++ "path:/portfolio/openvibe-tools": {++ "img": "/assets/img/previews/local_portfolio_openvibe-tools.jpg",++ "label": "alexfrison.net/portfolio/openvibe-tools"++ },++ "path:/portfolio/perf-harness": {++ "img": "/assets/img/previews/local_portfolio_perf-harness.jpg",++ "label": "alexfrison.net/portfolio/perf-harness"++ },++ "path:/portfolio/powerchat-developer-api": {++ "img": "/assets/img/previews/local_portfolio_powerchat-developer-api.jpg",++ "label": "alexfrison.net/portfolio/powerchat-developer-api"++ },++ "path:/portfolio/powerchat-payments": {++ "img": "/assets/img/previews/local_portfolio_powerchat-payments.jpg",++ "label": "alexfrison.net/portfolio/powerchat-payments"++ },++ "path:/portfolio/pytools-workshop": {++ "img": "/assets/img/previews/local_portfolio_pytools-workshop.jpg",++ "label": "alexfrison.net/portfolio/pytools-workshop"++ },++ "path:/portfolio/robotstreamer-toolkit": {++ "img": "/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg",++ "label": "alexfrison.net/portfolio/robotstreamer-toolkit"++ },++ "path:/portfolio/youtube-desktop-client": {++ "img": "/assets/img/previews/local_portfolio_youtube-desktop-client.jpg",++ "label": "alexfrison.net/portfolio/youtube-desktop-client"++ }++}@@@ -2,6 +2,20 @@"name": "Alex Frison","tagline": "Software engineer, data analyst and founder of OpenVibe.","headline": "I build the whole stack, then run it.",++ "strengths": [++ { "icon": "fa-solid fa-globe", "label": "Websites & web apps" },++ { "icon": "fa-solid fa-plug", "label": "APIs & integrations" },++ { "icon": "fa-solid fa-robot", "label": "AI agents" },++ { "icon": "fa-solid fa-gears", "label": "Automation" },++ { "icon": "fa-solid fa-microchip", "label": "Robotics & hardware" },++ { "icon": "fa-solid fa-chart-line", "label": "Data & analytics" },++ { "icon": "fa-solid fa-credit-card", "label": "Payments & billing" },++ { "icon": "fa-solid fa-tower-broadcast", "label": "Streaming & realtime" },++ { "icon": "fa-solid fa-shield-halved", "label": "Identity & security" },++ { "icon": "fa-solid fa-server", "label": "Servers & deploys" },++ { "icon": "fa-solid fa-gamepad", "label": "Games & 3D" },++ { "icon": "fa-solid fa-handshake", "label": "Sales & leadership" }++ ],"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." },@@@ -22,7 +36,7 @@"githubDisplay": "github.com/OpenVibers","linkedin": "https://www.linkedin.com/in/fris/","linkedinDisplay": "linkedin.com/in/fris",-- "resumePdf": "/Alex_Frison_Resume_Sep_2026_fde01332.pdf",++ "resumePdf": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.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.",@@@ -1,15 +1,15 @@{-- "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"++ "amber": "/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf",++ "blue": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",++ "cyan": "/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf",++ "default": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",++ "green": "/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf",++ "indigo": "/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf",++ "lime": "/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf",++ "orange": "/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf",++ "pink": "/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf",++ "red": "/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf",++ "teal": "/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf",++ "violet": "/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf",++ "yellow": "/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf"}@@@ -6,6 +6,7 @@ import ThemePicker from "../components/ThemePicker.astro";import profile from "../data/profile.json";import themePresets from "../data/theme-presets.json";import themePdfManifest from "../data/theme-pdf-manifest.json";++import linkPreviews from "../data/link-previews.json";interface Props {title?: string;@@@ -141,11 +142,18 @@ const imageUrl = new URL(image, site).toString();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.-- var accent = hslToHex(h, Math.max(55, Math.min(hsl[1], 90)), Math.max(48, Math.min(hsl[2], 68)));-- var violet = hslToHex(h + 45, 65, 65);-- var ember = hslToHex(h + 180, 80, 62);-- var mint = hslToHex(h + 150, 65, 55);++ // Toned down from the first version: saturation capped lower and lightness kept in a++ // narrower band, so picks stop reading as pure neon slabs and derived colors sit in++ // the same, slightly desaturated family.++ var accent = hslToHex(h, Math.max(48, Math.min(hsl[1], 76)), Math.max(46, Math.min(hsl[2], 62)));++ var violet = hslToHex(h + 40, 56, 62);++ var ember = hslToHex(h + 180, 64, 60);++ var mint = hslToHex(h + 150, 54, 52);var root = d.style;++ // Text on top of the accent: relative luminance decides dark ink vs white.++ var rgb = hexToRgbStr(accent).split(', ').map(function (v) { v = parseInt(v, 10) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });++ var lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];++ root.setProperty('--on-accent', lum > 0.36 ? '#0b0f1a' : '#ffffff');root.setProperty('--accent', accent);root.setProperty('--accent-rgb', hexToRgbStr(accent));root.setProperty('--violet', violet);@@@ -195,6 +203,8 @@ const imageUrl = new URL(image, site).toString();<main id="main"><slot /></main><Footer /><ThemePicker />++ <div class="lp" id="lp" aria-hidden="true"><div class="lp__img"><img alt="" decoding="async" /></div><div class="lp__cap"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i><span></span></div></div>++ <script type="application/json" id="lp-data" set:html={JSON.stringify(linkPreviews)} /><script>// ---- Resume PDF links: match whatever color was picked before paint ---@@@ -209,10 +219,10 @@ const imageUrl = new URL(image, site).toString();lenisScript.onload = () => {// @ts-ignoreconst L = window.Lenis;-- 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 });++ // Wheel/trackpad only. Smoothing touch scroll felt laggy on phones and swallowed++ // Chrome's pull-to-refresh, so touch stays fully native.++ if (!L || matchMedia('(pointer: coarse)').matches) return;++ const lenis = new L({ lerp: 0.11, wheelMultiplier: 0.95, smoothWheel: true });// @ts-ignorewindow.__lenis = lenis;const raf = (t: number) => { lenis.raf(t); requestAnimationFrame(raf); };@@@ -360,6 +370,58 @@ const imageUrl = new URL(image, site).toString();setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');});++ // ---- Link hover previews ----------------------------------------------------++ // Pre-rendered thumbnails only (see scripts/make-link-previews.mjs) — nothing is fetched++ // live. External links match by host, internal ones by pathname. Links that are already++ // image cards (project tiles) are skipped so the card doesn't duplicate what's under it.++ if (matchMedia('(hover: hover) and (pointer: fine)').matches) {++ const lp = document.getElementById('lp') as HTMLElement;++ const lpImg = lp.querySelector('img') as HTMLImageElement;++ const lpCap = lp.querySelector('.lp__cap span') as HTMLElement;++ const previews: Record<string, { img?: string; label: string }> = JSON.parse(document.getElementById('lp-data')?.textContent || '{}');++ const iconFor = (host: string) => host.includes('github') ? 'fa-brands fa-github' : host.includes('linkedin') ? 'fa-brands fa-linkedin-in' : 'fa-solid fa-arrow-up-right-from-square';++ const keyFor = (a: HTMLAnchorElement) => {++ try {++ const u = new URL(a.href, location.href);++ if (u.origin === location.origin) { const p = u.pathname.replace(/\/$/, '') || '/'; return previews['path:' + p] ? 'path:' + p : null; }++ const h = u.host.replace(/^www\./, '');++ return previews[h] ? h : (h.includes('linkedin') ? 'linkedin.com' : null);++ } catch { return null; }++ };++ let cur: HTMLAnchorElement | null = null, px = 0, py = 0, cx = 0, cy = 0, lpRaf = 0;++ const place = () => {++ cx += (px - cx) * 0.18; cy += (py - cy) * 0.18;++ const w = lp.offsetWidth || 290, h = lp.offsetHeight || 220;++ let x = cx + 20, y = cy + 22;++ const flip = x + w > innerWidth - 12;++ if (flip) x = cx - w - 20;++ if (y + h > innerHeight - 12) y = cy - h - 18;++ lp.classList.toggle('is-flip', flip);++ lp.style.setProperty('--x', `${Math.max(8, x)}px`); lp.style.setProperty('--y', `${Math.max(8, y)}px`);++ if (cur) lpRaf = requestAnimationFrame(place);++ };++ const hide = () => { cur = null; lp.classList.remove('is-on'); cancelAnimationFrame(lpRaf); };++ document.addEventListener('mousemove', (e) => { px = e.clientX; py = e.clientY; }, { passive: true });++ document.addEventListener('mouseover', (e) => {++ const a = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;++ if (!a || a === cur) return;++ if (a.querySelector('img') || a.closest('.nav__drawer, .lp')) return;++ const key = keyFor(a); if (!key) return;++ const p = previews[key] || { label: key };++ cur = a; cx = px; cy = py;++ lp.classList.toggle('lp--noimg', !p.img); lp.classList.remove('is-loaded');++ lpCap.textContent = p.label; (lp.querySelector('.lp__cap i') as HTMLElement).className = iconFor(key);++ if (p.img) { lpImg.onload = () => lp.classList.add('is-loaded'); if (lpImg.src.endsWith(p.img)) lp.classList.add('is-loaded'); else lpImg.src = p.img; }++ lp.classList.add('is-on');++ cancelAnimationFrame(lpRaf); place();++ });++ document.addEventListener('mouseout', (e) => {++ const a = (e.target as HTMLElement).closest('a[href]');++ if (a && a === cur && !a.contains(e.relatedTarget as Node)) hide();++ });++ addEventListener('scroll', hide, { passive: true });++ }++// ---- 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@@@ -64,6 +64,16 @@ main { position: relative; z-index: 1; }a { color: var(--accent); text-decoration: none; }a:hover { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }++/* Prose links: a gradient rule that thickens on hover, instead of the browser's default underline. */++p a, li a.inline, .sec-lead a, .now__text a, .hero__lead a, .ct__lead a, .resume__body a, .prose a {++ color: color-mix(in oklab, var(--accent) 78%, var(--fg)); font-weight: 600;++ background-image: linear-gradient(90deg, var(--accent), var(--accent-2));++ background-size: 100% 1.5px; background-repeat: no-repeat; background-position: 0 100%; padding-bottom: 1px;++ transition: background-size .3s var(--ease), color .25s var(--ease);++}++p a:hover, li a.inline:hover, .sec-lead a:hover, .now__text a:hover, .hero__lead a:hover, .ct__lead a:hover, .resume__body a:hover, .prose a:hover {++ color: var(--fg); background-size: 100% 2.5px; text-decoration: none;++}img, svg, video { max-width: 100%; display: block; }button { font: inherit; color: inherit; }@@@ -83,10 +93,18 @@ p { line-height: 1.7; color: var(--muted); margin: 0 0 1em; }strong { color: var(--fg); font-weight: 600; }.container { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter); }--.section { padding: clamp(72px, 10vw, 140px) 0; position: relative; }++.section { padding: clamp(52px, 7.5vw, 116px) 0; position: relative; }++/* A hairline between sections plus a faint alternating band so sections still read as distinct++ blocks when someone is flicking through the page fast. */++.section + .section::before, main > .section:not(.hero):first-child::before {++ content: ""; position: absolute; left: 50%; top: 0; transform: translateX(-50%); height: 1px;++ width: min(var(--container), calc(100% - 2 * var(--gutter)));++ background: linear-gradient(90deg, transparent, var(--border-strong) 20%, var(--border-strong) 80%, transparent);++}++main > .section:nth-of-type(even) { background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), .035) 18%, rgba(var(--accent-rgb), .035) 82%, transparent); }/* ---- Section headers --------------------------------------------------- */--.sec-head { display: grid; grid-template-columns: auto 1fr; gap: 18px 28px; align-items: end; margin-bottom: clamp(32px, 5vw, 56px); }++.sec-head { display: grid; grid-template-columns: auto 1fr; gap: 14px 22px; align-items: end; margin-bottom: clamp(22px, 3.5vw, 44px); }.sec-index {font-family: var(--font-mono); font-size: .8rem; letter-spacing: .22em; text-transform: uppercase;color: var(--accent); display: inline-flex; align-items: center; gap: 12px; margin-bottom: 14px;@@@ -96,14 +114,17 @@ strong { color: var(--fg); font-weight: 600; }.sec-title em { font-style: normal; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }.sec-lead { color: var(--muted); font-size: 1.08rem; max-width: 52ch; margin: 0; align-self: end; }.sec-num {-- grid-row: 1 / span 2; align-self: start;-- font-family: var(--font-display); font-weight: 700; font-size: clamp(3rem, 9vw, 7rem); line-height: .8;-- color: transparent; -webkit-text-stroke: 1px rgba(var(--accent-rgb), .45);++ grid-row: 1 / span 2; align-self: start; margin-top: 4px;++ display: inline-grid; place-items: center; width: 44px; height: 44px; border-radius: 12px;++ font-family: var(--font-mono); font-weight: 600; font-size: .86rem; letter-spacing: .06em;++ color: var(--accent); background: rgba(var(--accent-rgb), .1); border: 1px solid rgba(var(--accent-rgb), .3);++ box-shadow: 0 0 0 4px rgba(var(--accent-rgb), .06), 0 10px 24px -14px rgba(var(--accent-rgb), .8);user-select: none;}@media (max-width: 760px) {-- .sec-head { grid-template-columns: 1fr; }-- .sec-num { grid-row: auto; font-size: 3rem; }++ .sec-head { grid-template-columns: 1fr; gap: 12px; }++ .sec-num { display: none; }++ .sec-lead { font-size: 1rem; }}/* ---- Surfaces ------------------------------------------------------------ */@@@ -134,33 +155,41 @@ strong { color: var(--fg); font-weight: 600; }--btn-bg: transparent;position: relative; isolation: isolate;display: inline-flex; align-items: center; gap: 10px;-- padding: 13px 22px; border-radius: 999px;++ padding: 14px 24px; min-height: 48px; border-radius: 999px;border: 1px solid var(--border-strong);background: var(--btn-bg); color: var(--fg);-- font: 600 .95rem var(--font-body); letter-spacing: .005em;++ font: 600 .98rem/1 var(--font-body); letter-spacing: .005em;cursor: pointer; text-decoration: none; white-space: nowrap;transition: transform .35s var(--ease), border-color .3s var(--ease), background .3s var(--ease), color .3s var(--ease), box-shadow .35s var(--ease);will-change: transform;}--.btn:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .6); background: rgba(var(--accent-rgb), .08); }--.btn i { font-size: .9em; }--.btn--primary { --btn-bg: var(--accent); color: #fff; border-color: transparent; box-shadow: 0 10px 30px -12px rgba(var(--accent-rgb), .8); overflow: hidden; }++.btn:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .6); background: rgba(var(--accent-rgb), .1); transform: translateY(-1px); }++.btn i { font-size: .95em; }++/* Primary: text color comes from --on-accent (Base.astro derives it from the accent's luminance),++ so a lime or yellow accent gets dark ink instead of unreadable white-on-bright. A soft top++ highlight and inner ring keep it from reading as one flat slab of color. */++.btn--primary {++ --btn-bg: var(--accent); color: var(--on-accent); border-color: transparent; overflow: hidden;++ background-image: linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,0) 60%);++ box-shadow: inset 0 1px 0 rgba(255,255,255,.28), inset 0 0 0 1px rgba(0,0,0,.08), 0 12px 30px -12px rgba(var(--accent-rgb), .75);++}.btn--primary::after {-- content: ""; position: absolute; inset: 0; z-index: -1;-- background: var(--grad); opacity: 0; transition: opacity .35s var(--ease);++ content: ""; position: absolute; top: 0; bottom: 0; left: -40%; width: 30%; z-index: -1;++ background: linear-gradient(100deg, transparent, rgba(255,255,255,.35), transparent); transform: skewX(-15deg);++ transition: left .6s var(--ease);}--.btn--primary:hover { background: var(--accent); color: #fff; }--.btn--primary:hover::after { opacity: 1; }--.btn--ghost { border-color: transparent; color: var(--muted); }--.btn--ghost:hover { color: var(--fg); background: var(--surface-2); border-color: transparent; }++.btn--primary:hover { background-color: var(--accent); color: var(--on-accent); filter: saturate(1.08) brightness(1.05); }++.btn--primary:hover::after { left: 120%; }++.btn--ghost { border-color: var(--border); color: color-mix(in oklab, var(--fg) 88%, var(--muted)); background: transparent; }++.btn--ghost:hover { color: var(--fg); background: var(--surface-2); border-color: var(--border-strong); }.btn__arrow { display: inline-block; transition: transform .35s var(--ease); }.btn:hover .btn__arrow { transform: translateX(4px); }/* ---- Chips ---------------------------------------------------------------- */.chip {display: inline-flex; align-items: center; gap: 6px;-- padding: 5px 12px; border-radius: 999px;-- font: 500 .74rem var(--font-mono); letter-spacing: .02em;++ padding: 7px 13px; border-radius: 999px;++ font: 500 .8rem var(--font-mono); letter-spacing: .02em;background: rgba(var(--accent-rgb), .08);border: 1px solid rgba(var(--accent-rgb), .22);color: color-mix(in oklab, var(--accent) 70%, var(--fg));@@@ -246,3 +275,29 @@ html::-webkit-scrollbar { display: none; width: 0; height: 0; }.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; } }++++/* ---- Link hover previews ---------------------------------------------------- */++/* Pre-rendered thumbnails (scripts/make-link-previews.mjs); Base.astro positions this card++ next to the pointer for any link whose destination has one. Pointer devices only. */++.lp {++ position: fixed; left: 0; top: 0; z-index: 150; width: 290px; pointer-events: none;++ border-radius: 16px; overflow: hidden; background: var(--surface-solid); border: 1px solid var(--border-strong);++ box-shadow: 0 30px 70px -20px rgba(0,0,0,.65), 0 0 0 1px rgba(var(--accent-rgb), .12), 0 0 40px -10px rgba(var(--accent-rgb), .35);++ opacity: 0; transform: translate3d(var(--x, 0px), var(--y, 0px), 0) scale(.9) rotate(-1.5deg);++ transform-origin: top left; transition: opacity .22s var(--ease), transform .38s var(--ease);++}++.lp.is-on { opacity: 1; transform: translate3d(var(--x, 0px), var(--y, 0px), 0) scale(1) rotate(0); }++.lp.is-flip { transform-origin: top right; }++.lp__img { position: relative; aspect-ratio: 16 / 10; background: var(--bg-3); overflow: hidden; }++.lp__img img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; opacity: 0; transition: opacity .35s var(--ease), transform 4s linear; transform: scale(1.02); }++.lp.is-loaded .lp__img img { opacity: 1; transform: scale(1); }++.lp__img::before { content: ""; position: absolute; inset: 0; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.12) 50%, transparent 70%); background-size: 250% 100%; animation: lpShimmer 1.4s linear infinite; }++.lp.is-loaded .lp__img::before { animation: lpSheen 1.1s var(--ease) 1 forwards; }++@keyframes lpShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }++@keyframes lpSheen { from { background-position: 120% 0; opacity: 1; } to { background-position: -120% 0; opacity: 0; } }++.lp__img::after { content: ""; position: absolute; inset: auto 0 0; height: 40%; background: linear-gradient(to top, rgba(0,0,0,.35), transparent); pointer-events: none; }++.lp__cap { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font: 500 .74rem var(--font-mono); color: var(--muted); letter-spacing: .02em; border-top: 1px solid var(--border); }++.lp__cap i { color: var(--accent); font-size: .8rem; }++.lp__cap span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.lp--noimg .lp__img { display: none; }++@media (hover: none), (pointer: coarse) { .lp { display: none; } }
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Sep_2026_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_amber_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_cyan_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_green_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_green_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_indigo_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_lime_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_orange_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_pink_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_red_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_red_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_teal_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_violet_fde01332.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_1c4cdbb2.pdf differ
Binary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf differ
deleted file mode 100644
deleted file mode 100644
Binary files a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf and /dev/null differ
Binary files a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf and /dev/null differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/alexfrison.net.jpg differ
Binary files /dev/null and b/public/assets/img/previews/alexfrison.net.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/github.com.jpg differ
Binary files /dev/null and b/public/assets/img/previews/github.com.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_home.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_home.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/local_resume.jpg differ
Binary files /dev/null and b/public/assets/img/previews/local_resume.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.chat.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.chat.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.community.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.community.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.games.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.games.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.live.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.live.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.media.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.media.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.network.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.network.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/openvibe.tools.jpg differ
Binary files /dev/null and b/public/assets/img/previews/openvibe.tools.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/public/assets/img/previews/powerchat.live.jpg differ
Binary files /dev/null and b/public/assets/img/previews/powerchat.live.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+// Pre-renders the hover-preview thumbnails for every external site and internal page the site
+// links to, so hovering a link never fetches a live page. Run after `npm run build` (internal
+// pages are shot from dist/ served locally). Output: public/assets/img/previews/*.jpg and
+// src/data/link-previews.json (key → { img, label }).
+import { chromium } from '/home/workstation/OpenVibers/OpenVibe.Games/node_modules/playwright/index.mjs';
+import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const profile = JSON.parse(readFileSync(`${ROOT}src/data/profile.json`, 'utf8'));
+const OUT = `${ROOT}public/assets/img/previews`;
+const W = 1280, H = 800, SCALE = 0.5; // 640x400 output
+
+const external = [
+ ...profile.network.map((n) => n.url),
+ 'https://powerchat.live',
+ 'https://github.com/OpenVibers',
+ 'https://alexfrison.net',
+];
+const internal = ['/', '/resume/', '/portfolio/', ...readdirSync(`${ROOT}dist/portfolio`).filter((d) => existsSync(`${ROOT}dist/portfolio/${d}/index.html`)).map((d) => `/portfolio/${d}/`)];
+
+const server = spawn('python3', ['-m', 'http.server', '4399'], { cwd: `${ROOT}dist`, stdio: 'ignore' });
+await new Promise((r) => setTimeout(r, 800));
+
+const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
+const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: 'dark',
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' });
+const map = {};
+const shoot = async (url, key, label, file) => {
+ const page = await ctx.newPage();
+ try {
+ await page.addInitScript(() => { try { localStorage.setItem('ov_theme_asked', '1'); } catch {} });
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 }).catch(() => {});
+ await page.waitForTimeout(url.startsWith('http://localhost') ? 2500 : 1500);
+ await page.screenshot({ path: `${OUT}/${file}.jpg`, type: 'jpeg', quality: 74, clip: { x: 0, y: 0, width: W, height: H } });
+ map[key] = { img: `/assets/img/previews/${file}.jpg`, label };
+ console.log('ok', key);
+ } catch (e) { console.log('FAIL', key, e.message); }
+ await page.close();
+};
+for (const u of external) {
+ const host = new URL(u).host.replace(/^www\./, '');
+ await shoot(u, host, host, host.replace(/[^a-z0-9.-]/gi, '_'));
+}
+for (const p of internal) {
+ const label = p === '/' ? 'alexfrison.net' : `alexfrison.net${p.replace(/\/$/, '')}`;
+ await shoot(`http://localhost:4399${p}`, `path:${p.replace(/\/$/, '') || '/'}`, label, 'local' + (p.replace(/\/$/, '') || '/home').replace(/\//g, '_'));
+}
+await browser.close();
+server.kill();
+writeFileSync(`${ROOT}src/data/link-previews.json`, JSON.stringify(map, null, 2) + '\n');
+console.log('wrote', Object.keys(map).length, 'previews');
@@ -43,14 +43,14 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -43,14 +43,14 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
<a href="#contact" class="btn btn--ghost" data-magnetic><i class="fa-solid fa-paper-plane"></i> Get in touch</a>
<a href="#contact" class="btn btn--ghost" data-magnetic><i class="fa-solid fa-paper-plane"></i> Get in touch</a>
</div>
</div>
- <ul class="hero__tags hero__in" style="--d:950ms">
- <li class="chip"><i class="fa-solid fa-robot"></i> AI agents</li>
- <li class="chip"><i class="fa-solid fa-gears"></i> Automation</li>
- <li class="chip"><i class="fa-solid fa-microchip"></i> Robotics</li>
- <li class="chip"><i class="fa-solid fa-tower-broadcast"></i> Streaming infra</li>
- <li class="chip chip--ember"><i class="fa-solid fa-credit-card"></i> Payments</li>
- <li class="chip chip--mint"><i class="fa-solid fa-server"></i> Ops & deploys</li>
- </ul>
+ <div class="hero__tags hero__in" style="--d:950ms">
+ <div class="hero__tags-label"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> What I build and do</div>
+ <ul class="hero__tags-grid" aria-label="Areas of work">
+ {profile.strengths.map((t, i) => (
+ <li class="hero__tag" style={`--i:${i}`}><span class="hero__tag-ico"><i class={t.icon} aria-hidden="true"></i></span><span>{t.label}</span></li>
+ ))}
+ </ul>
+ </div>
</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">
@@ -136,17 +136,49 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -136,17 +136,49 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
min-height: 2.5em;
min-height: 2.5em;
}
}
.hero__role-static { color: var(--dim); font-weight: 500; }
.hero__role-static { color: var(--dim); font-weight: 500; }
- .hero__rotator { position: relative; color: var(--accent); padding-bottom: .12em; }
+ .hero__rotator {
+ position: relative; color: var(--accent); padding-bottom: .14em;
+ text-shadow: 0 0 22px rgba(var(--accent-rgb), .35);
+ }
+ /* The rule under the word is a slow-moving gradient sheen; the caret is a thin glowing bar
+ that sits inline after the last glyph (not an underscore fighting the rule for the same
+ baseline). While the scramble runs the caret goes solid, like a terminal mid-output. */
.hero__rotator::before {
.hero__rotator::before {
- content: ""; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%;
- background: var(--grad); border-radius: 2px; opacity: .55;
+ content: ""; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; border-radius: 2px;
+ background: linear-gradient(90deg, var(--accent), var(--violet), var(--accent-2), var(--accent-3), var(--accent));
+ background-size: 300% 100%; opacity: .6; animation: ruleSheen 6s linear infinite;
+ mask-image: linear-gradient(90deg, #000 85%, transparent); -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent);
}
}
- .hero__rotator::after { content: "_"; margin-left: 2px; color: var(--accent-2); animation: caret 1s steps(2) infinite; }
- @keyframes caret { 50% { opacity: 0; } }
+ @keyframes ruleSheen { to { background-position: -300% 0; } }
+ .hero__rotator::after {
+ content: ""; display: inline-block; width: .085em; height: .82em; margin-left: .14em; vertical-align: -.04em; border-radius: 2px;
+ background: linear-gradient(180deg, var(--accent-2), var(--accent)); box-shadow: 0 0 14px rgba(var(--accent-2-rgb), .8);
+ animation: caret 1.15s ease-in-out infinite;
+ }
+ .hero__rotator.is-scrambling::after { animation: none; opacity: 1; }
+ .hero__rotator.is-scrambling { animation: glitchShift .18s steps(2) infinite; }
+ @keyframes caret { 0%, 100% { opacity: 1; } 50% { opacity: .15; } }
+ @keyframes glitchShift { 0% { text-shadow: -1px 0 rgba(0, 220, 255, .55), 1px 0 rgba(255, 60, 120, .55), 0 0 22px rgba(var(--accent-rgb), .35); } 100% { text-shadow: 1px 0 rgba(0, 220, 255, .55), -1px 0 rgba(255, 60, 120, .55), 0 0 22px rgba(var(--accent-rgb), .35); } }
.hero__lead { max-width: 58ch; font-size: clamp(1rem, 1.3vw, 1.12rem); color: var(--muted); }
.hero__lead { max-width: 58ch; font-size: clamp(1rem, 1.3vw, 1.12rem); color: var(--muted); }
.hero__cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 1.6rem 0 1.4rem; }
.hero__cta { display: flex; gap: 12px; flex-wrap: wrap; margin: 1.6rem 0 1.4rem; }
- .hero__tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+ .hero__tags { max-width: 620px; }
+ .hero__tags-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font: 500 .72rem var(--font-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--dim); }
+ .hero__tags-label i { color: var(--accent); }
+ .hero__tags-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
+ .hero__tag {
+ display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; min-height: 44px;
+ background: var(--surface); border: 1px solid var(--border); color: var(--fg);
+ font: 500 .82rem/1.2 var(--font-body); letter-spacing: -.005em;
+ opacity: 0; animation: tagIn .6s var(--ease) forwards; animation-delay: calc(1000ms + var(--i) * 45ms);
+ transition: border-color .3s var(--ease), background .3s var(--ease), transform .3s var(--ease);
+ }
+ .hero__tag:hover { border-color: rgba(var(--accent-rgb), .5); background: var(--surface-2); transform: translateY(-2px); }
+ @keyframes tagIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
+ .hero__tag-ico { flex: none; width: 28px; height: 28px; border-radius: 9px; display: inline-grid; place-items: center; font-size: .78rem; background: rgba(var(--accent-rgb), .14); color: var(--accent); }
+ .hero__tag:nth-child(3n+2) .hero__tag-ico { background: rgba(var(--accent-2-rgb), .14); color: var(--accent-2); }
+ .hero__tag:nth-child(3n) .hero__tag-ico { background: rgba(var(--accent-3-rgb), .14); color: var(--accent-3); }
+ @media (max-width: 520px) { .hero__tags-grid { grid-template-columns: 1fr 1fr; } .hero__tag { font-size: .78rem; padding: 7px 9px; min-height: 42px; } }
/* Portrait */
/* Portrait */
.hero__visual { display: grid; place-items: center; min-width: 0; }
.hero__visual { display: grid; place-items: center; min-width: 0; }
@@ -294,6 +326,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -294,6 +326,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
const glyphs = '!<>-_\\/[]{}—=+*^?#0123456789';
const glyphs = '!<>-_\\/[]{}—=+*^?#0123456789';
let i = 0, busy = false;
let i = 0, busy = false;
const scramble = (to: string) => new Promise<void>((res) => {
const scramble = (to: string) => new Promise<void>((res) => {
+ rot.classList.add('is-scrambling');
const from = rot.textContent || '';
const from = rot.textContent || '';
const len = Math.max(from.length, to.length);
const len = Math.max(from.length, to.length);
const q = Array.from({ length: len }, (_, k) => ({ from: from[k] || '', to: to[k] || '', start: Math.floor(Math.random() * 14), end: Math.floor(Math.random() * 14) + 14, ch: '' }));
const q = Array.from({ length: len }, (_, k) => ({ from: from[k] || '', to: to[k] || '', start: Math.floor(Math.random() * 14), end: Math.floor(Math.random() * 14) + 14, ch: '' }));
@@ -306,7 +339,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -306,7 +339,7 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
else out += s.from;
else out += s.from;
}
}
rot.innerHTML = out;
rot.innerHTML = out;
- if (done === q.length) res(); else { frame++; requestAnimationFrame(step); }
+ if (done === q.length) { rot.classList.remove('is-scrambling'); res(); } else { frame++; requestAnimationFrame(step); }
};
};
step();
step();
});
});
@@ -451,5 +484,6 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
@@ -451,5 +484,6 @@ const offsets = words.map((_, i) => words.slice(0, i).join(" ").length + (i ? 1
</script>
</script>
<style is:global>
<style is:global>
- .hero__rotator .scr { color: var(--accent-2); opacity: .8; }
+ .hero__rotator .scr { display: inline-block; color: var(--accent-2); opacity: .85; animation: scrJit .09s steps(2) infinite; }
+ @keyframes scrJit { 0% { transform: translateY(-1px) skewX(-6deg); opacity: .6; } 100% { transform: translateY(1px) skewX(4deg); opacity: .95; } }
</style>
</style>
@@ -28,6 +28,7 @@ const path = Astro.url.pathname;
@@ -28,6 +28,7 @@ const path = Astro.url.pathname;
</span>
</span>
<span class="nav__name">Alex Frison</span>
<span class="nav__name">Alex Frison</span>
</a>
</a>
+ <span class="nav__where" id="nav-where" aria-live="polite"><i class="fa-solid fa-house" aria-hidden="true"></i><span class="nav__where-txt">Home</span></span>
<nav class="nav__links" aria-label="Primary">
<nav class="nav__links" aria-label="Primary">
<ul>
<ul>
@@ -57,7 +58,7 @@ const path = Astro.url.pathname;
@@ -57,7 +58,7 @@ const path = Astro.url.pathname;
<ul>
<ul>
{links.map((l, i) => (
{links.map((l, i) => (
<li style={`--i:${i}`}>
<li style={`--i:${i}`}>
- <a href={l.href} class="nav__drawer-link" aria-current={path.startsWith(`/${l.id}`) ? "page" : undefined}>
+ <a href={l.href} class="nav__drawer-link" data-nav={l.id} aria-current={path.startsWith(`/${l.id}`) ? "page" : undefined}>
<span class="nav__drawer-ico"><i class={l.icon} aria-hidden="true"></i></span>
<span class="nav__drawer-ico"><i class={l.icon} aria-hidden="true"></i></span>
<span class="nav__drawer-text">{l.label}</span>
<span class="nav__drawer-text">{l.label}</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>
@@ -130,6 +131,22 @@ const path = Astro.url.pathname;
@@ -130,6 +131,22 @@ const path = Astro.url.pathname;
}
}
@media (prefers-reduced-motion: reduce) { .nav__mark-comet { animation: none; } }
@media (prefers-reduced-motion: reduce) { .nav__mark-comet { animation: none; } }
.nav__name { font-size: .98rem; }
.nav__name { font-size: .98rem; }
+ /* Current-section readout: swaps icon + label as you scroll into each section. */
+ .nav__where {
+ display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px 0 10px; border-radius: 999px; flex: none;
+ background: rgba(var(--accent-rgb), .1); border: 1px solid rgba(var(--accent-rgb), .25);
+ font: 500 .72rem var(--font-mono); letter-spacing: .08em; text-transform: uppercase; color: var(--fg);
+ opacity: 0; transform: translateX(-6px); transition: opacity .35s var(--ease), transform .35s var(--ease); overflow: hidden;
+ }
+ .nav__where i { color: var(--accent); font-size: .8rem; }
+ .nav.is-scrolled .nav__where { opacity: 1; transform: none; }
+ .nav__where.is-swap .nav__where-txt, .nav__where.is-swap i { animation: whereIn .45s var(--ease); }
+ @keyframes whereIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+ @media (max-width: 920px) { .nav__where { margin-left: auto; } }
+ /* Phones: the drawer has its own dark-mode switch, so the standalone toggle goes; the section
+ chip drops its label below 520px so the PDF button, palette and burger always fit. */
+ @media (max-width: 560px) { .nav__inner { gap: 8px; padding: 0 8px 0 10px; } .nav__icon[data-theme-toggle] { display: none; } }
+ @media (max-width: 520px) { .nav__where .nav__where-txt { display: none; } .nav__where { padding: 0 10px; } }
.nav__links { flex: 1; display: flex; justify-content: center; }
.nav__links { flex: 1; display: flex; justify-content: center; }
.nav__links ul { position: relative; list-style: none; margin: 0; padding: 4px; display: flex; gap: 2px; border-radius: 999px; }
.nav__links ul { position: relative; list-style: none; margin: 0; padding: 4px; display: flex; gap: 2px; border-radius: 999px; }
@@ -151,7 +168,7 @@ const path = Astro.url.pathname;
@@ -151,7 +168,7 @@ const path = Astro.url.pathname;
.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.nav__end { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.nav__cta {
.nav__cta {
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px;
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px;
- background: var(--accent); color: #fff; font: 600 .82rem var(--font-body); text-decoration: none;
+ background: var(--accent); color: var(--on-accent); font: 600 .82rem var(--font-body); text-decoration: none;
box-shadow: 0 8px 22px -10px rgba(var(--accent-rgb), .9);
box-shadow: 0 8px 22px -10px rgba(var(--accent-rgb), .9);
transition: transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s;
transition: transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s;
}
}
@@ -194,13 +211,18 @@ const path = Astro.url.pathname;
@@ -194,13 +211,18 @@ const path = Astro.url.pathname;
.nav__drawer-sep { height: 1px; margin: 6px 10px; background: var(--border); flex: none; }
.nav__drawer-sep { height: 1px; margin: 6px 10px; background: var(--border); flex: none; }
.nav__drawer-link {
.nav__drawer-link {
- display: flex; align-items: center; gap: 13px; width: 100%; padding: 10px 12px; border-radius: 14px;
+ position: relative; display: flex; align-items: center; gap: 13px; width: 100%; padding: 10px 12px; border-radius: 14px;
color: var(--fg); font: 600 1rem var(--font-display); text-decoration: none;
color: var(--fg); font: 600 1rem var(--font-display); text-decoration: none;
background: transparent; border: 1px solid transparent; cursor: pointer; text-align: left;
background: transparent; border: 1px solid transparent; cursor: pointer; text-align: left;
transition: background .2s var(--ease), border-color .2s var(--ease), transform .2s var(--ease);
transition: background .2s var(--ease), border-color .2s var(--ease), transform .2s var(--ease);
}
}
.nav__drawer-link:hover, .nav__drawer-link:active { background: var(--surface-2); border-color: var(--border); text-decoration: none; transform: translateX(2px); }
.nav__drawer-link:hover, .nav__drawer-link:active { background: var(--surface-2); border-color: var(--border); text-decoration: none; transform: translateX(2px); }
.nav__drawer-link[aria-current="page"] { background: var(--surface); border-color: var(--border-strong); }
.nav__drawer-link[aria-current="page"] { background: var(--surface); border-color: var(--border-strong); }
+ .nav__drawer-link::before { content: ""; position: absolute; left: 0; top: 22%; bottom: 22%; width: 3px; border-radius: 3px; background: var(--grad); transform: scaleY(0); transition: transform .35s var(--ease); }
+ .nav__drawer-link.is-active { background: rgba(var(--accent-rgb), .1); border-color: rgba(var(--accent-rgb), .35); }
+ .nav__drawer-link.is-active::before { transform: scaleY(1); }
+ .nav__drawer-link.is-active .nav__drawer-ico { background: var(--grad); color: var(--on-accent); border-color: transparent; box-shadow: 0 8px 20px -10px rgba(var(--accent-rgb), .9); }
+ .nav__drawer-link.is-active .nav__drawer-go { opacity: 1; transform: none; color: var(--accent); }
.nav__drawer-ico {
.nav__drawer-ico {
flex: none; width: 38px; height: 38px; border-radius: 11px; display: inline-grid; place-items: center;
flex: none; width: 38px; height: 38px; border-radius: 11px; display: inline-grid; place-items: center;
background: rgba(var(--accent-rgb), .12); color: var(--accent); font-size: .95rem;
background: rgba(var(--accent-rgb), .12); color: var(--accent); font-size: .95rem;
@@ -231,10 +253,10 @@ const path = Astro.url.pathname;
@@ -231,10 +253,10 @@ const path = Astro.url.pathname;
.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-moon { opacity: 0; transform: rotate(90deg) scale(.6); }
.nav__switch[aria-checked="false"] .nav__switch-knob .fa-sun { opacity: 1; transform: none; }
.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__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: var(--on-accent); }
.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-link--cta .nav__drawer-go { color: #fff; opacity: .8; }
+ .nav__drawer-ico--cta { background: rgba(255,255,255,.2); color: var(--on-accent); border-color: rgba(255,255,255,.3); }
+ .nav__drawer-link--cta .nav__drawer-go { color: var(--on-accent); opacity: .8; }
@media (max-width: 900px) { .nav__links a { padding: 8px 10px; font-size: .84rem; } }
@media (max-width: 900px) { .nav__links a { padding: 8px 10px; font-size: .84rem; } }
@media (max-width: 760px) {
@media (max-width: 760px) {
@@ -296,6 +318,20 @@ const path = Astro.url.pathname;
@@ -296,6 +318,20 @@ const path = Astro.url.pathname;
// Sliding pill follows the active section
// Sliding pill follows the active section
const list = nav.querySelector<HTMLUListElement>('.nav__links ul');
const list = nav.querySelector<HTMLUListElement>('.nav__links ul');
const anchors = Array.from(nav.querySelectorAll<HTMLAnchorElement>('.nav__links a[data-nav]'));
const anchors = Array.from(nav.querySelectorAll<HTMLAnchorElement>('.nav__links a[data-nav]'));
+ const drawerAnchors = Array.from(drawer.querySelectorAll<HTMLAnchorElement>('a[data-nav]'));
+ const where = document.getElementById('nav-where');
+ const whereIcon = where?.querySelector('i'), whereTxt = where?.querySelector('.nav__where-txt');
+ // Sections that aren't nav items still get a readout of their own.
+ const extraWhere: Record<string, [string, string]> = { about: ['Profile', 'fa-solid fa-user'], metrics: ['At a glance', 'fa-solid fa-chart-simple'] };
+ const setWhere = (id: string) => {
+ if (!where || !whereIcon || !whereTxt) return;
+ const src = anchors.find((a) => a.dataset.nav === id);
+ const icon = src ? src.querySelector('i')!.className : (extraWhere[id]?.[1] || 'fa-solid fa-house');
+ const label = src ? (src.textContent || '').trim() : (extraWhere[id]?.[0] || 'Home');
+ if (whereTxt.textContent === label) return;
+ whereIcon.className = icon; whereTxt.textContent = label;
+ where.classList.remove('is-swap'); void where.offsetWidth; where.classList.add('is-swap');
+ };
const movePill = (a: HTMLAnchorElement | null) => {
const movePill = (a: HTMLAnchorElement | null) => {
if (!list) return;
if (!list) return;
if (!a) { list.style.setProperty('--po', '0'); return; }
if (!a) { list.style.setProperty('--po', '0'); return; }
@@ -307,7 +343,7 @@ const path = Astro.url.pathname;
@@ -307,7 +343,7 @@ const path = Astro.url.pathname;
anchors.forEach((a) => a.addEventListener('mouseenter', () => movePill(a)));
anchors.forEach((a) => a.addEventListener('mouseenter', () => movePill(a)));
list?.addEventListener('mouseleave', () => movePill(anchors.find((a) => a.classList.contains('is-active')) || null));
list?.addEventListener('mouseleave', () => movePill(anchors.find((a) => a.classList.contains('is-active')) || null));
- const sections = ['top', ...anchors.map((a) => a.dataset.nav!)].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
+ const sections = ['top', 'metrics', 'about', ...anchors.map((a) => a.dataset.nav!)].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
if (sections.length) {
if (sections.length) {
let lastSection = '';
let lastSection = '';
const markPulse = document.querySelector('#nav-mark .nav__mark-pulse');
const markPulse = document.querySelector('#nav-mark .nav__mark-pulse');
@@ -316,6 +352,8 @@ const path = Astro.url.pathname;
@@ -316,6 +352,8 @@ const path = Astro.url.pathname;
if (!vis) return;
if (!vis) return;
const id = vis.target.id === 'top' ? '' : vis.target.id;
const id = vis.target.id === 'top' ? '' : vis.target.id;
anchors.forEach((a) => a.classList.toggle('is-active', a.dataset.nav === id));
anchors.forEach((a) => a.classList.toggle('is-active', a.dataset.nav === id));
+ drawerAnchors.forEach((a) => a.classList.toggle('is-active', a.dataset.nav === id));
+ setWhere(id);
movePill(anchors.find((a) => a.dataset.nav === id) || null);
movePill(anchors.find((a) => a.dataset.nav === id) || null);
// The brand mark gives a quiet pulse each time you cross into a new section — a small
// The brand mark gives a quiet pulse each time you cross into a new section — a small
// sign the page is tracking where you are, not just a static logo.
// sign the page is tracking where you are, not just a static logo.
@@ -64,14 +64,14 @@ const edges = [
@@ -64,14 +64,14 @@ const edges = [
<div class="arch card" data-reveal>
<div class="arch card" data-reveal>
<div class="arch__copy">
<div class="arch__copy">
- <div class="eyebrow">How it fits together</div>
- <h3>Identity in the middle, contracts between everything.</h3>
- <p>OpenVibe.Network is the OIDC provider and wallet. Every other service verifies RS256 tokens offline against its cached key set, calls Media over a signed multi-tenant API, and renders the same shared navbar, themes and notifications. Contracts are written down before code moves, and legacy domains 301 into the new network so no link ever died.</p>
+ <div class="eyebrow"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> How it fits together</div>
+ <h3>One login, one wallet, seven products that feel like one.</h3>
+ <p>Sign in once and every OpenVibe site knows who you are, what you own and what you look like. The identity service in the middle handles accounts, security and the shared virtual currency; every other product plugs into it through clear, documented APIs. That's the same playbook I bring to any company: secure single sign-on, payments and APIs designed so new products can be added without breaking the old ones.</p>
<ul class="arch__facts">
<ul class="arch__facts">
- <li><i class="fa-solid fa-check"></i> Zero-downtime deploys via systemd socket activation</li>
- <li><i class="fa-solid fa-check"></i> Local → B2 → R2 media tiering with presigned playback</li>
- <li><i class="fa-solid fa-check"></i> Budget-metered, provider-agnostic AI jobs</li>
- <li><i class="fa-solid fa-check"></i> Dry-run-first migrations, three-window cutover runbook</li>
+ <li><i class="fa-solid fa-rotate"></i> Updates ship with zero downtime, so users never see an outage</li>
+ <li><i class="fa-solid fa-cloud-arrow-up"></i> Media storage tiers automatically from fast local disks to cheap cloud storage</li>
+ <li><i class="fa-solid fa-robot"></i> AI features run on a budget-capped, vendor-neutral layer (OpenAI, Anthropic or local models)</li>
+ <li><i class="fa-solid fa-shield-halved"></i> Every migration is rehearsed as a dry run before it touches real users</li>
</ul>
</ul>
</div>
</div>
<svg class="arch__svg" viewBox="0 0 800 420" role="img" aria-label="Diagram: OpenVibe.Network at the center connected to Live, Media, Tools, Games, Community and 13 static sites; Live also talks to Media.">
<svg class="arch__svg" viewBox="0 0 800 420" role="img" aria-label="Diagram: OpenVibe.Network at the center connected to Live, Media, Tools, Games, Community and 13 static sites; Live also talks to Media.">
@@ -141,7 +141,8 @@ const edges = [
@@ -141,7 +141,8 @@ const edges = [
@media (max-width: 560px) { .bento { grid-template-columns: 1fr; } .tile--big, .tile--community, .tile--sites { grid-column: span 1; } .tile--big { min-height: 320px; } }
@media (max-width: 560px) { .bento { grid-template-columns: 1fr; } .tile--big, .tile--community, .tile--sites { grid-column: span 1; } .tile--big { min-height: 320px; } }
/* Architecture */
/* Architecture */
- .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); }
+ .arch { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(20px, 4vw, 48px); align-items: center; padding: clamp(22px, 3.5vw, 40px); margin-top: clamp(16px, 3vw, 28px); }
+ .arch .eyebrow { display: inline-flex; align-items: center; gap: 8px; }
.arch__copy h3 { font-size: clamp(1.3rem, 2.2vw, 1.8rem); margin-bottom: .6em; }
.arch__copy h3 { font-size: clamp(1.3rem, 2.2vw, 1.8rem); margin-bottom: .6em; }
.arch__facts { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
.arch__facts { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
.arch__facts li { display: flex; gap: 10px; align-items: flex-start; color: var(--muted); font-size: .9rem; }
.arch__facts li { display: flex; gap: 10px; align-items: flex-start; color: var(--muted); font-size: .9rem; }
@@ -146,7 +146,7 @@ const cats = Array.from(new Set(projects.map((p) => p.data.category)));
@@ -146,7 +146,7 @@ const cats = Array.from(new Set(projects.map((p) => p.data.category)));
}
}
.work__all-ico {
.work__all-ico {
flex: none; width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center;
flex: none; width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center;
- background: var(--grad); color: #fff; font-size: 1.25rem; box-shadow: var(--shadow-glow);
+ background: var(--grad); color: var(--on-accent); font-size: 1.25rem; box-shadow: var(--shadow-glow);
animation: workAllPulse 2.6s var(--ease) infinite;
animation: workAllPulse 2.6s var(--ease) infinite;
}
}
@keyframes workAllPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } }
@keyframes workAllPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } }
@@ -59,7 +59,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
@@ -59,7 +59,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
.tp-close:hover { color: var(--fg); border-color: var(--border-strong); }
.tp-close:hover { color: var(--fg); border-color: var(--border-strong); }
.tp-icon {
.tp-icon {
width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; margin-bottom: 16px;
width: 52px; height: 52px; border-radius: 16px; display: grid; place-items: center; margin-bottom: 16px;
- background: var(--grad); color: #fff; font-size: 1.3rem; box-shadow: var(--shadow-glow);
+ background: var(--grad); color: var(--on-accent); font-size: 1.3rem; box-shadow: var(--shadow-glow);
}
}
.tp-panel h2 { font-size: 1.4rem; margin: 0 0 8px; }
.tp-panel h2 { font-size: 1.4rem; margin: 0 0 8px; }
.tp-lead { color: var(--muted); font-size: .92rem; margin: 0 0 18px; line-height: 1.6; }
.tp-lead { color: var(--muted); font-size: .92rem; margin: 0 0 18px; line-height: 1.6; }
@@ -71,7 +71,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
@@ -71,7 +71,7 @@ const PRESETS = PRESETS_DATA.map((p) => p.hex);
font: 600 .88rem var(--font-body); cursor: pointer; transition: all .25s var(--ease);
font: 600 .88rem var(--font-body); cursor: pointer; transition: all .25s var(--ease);
}
}
.tp-mode-btn:hover { color: var(--fg); border-color: var(--accent); }
.tp-mode-btn:hover { color: var(--fg); border-color: var(--accent); }
- .tp-mode-btn.is-active { background: var(--grad); color: #fff; border-color: transparent; box-shadow: var(--shadow-glow); }
+ .tp-mode-btn.is-active { background: var(--grad); color: var(--on-accent); border-color: transparent; box-shadow: var(--shadow-glow); }
.tp-swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 18px; }
.tp-swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 18px; }
.tp-swatch {
.tp-swatch {
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+{
+ "openvibe.live": {
+ "img": "/assets/img/previews/openvibe.live.jpg",
+ "label": "openvibe.live"
+ },
+ "openvibe.network": {
+ "img": "/assets/img/previews/openvibe.network.jpg",
+ "label": "openvibe.network"
+ },
+ "openvibe.media": {
+ "img": "/assets/img/previews/openvibe.media.jpg",
+ "label": "openvibe.media"
+ },
+ "openvibe.tools": {
+ "img": "/assets/img/previews/openvibe.tools.jpg",
+ "label": "openvibe.tools"
+ },
+ "openvibe.games": {
+ "img": "/assets/img/previews/openvibe.games.jpg",
+ "label": "openvibe.games"
+ },
+ "openvibe.community": {
+ "img": "/assets/img/previews/openvibe.community.jpg",
+ "label": "openvibe.community"
+ },
+ "openvibe.chat": {
+ "img": "/assets/img/previews/openvibe.chat.jpg",
+ "label": "openvibe.chat"
+ },
+ "powerchat.live": {
+ "img": "/assets/img/previews/powerchat.live.jpg",
+ "label": "powerchat.live"
+ },
+ "github.com": {
+ "img": "/assets/img/previews/github.com.jpg",
+ "label": "github.com"
+ },
+ "alexfrison.net": {
+ "img": "/assets/img/previews/alexfrison.net.jpg",
+ "label": "alexfrison.net"
+ },
+ "path:/": {
+ "img": "/assets/img/previews/local_home.jpg",
+ "label": "alexfrison.net"
+ },
+ "path:/resume": {
+ "img": "/assets/img/previews/local_resume.jpg",
+ "label": "alexfrison.net/resume"
+ },
+ "path:/portfolio": {
+ "img": "/assets/img/previews/local_portfolio.jpg",
+ "label": "alexfrison.net/portfolio"
+ },
+ "path:/portfolio/amazon-sql-copilot": {
+ "img": "/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg",
+ "label": "alexfrison.net/portfolio/amazon-sql-copilot"
+ },
+ "path:/portfolio/amazon-userscripts": {
+ "img": "/assets/img/previews/local_portfolio_amazon-userscripts.jpg",
+ "label": "alexfrison.net/portfolio/amazon-userscripts"
+ },
+ "path:/portfolio/gameserverlists": {
+ "img": "/assets/img/previews/local_portfolio_gameserverlists.jpg",
+ "label": "alexfrison.net/portfolio/gameserverlists"
+ },
+ "path:/portfolio/gameserverstats": {
+ "img": "/assets/img/previews/local_portfolio_gameserverstats.jpg",
+ "label": "alexfrison.net/portfolio/gameserverstats"
+ },
+ "path:/portfolio/hartools": {
+ "img": "/assets/img/previews/local_portfolio_hartools.jpg",
+ "label": "alexfrison.net/portfolio/hartools"
+ },
+ "path:/portfolio/inbound-pull-tool": {
+ "img": "/assets/img/previews/local_portfolio_inbound-pull-tool.jpg",
+ "label": "alexfrison.net/portfolio/inbound-pull-tool"
+ },
+ "path:/portfolio/irdr-autonomous-bot": {
+ "img": "/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg",
+ "label": "alexfrison.net/portfolio/irdr-autonomous-bot"
+ },
+ "path:/portfolio/irdr-quality-recap": {
+ "img": "/assets/img/previews/local_portfolio_irdr-quality-recap.jpg",
+ "label": "alexfrison.net/portfolio/irdr-quality-recap"
+ },
+ "path:/portfolio/live-translation": {
+ "img": "/assets/img/previews/local_portfolio_live-translation.jpg",
+ "label": "alexfrison.net/portfolio/live-translation"
+ },
+ "path:/portfolio/lyric-video-toolkit": {
+ "img": "/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg",
+ "label": "alexfrison.net/portfolio/lyric-video-toolkit"
+ },
+ "path:/portfolio/mp-select-mini": {
+ "img": "/assets/img/previews/local_portfolio_mp-select-mini.jpg",
+ "label": "alexfrison.net/portfolio/mp-select-mini"
+ },
+ "path:/portfolio/openvibe-community": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-community.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-community"
+ },
+ "path:/portfolio/openvibe-cutover": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-cutover.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-cutover"
+ },
+ "path:/portfolio/openvibe-games": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-games.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-games"
+ },
+ "path:/portfolio/openvibe-live": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-live.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-live"
+ },
+ "path:/portfolio/openvibe-media": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-media.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-media"
+ },
+ "path:/portfolio/openvibe-network": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-network.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-network"
+ },
+ "path:/portfolio/openvibe-sites": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-sites.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-sites"
+ },
+ "path:/portfolio/openvibe-tools": {
+ "img": "/assets/img/previews/local_portfolio_openvibe-tools.jpg",
+ "label": "alexfrison.net/portfolio/openvibe-tools"
+ },
+ "path:/portfolio/perf-harness": {
+ "img": "/assets/img/previews/local_portfolio_perf-harness.jpg",
+ "label": "alexfrison.net/portfolio/perf-harness"
+ },
+ "path:/portfolio/powerchat-developer-api": {
+ "img": "/assets/img/previews/local_portfolio_powerchat-developer-api.jpg",
+ "label": "alexfrison.net/portfolio/powerchat-developer-api"
+ },
+ "path:/portfolio/powerchat-payments": {
+ "img": "/assets/img/previews/local_portfolio_powerchat-payments.jpg",
+ "label": "alexfrison.net/portfolio/powerchat-payments"
+ },
+ "path:/portfolio/pytools-workshop": {
+ "img": "/assets/img/previews/local_portfolio_pytools-workshop.jpg",
+ "label": "alexfrison.net/portfolio/pytools-workshop"
+ },
+ "path:/portfolio/robotstreamer-toolkit": {
+ "img": "/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg",
+ "label": "alexfrison.net/portfolio/robotstreamer-toolkit"
+ },
+ "path:/portfolio/youtube-desktop-client": {
+ "img": "/assets/img/previews/local_portfolio_youtube-desktop-client.jpg",
+ "label": "alexfrison.net/portfolio/youtube-desktop-client"
+ }
+}
@@ -2,6 +2,20 @@
@@ -2,6 +2,20 @@
"name": "Alex Frison",
"name": "Alex Frison",
"tagline": "Software engineer, data analyst and founder of OpenVibe.",
"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.",
+ "strengths": [
+ { "icon": "fa-solid fa-globe", "label": "Websites & web apps" },
+ { "icon": "fa-solid fa-plug", "label": "APIs & integrations" },
+ { "icon": "fa-solid fa-robot", "label": "AI agents" },
+ { "icon": "fa-solid fa-gears", "label": "Automation" },
+ { "icon": "fa-solid fa-microchip", "label": "Robotics & hardware" },
+ { "icon": "fa-solid fa-chart-line", "label": "Data & analytics" },
+ { "icon": "fa-solid fa-credit-card", "label": "Payments & billing" },
+ { "icon": "fa-solid fa-tower-broadcast", "label": "Streaming & realtime" },
+ { "icon": "fa-solid fa-shield-halved", "label": "Identity & security" },
+ { "icon": "fa-solid fa-server", "label": "Servers & deploys" },
+ { "icon": "fa-solid fa-gamepad", "label": "Games & 3D" },
+ { "icon": "fa-solid fa-handshake", "label": "Sales & leadership" }
+ ],
"highlights": [
"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-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-credit-card", "title": "Moves money safely", "text": "Rebuilt PowerChat's payments across five processors, with ledgers that make double-charging impossible." },
@@ -22,7 +36,7 @@
@@ -22,7 +36,7 @@
"githubDisplay": "github.com/OpenVibers",
"githubDisplay": "github.com/OpenVibers",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedin": "https://www.linkedin.com/in/fris/",
"linkedinDisplay": "linkedin.com/in/fris",
"linkedinDisplay": "linkedin.com/in/fris",
- "resumePdf": "/Alex_Frison_Resume_Sep_2026_fde01332.pdf",
+ "resumePdf": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.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.",
@@ -1,15 +1,15 @@
@@ -1,15 +1,15 @@
{
{
- "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"
+ "amber": "/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf",
+ "blue": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",
+ "cyan": "/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf",
+ "default": "/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf",
+ "green": "/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf",
+ "indigo": "/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf",
+ "lime": "/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf",
+ "orange": "/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf",
+ "pink": "/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf",
+ "red": "/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf",
+ "teal": "/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf",
+ "violet": "/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf",
+ "yellow": "/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf"
}
}
@@ -6,6 +6,7 @@ import ThemePicker from "../components/ThemePicker.astro";
@@ -6,6 +6,7 @@ import ThemePicker from "../components/ThemePicker.astro";
import profile from "../data/profile.json";
import profile from "../data/profile.json";
import themePresets from "../data/theme-presets.json";
import themePresets from "../data/theme-presets.json";
import themePdfManifest from "../data/theme-pdf-manifest.json";
import themePdfManifest from "../data/theme-pdf-manifest.json";
+import linkPreviews from "../data/link-previews.json";
interface Props {
interface Props {
title?: string;
title?: string;
@@ -141,11 +142,18 @@ const imageUrl = new URL(image, site).toString();
@@ -141,11 +142,18 @@ const imageUrl = new URL(image, site).toString();
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.
- var accent = hslToHex(h, Math.max(55, Math.min(hsl[1], 90)), Math.max(48, Math.min(hsl[2], 68)));
- var violet = hslToHex(h + 45, 65, 65);
- var ember = hslToHex(h + 180, 80, 62);
- var mint = hslToHex(h + 150, 65, 55);
+ // Toned down from the first version: saturation capped lower and lightness kept in a
+ // narrower band, so picks stop reading as pure neon slabs and derived colors sit in
+ // the same, slightly desaturated family.
+ var accent = hslToHex(h, Math.max(48, Math.min(hsl[1], 76)), Math.max(46, Math.min(hsl[2], 62)));
+ var violet = hslToHex(h + 40, 56, 62);
+ var ember = hslToHex(h + 180, 64, 60);
+ var mint = hslToHex(h + 150, 54, 52);
var root = d.style;
var root = d.style;
+ // Text on top of the accent: relative luminance decides dark ink vs white.
+ var rgb = hexToRgbStr(accent).split(', ').map(function (v) { v = parseInt(v, 10) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
+ var lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
+ root.setProperty('--on-accent', lum > 0.36 ? '#0b0f1a' : '#ffffff');
root.setProperty('--accent', accent);
root.setProperty('--accent', accent);
root.setProperty('--accent-rgb', hexToRgbStr(accent));
root.setProperty('--accent-rgb', hexToRgbStr(accent));
root.setProperty('--violet', violet);
root.setProperty('--violet', violet);
@@ -195,6 +203,8 @@ const imageUrl = new URL(image, site).toString();
@@ -195,6 +203,8 @@ const imageUrl = new URL(image, site).toString();
<main id="main"><slot /></main>
<main id="main"><slot /></main>
<Footer />
<Footer />
<ThemePicker />
<ThemePicker />
+ <div class="lp" id="lp" aria-hidden="true"><div class="lp__img"><img alt="" decoding="async" /></div><div class="lp__cap"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i><span></span></div></div>
+ <script type="application/json" id="lp-data" set:html={JSON.stringify(linkPreviews)} />
<script>
<script>
// ---- Resume PDF links: match whatever color was picked before paint ---
// ---- Resume PDF links: match whatever color was picked before paint ---
@@ -209,10 +219,10 @@ const imageUrl = new URL(image, site).toString();
@@ -209,10 +219,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) 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 });
+ // Wheel/trackpad only. Smoothing touch scroll felt laggy on phones and swallowed
+ // Chrome's pull-to-refresh, so touch stays fully native.
+ if (!L || matchMedia('(pointer: coarse)').matches) return;
+ const lenis = new L({ lerp: 0.11, wheelMultiplier: 0.95, smoothWheel: true });
// @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); };
@@ -360,6 +370,58 @@ const imageUrl = new URL(image, site).toString();
@@ -360,6 +370,58 @@ const imageUrl = new URL(image, site).toString();
setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
});
});
+ // ---- Link hover previews ----------------------------------------------------
+ // Pre-rendered thumbnails only (see scripts/make-link-previews.mjs) — nothing is fetched
+ // live. External links match by host, internal ones by pathname. Links that are already
+ // image cards (project tiles) are skipped so the card doesn't duplicate what's under it.
+ if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
+ const lp = document.getElementById('lp') as HTMLElement;
+ const lpImg = lp.querySelector('img') as HTMLImageElement;
+ const lpCap = lp.querySelector('.lp__cap span') as HTMLElement;
+ const previews: Record<string, { img?: string; label: string }> = JSON.parse(document.getElementById('lp-data')?.textContent || '{}');
+ const iconFor = (host: string) => host.includes('github') ? 'fa-brands fa-github' : host.includes('linkedin') ? 'fa-brands fa-linkedin-in' : 'fa-solid fa-arrow-up-right-from-square';
+ const keyFor = (a: HTMLAnchorElement) => {
+ try {
+ const u = new URL(a.href, location.href);
+ if (u.origin === location.origin) { const p = u.pathname.replace(/\/$/, '') || '/'; return previews['path:' + p] ? 'path:' + p : null; }
+ const h = u.host.replace(/^www\./, '');
+ return previews[h] ? h : (h.includes('linkedin') ? 'linkedin.com' : null);
+ } catch { return null; }
+ };
+ let cur: HTMLAnchorElement | null = null, px = 0, py = 0, cx = 0, cy = 0, lpRaf = 0;
+ const place = () => {
+ cx += (px - cx) * 0.18; cy += (py - cy) * 0.18;
+ const w = lp.offsetWidth || 290, h = lp.offsetHeight || 220;
+ let x = cx + 20, y = cy + 22;
+ const flip = x + w > innerWidth - 12;
+ if (flip) x = cx - w - 20;
+ if (y + h > innerHeight - 12) y = cy - h - 18;
+ lp.classList.toggle('is-flip', flip);
+ lp.style.setProperty('--x', `${Math.max(8, x)}px`); lp.style.setProperty('--y', `${Math.max(8, y)}px`);
+ if (cur) lpRaf = requestAnimationFrame(place);
+ };
+ const hide = () => { cur = null; lp.classList.remove('is-on'); cancelAnimationFrame(lpRaf); };
+ document.addEventListener('mousemove', (e) => { px = e.clientX; py = e.clientY; }, { passive: true });
+ document.addEventListener('mouseover', (e) => {
+ const a = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
+ if (!a || a === cur) return;
+ if (a.querySelector('img') || a.closest('.nav__drawer, .lp')) return;
+ const key = keyFor(a); if (!key) return;
+ const p = previews[key] || { label: key };
+ cur = a; cx = px; cy = py;
+ lp.classList.toggle('lp--noimg', !p.img); lp.classList.remove('is-loaded');
+ lpCap.textContent = p.label; (lp.querySelector('.lp__cap i') as HTMLElement).className = iconFor(key);
+ if (p.img) { lpImg.onload = () => lp.classList.add('is-loaded'); if (lpImg.src.endsWith(p.img)) lp.classList.add('is-loaded'); else lpImg.src = p.img; }
+ lp.classList.add('is-on');
+ cancelAnimationFrame(lpRaf); place();
+ });
+ document.addEventListener('mouseout', (e) => {
+ const a = (e.target as HTMLElement).closest('a[href]');
+ if (a && a === cur && !a.contains(e.relatedTarget as Node)) hide();
+ });
+ addEventListener('scroll', hide, { passive: true });
+ }
+
// ---- Floating scrollbar ---------------------------------------------------
// ---- Floating scrollbar ---------------------------------------------------
// The native sbar is hidden (global.css); this overlay one floats above the content, takes no
// 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
// layout space, shows itself while you scroll or approach the right edge, and fades out
@@ -64,6 +64,16 @@ main { position: relative; z-index: 1; }
@@ -64,6 +64,16 @@ main { position: relative; z-index: 1; }
a { color: var(--accent); text-decoration: none; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }
a:hover { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }
+/* Prose links: a gradient rule that thickens on hover, instead of the browser's default underline. */
+p a, li a.inline, .sec-lead a, .now__text a, .hero__lead a, .ct__lead a, .resume__body a, .prose a {
+ color: color-mix(in oklab, var(--accent) 78%, var(--fg)); font-weight: 600;
+ background-image: linear-gradient(90deg, var(--accent), var(--accent-2));
+ background-size: 100% 1.5px; background-repeat: no-repeat; background-position: 0 100%; padding-bottom: 1px;
+ transition: background-size .3s var(--ease), color .25s var(--ease);
+}
+p a:hover, li a.inline:hover, .sec-lead a:hover, .now__text a:hover, .hero__lead a:hover, .ct__lead a:hover, .resume__body a:hover, .prose a:hover {
+ color: var(--fg); background-size: 100% 2.5px; text-decoration: none;
+}
img, svg, video { max-width: 100%; display: block; }
img, svg, video { max-width: 100%; display: block; }
button { font: inherit; color: inherit; }
button { font: inherit; color: inherit; }
@@ -83,10 +93,18 @@ p { line-height: 1.7; color: var(--muted); margin: 0 0 1em; }
@@ -83,10 +93,18 @@ p { line-height: 1.7; color: var(--muted); margin: 0 0 1em; }
strong { color: var(--fg); font-weight: 600; }
strong { color: var(--fg); font-weight: 600; }
.container { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter); }
.container { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter); }
-.section { padding: clamp(72px, 10vw, 140px) 0; position: relative; }
+.section { padding: clamp(52px, 7.5vw, 116px) 0; position: relative; }
+/* A hairline between sections plus a faint alternating band so sections still read as distinct
+ blocks when someone is flicking through the page fast. */
+.section + .section::before, main > .section:not(.hero):first-child::before {
+ content: ""; position: absolute; left: 50%; top: 0; transform: translateX(-50%); height: 1px;
+ width: min(var(--container), calc(100% - 2 * var(--gutter)));
+ background: linear-gradient(90deg, transparent, var(--border-strong) 20%, var(--border-strong) 80%, transparent);
+}
+main > .section:nth-of-type(even) { background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), .035) 18%, rgba(var(--accent-rgb), .035) 82%, transparent); }
/* ---- Section headers --------------------------------------------------- */
/* ---- Section headers --------------------------------------------------- */
-.sec-head { display: grid; grid-template-columns: auto 1fr; gap: 18px 28px; align-items: end; margin-bottom: clamp(32px, 5vw, 56px); }
+.sec-head { display: grid; grid-template-columns: auto 1fr; gap: 14px 22px; align-items: end; margin-bottom: clamp(22px, 3.5vw, 44px); }
.sec-index {
.sec-index {
font-family: var(--font-mono); font-size: .8rem; letter-spacing: .22em; text-transform: uppercase;
font-family: var(--font-mono); font-size: .8rem; letter-spacing: .22em; text-transform: uppercase;
color: var(--accent); display: inline-flex; align-items: center; gap: 12px; margin-bottom: 14px;
color: var(--accent); display: inline-flex; align-items: center; gap: 12px; margin-bottom: 14px;
@@ -96,14 +114,17 @@ strong { color: var(--fg); font-weight: 600; }
@@ -96,14 +114,17 @@ strong { color: var(--fg); font-weight: 600; }
.sec-title em { font-style: normal; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.sec-title em { font-style: normal; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
.sec-lead { color: var(--muted); font-size: 1.08rem; max-width: 52ch; margin: 0; align-self: end; }
.sec-lead { color: var(--muted); font-size: 1.08rem; max-width: 52ch; margin: 0; align-self: end; }
.sec-num {
.sec-num {
- grid-row: 1 / span 2; align-self: start;
- font-family: var(--font-display); font-weight: 700; font-size: clamp(3rem, 9vw, 7rem); line-height: .8;
- color: transparent; -webkit-text-stroke: 1px rgba(var(--accent-rgb), .45);
+ grid-row: 1 / span 2; align-self: start; margin-top: 4px;
+ display: inline-grid; place-items: center; width: 44px; height: 44px; border-radius: 12px;
+ font-family: var(--font-mono); font-weight: 600; font-size: .86rem; letter-spacing: .06em;
+ color: var(--accent); background: rgba(var(--accent-rgb), .1); border: 1px solid rgba(var(--accent-rgb), .3);
+ box-shadow: 0 0 0 4px rgba(var(--accent-rgb), .06), 0 10px 24px -14px rgba(var(--accent-rgb), .8);
user-select: none;
user-select: none;
}
}
@media (max-width: 760px) {
@media (max-width: 760px) {
- .sec-head { grid-template-columns: 1fr; }
- .sec-num { grid-row: auto; font-size: 3rem; }
+ .sec-head { grid-template-columns: 1fr; gap: 12px; }
+ .sec-num { display: none; }
+ .sec-lead { font-size: 1rem; }
}
}
/* ---- Surfaces ------------------------------------------------------------ */
/* ---- Surfaces ------------------------------------------------------------ */
@@ -134,33 +155,41 @@ strong { color: var(--fg); font-weight: 600; }
@@ -134,33 +155,41 @@ strong { color: var(--fg); font-weight: 600; }
--btn-bg: transparent;
--btn-bg: transparent;
position: relative; isolation: isolate;
position: relative; isolation: isolate;
display: inline-flex; align-items: center; gap: 10px;
display: inline-flex; align-items: center; gap: 10px;
- padding: 13px 22px; border-radius: 999px;
+ padding: 14px 24px; min-height: 48px; border-radius: 999px;
border: 1px solid var(--border-strong);
border: 1px solid var(--border-strong);
background: var(--btn-bg); color: var(--fg);
background: var(--btn-bg); color: var(--fg);
- font: 600 .95rem var(--font-body); letter-spacing: .005em;
+ font: 600 .98rem/1 var(--font-body); letter-spacing: .005em;
cursor: pointer; text-decoration: none; white-space: nowrap;
cursor: pointer; text-decoration: none; white-space: nowrap;
transition: transform .35s var(--ease), border-color .3s var(--ease), background .3s var(--ease), color .3s var(--ease), box-shadow .35s var(--ease);
transition: transform .35s var(--ease), border-color .3s var(--ease), background .3s var(--ease), color .3s var(--ease), box-shadow .35s var(--ease);
will-change: transform;
will-change: transform;
}
}
-.btn:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .6); background: rgba(var(--accent-rgb), .08); }
-.btn i { font-size: .9em; }
-.btn--primary { --btn-bg: var(--accent); color: #fff; border-color: transparent; box-shadow: 0 10px 30px -12px rgba(var(--accent-rgb), .8); overflow: hidden; }
+.btn:hover { text-decoration: none; border-color: rgba(var(--accent-rgb), .6); background: rgba(var(--accent-rgb), .1); transform: translateY(-1px); }
+.btn i { font-size: .95em; }
+/* Primary: text color comes from --on-accent (Base.astro derives it from the accent's luminance),
+ so a lime or yellow accent gets dark ink instead of unreadable white-on-bright. A soft top
+ highlight and inner ring keep it from reading as one flat slab of color. */
+.btn--primary {
+ --btn-bg: var(--accent); color: var(--on-accent); border-color: transparent; overflow: hidden;
+ background-image: linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,0) 60%);
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.28), inset 0 0 0 1px rgba(0,0,0,.08), 0 12px 30px -12px rgba(var(--accent-rgb), .75);
+}
.btn--primary::after {
.btn--primary::after {
- content: ""; position: absolute; inset: 0; z-index: -1;
- background: var(--grad); opacity: 0; transition: opacity .35s var(--ease);
+ content: ""; position: absolute; top: 0; bottom: 0; left: -40%; width: 30%; z-index: -1;
+ background: linear-gradient(100deg, transparent, rgba(255,255,255,.35), transparent); transform: skewX(-15deg);
+ transition: left .6s var(--ease);
}
}
-.btn--primary:hover { background: var(--accent); color: #fff; }
-.btn--primary:hover::after { opacity: 1; }
-.btn--ghost { border-color: transparent; color: var(--muted); }
-.btn--ghost:hover { color: var(--fg); background: var(--surface-2); border-color: transparent; }
+.btn--primary:hover { background-color: var(--accent); color: var(--on-accent); filter: saturate(1.08) brightness(1.05); }
+.btn--primary:hover::after { left: 120%; }
+.btn--ghost { border-color: var(--border); color: color-mix(in oklab, var(--fg) 88%, var(--muted)); background: transparent; }
+.btn--ghost:hover { color: var(--fg); background: var(--surface-2); border-color: var(--border-strong); }
.btn__arrow { display: inline-block; transition: transform .35s var(--ease); }
.btn__arrow { display: inline-block; transition: transform .35s var(--ease); }
.btn:hover .btn__arrow { transform: translateX(4px); }
.btn:hover .btn__arrow { transform: translateX(4px); }
/* ---- Chips ---------------------------------------------------------------- */
/* ---- Chips ---------------------------------------------------------------- */
.chip {
.chip {
display: inline-flex; align-items: center; gap: 6px;
display: inline-flex; align-items: center; gap: 6px;
- padding: 5px 12px; border-radius: 999px;
- font: 500 .74rem var(--font-mono); letter-spacing: .02em;
+ padding: 7px 13px; border-radius: 999px;
+ font: 500 .8rem var(--font-mono); letter-spacing: .02em;
background: rgba(var(--accent-rgb), .08);
background: rgba(var(--accent-rgb), .08);
border: 1px solid rgba(var(--accent-rgb), .22);
border: 1px solid rgba(var(--accent-rgb), .22);
color: color-mix(in oklab, var(--accent) 70%, var(--fg));
color: color-mix(in oklab, var(--accent) 70%, var(--fg));
@@ -246,3 +275,29 @@ html::-webkit-scrollbar { display: none; width: 0; height: 0; }
@@ -246,3 +275,29 @@ html::-webkit-scrollbar { display: none; width: 0; height: 0; }
.ovbar.is-dragging .ovbar__thumb { cursor: grabbing; filter: brightness(1.12); }
.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 (pointer: coarse) { .ovbar { width: 8px; right: 3px; } .ovbar__track { inset: 0 1px; } }
@media (prefers-reduced-motion: reduce) { .ovbar { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovbar { transition: none; } }
+
+/* ---- Link hover previews ---------------------------------------------------- */
+/* Pre-rendered thumbnails (scripts/make-link-previews.mjs); Base.astro positions this card
+ next to the pointer for any link whose destination has one. Pointer devices only. */
+.lp {
+ position: fixed; left: 0; top: 0; z-index: 150; width: 290px; pointer-events: none;
+ border-radius: 16px; overflow: hidden; background: var(--surface-solid); border: 1px solid var(--border-strong);
+ box-shadow: 0 30px 70px -20px rgba(0,0,0,.65), 0 0 0 1px rgba(var(--accent-rgb), .12), 0 0 40px -10px rgba(var(--accent-rgb), .35);
+ opacity: 0; transform: translate3d(var(--x, 0px), var(--y, 0px), 0) scale(.9) rotate(-1.5deg);
+ transform-origin: top left; transition: opacity .22s var(--ease), transform .38s var(--ease);
+}
+.lp.is-on { opacity: 1; transform: translate3d(var(--x, 0px), var(--y, 0px), 0) scale(1) rotate(0); }
+.lp.is-flip { transform-origin: top right; }
+.lp__img { position: relative; aspect-ratio: 16 / 10; background: var(--bg-3); overflow: hidden; }
+.lp__img img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; opacity: 0; transition: opacity .35s var(--ease), transform 4s linear; transform: scale(1.02); }
+.lp.is-loaded .lp__img img { opacity: 1; transform: scale(1); }
+.lp__img::before { content: ""; position: absolute; inset: 0; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.12) 50%, transparent 70%); background-size: 250% 100%; animation: lpShimmer 1.4s linear infinite; }
+.lp.is-loaded .lp__img::before { animation: lpSheen 1.1s var(--ease) 1 forwards; }
+@keyframes lpShimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }
+@keyframes lpSheen { from { background-position: 120% 0; opacity: 1; } to { background-position: -120% 0; opacity: 0; } }
+.lp__img::after { content: ""; position: absolute; inset: auto 0 0; height: 40%; background: linear-gradient(to top, rgba(0,0,0,.35), transparent); pointer-events: none; }
+.lp__cap { display: flex; align-items: center; gap: 9px; padding: 9px 12px; font: 500 .74rem var(--font-mono); color: var(--muted); letter-spacing: .02em; border-top: 1px solid var(--border); }
+.lp__cap i { color: var(--accent); font-size: .8rem; }
+.lp__cap span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lp--noimg .lp__img { display: none; }
+@media (hover: none), (pointer: coarse) { .lp { display: none; } }