openvibers/afresume Commits

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

OP OpenVibers committed 20 Sept 2026 · 3fc1f05b71 · 73 files, +486 −76

- Accent derivation is less neon (saturation/lightness clamped tighter,
  derived hues desaturated) and now sets --on-accent from the accent's
  luminance, so primary buttons, the nav CTA, drawer CTA and picker use
  dark ink on bright colors (lime/yellow) instead of white-on-bright.
- Buttons: larger, top highlight + inner ring, sheen on hover; ghost
  buttons readable. Prose links get a gradient rule that thickens on
  hover.
- Link previews: scripts/make-link-previews.mjs pre-renders 38 JPEG
  thumbnails (OpenVibe sites, PowerChat, GitHub, every internal page)
  into public/assets/img/previews; Base.astro shows a pointer-following
  card for any link with one. Pointer devices only, nothing fetched
  live. LinkedIn is omitted (login wall).
- Sections: smaller numbered badge instead of the giant outline number,
  tighter padding, a hairline + faint band between sections so they
  read as blocks while scrolling fast.
- Hero: tags replaced by an organized "What I build and do" grid from
  profile.strengths (12 areas, icon badges); rotator caret is a glowing
  bar with the gradient rule sheened underneath; scramble glyphs get a
  chromatic jitter.
- Nav: section readout chip (icon + label) swaps as you scroll; drawer
  links highlight the current section; the phone layout drops the
  standalone theme toggle (the drawer has a switch) so nothing clips.
- Network "How it fits together" rewritten for a non-technical reader.
- Lenis is wheel/trackpad only again: touch smoothing felt laggy and
  broke pull-to-refresh.

Changes

ddiff --git a/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..bef2170
BBinary files /dev/null and b/public/Alex_Frison_Resume_Sep_2026_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf b/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf
ddeleted file mode 100644
iindex d9ba3ca..0000000
BBinary files a/public/Alex_Frison_Resume_Sep_2026_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..8cd3e9a
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_amber_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf b/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf
ddeleted file mode 100644
iindex ae9473f..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_amber_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..10defd8
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_cyan_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf b/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf
ddeleted file mode 100644
iindex a291f97..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_cyan_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..f4d433d
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_green_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_green_fde01332.pdf b/public/Alex_Frison_Resume_Themed_green_fde01332.pdf
ddeleted file mode 100644
iindex 676535c..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_green_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..cfd189f
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_indigo_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf b/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf
ddeleted file mode 100644
iindex 0657161..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_indigo_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..b0e1417
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_lime_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf b/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf
ddeleted file mode 100644
iindex 893191c..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_lime_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..53ecc95
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_orange_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf b/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf
ddeleted file mode 100644
iindex 7869ff5..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_orange_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..d0243d6
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_pink_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf b/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf
ddeleted file mode 100644
iindex ebdfe48..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_pink_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..07bc4e2
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_red_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_red_fde01332.pdf b/public/Alex_Frison_Resume_Themed_red_fde01332.pdf
ddeleted file mode 100644
iindex 9784e1d..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_red_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..cfccde7
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_teal_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf b/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf
ddeleted file mode 100644
iindex 0d5df37..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_teal_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..844f4a3
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_violet_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf b/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf
ddeleted file mode 100644
iindex f7284a3..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_violet_fde01332.pdf and /dev/null differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf b/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf
nnew file mode 100644
iindex 0000000..80c5c36
BBinary files /dev/null and b/public/Alex_Frison_Resume_Themed_yellow_1c4cdbb2.pdf differ
ddiff --git a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf b/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf
ddeleted file mode 100644
iindex d4357b0..0000000
BBinary files a/public/Alex_Frison_Resume_Themed_yellow_fde01332.pdf and /dev/null differ
ddiff --git a/public/assets/img/previews/alexfrison.net.jpg b/public/assets/img/previews/alexfrison.net.jpg
nnew file mode 100644
iindex 0000000..b31c56c
BBinary files /dev/null and b/public/assets/img/previews/alexfrison.net.jpg differ
ddiff --git a/public/assets/img/previews/github.com.jpg b/public/assets/img/previews/github.com.jpg
nnew file mode 100644
iindex 0000000..504f99d
BBinary files /dev/null and b/public/assets/img/previews/github.com.jpg differ
ddiff --git a/public/assets/img/previews/local_home.jpg b/public/assets/img/previews/local_home.jpg
nnew file mode 100644
iindex 0000000..681b74b
BBinary files /dev/null and b/public/assets/img/previews/local_home.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio.jpg b/public/assets/img/previews/local_portfolio.jpg
nnew file mode 100644
iindex 0000000..f9a2bb3
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg
nnew file mode 100644
iindex 0000000..367ef3b
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-sql-copilot.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg
nnew file mode 100644
iindex 0000000..62f2b33
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_amazon-userscripts.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_gameserverlists.jpg b/public/assets/img/previews/local_portfolio_gameserverlists.jpg
nnew file mode 100644
iindex 0000000..61eb54f
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverlists.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_gameserverstats.jpg b/public/assets/img/previews/local_portfolio_gameserverstats.jpg
nnew file mode 100644
iindex 0000000..fff7186
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_gameserverstats.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_hartools.jpg b/public/assets/img/previews/local_portfolio_hartools.jpg
nnew file mode 100644
iindex 0000000..ca4a14d
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_hartools.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg
nnew file mode 100644
iindex 0000000..9e22083
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_inbound-pull-tool.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg
nnew file mode 100644
iindex 0000000..eb9a866
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-autonomous-bot.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg
nnew file mode 100644
iindex 0000000..5b75f75
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_irdr-quality-recap.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_live-translation.jpg b/public/assets/img/previews/local_portfolio_live-translation.jpg
nnew file mode 100644
iindex 0000000..bc642ce
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_live-translation.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg
nnew file mode 100644
iindex 0000000..783545d
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_lyric-video-toolkit.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_mp-select-mini.jpg b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg
nnew file mode 100644
iindex 0000000..5988b94
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_mp-select-mini.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-community.jpg b/public/assets/img/previews/local_portfolio_openvibe-community.jpg
nnew file mode 100644
iindex 0000000..12442a2
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-community.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg
nnew file mode 100644
iindex 0000000..d5e72b3
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-cutover.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-games.jpg b/public/assets/img/previews/local_portfolio_openvibe-games.jpg
nnew file mode 100644
iindex 0000000..efc7cc7
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-games.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-live.jpg b/public/assets/img/previews/local_portfolio_openvibe-live.jpg
nnew file mode 100644
iindex 0000000..ed8a1fc
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-live.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-media.jpg b/public/assets/img/previews/local_portfolio_openvibe-media.jpg
nnew file mode 100644
iindex 0000000..e40b61c
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-media.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-network.jpg b/public/assets/img/previews/local_portfolio_openvibe-network.jpg
nnew file mode 100644
iindex 0000000..b3f03e3
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-network.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-sites.jpg b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg
nnew file mode 100644
iindex 0000000..d0afd1d
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-sites.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_openvibe-tools.jpg b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg
nnew file mode 100644
iindex 0000000..e6bbc2a
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_openvibe-tools.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_perf-harness.jpg b/public/assets/img/previews/local_portfolio_perf-harness.jpg
nnew file mode 100644
iindex 0000000..fe022ab
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_perf-harness.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg
nnew file mode 100644
iindex 0000000..514dbaf
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-developer-api.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_powerchat-payments.jpg b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg
nnew file mode 100644
iindex 0000000..1261bc6
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_powerchat-payments.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_pytools-workshop.jpg b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg
nnew file mode 100644
iindex 0000000..747a924
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_pytools-workshop.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg
nnew file mode 100644
iindex 0000000..415d2bc
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_robotstreamer-toolkit.jpg differ
ddiff --git a/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg
nnew file mode 100644
iindex 0000000..65b8c2f
BBinary files /dev/null and b/public/assets/img/previews/local_portfolio_youtube-desktop-client.jpg differ
ddiff --git a/public/assets/img/previews/local_resume.jpg b/public/assets/img/previews/local_resume.jpg
nnew file mode 100644
iindex 0000000..f362476
BBinary files /dev/null and b/public/assets/img/previews/local_resume.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.chat.jpg b/public/assets/img/previews/openvibe.chat.jpg
nnew file mode 100644
iindex 0000000..8b6f6fd
BBinary files /dev/null and b/public/assets/img/previews/openvibe.chat.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.community.jpg b/public/assets/img/previews/openvibe.community.jpg
nnew file mode 100644
iindex 0000000..18ab4eb
BBinary files /dev/null and b/public/assets/img/previews/openvibe.community.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.games.jpg b/public/assets/img/previews/openvibe.games.jpg
nnew file mode 100644
iindex 0000000..5c3daa9
BBinary files /dev/null and b/public/assets/img/previews/openvibe.games.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.live.jpg b/public/assets/img/previews/openvibe.live.jpg
nnew file mode 100644
iindex 0000000..76191e8
BBinary files /dev/null and b/public/assets/img/previews/openvibe.live.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.media.jpg b/public/assets/img/previews/openvibe.media.jpg
nnew file mode 100644
iindex 0000000..91c950a
BBinary files /dev/null and b/public/assets/img/previews/openvibe.media.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.network.jpg b/public/assets/img/previews/openvibe.network.jpg
nnew file mode 100644
iindex 0000000..17000f0
BBinary files /dev/null and b/public/assets/img/previews/openvibe.network.jpg differ
ddiff --git a/public/assets/img/previews/openvibe.tools.jpg b/public/assets/img/previews/openvibe.tools.jpg
nnew file mode 100644
iindex 0000000..12487b7
BBinary files /dev/null and b/public/assets/img/previews/openvibe.tools.jpg differ
ddiff --git a/public/assets/img/previews/powerchat.live.jpg b/public/assets/img/previews/powerchat.live.jpg
nnew file mode 100644
iindex 0000000..4a9e23a
BBinary files /dev/null and b/public/assets/img/previews/powerchat.live.jpg differ
ddiff --git a/scripts/make-link-previews.mjs b/scripts/make-link-previews.mjs
nnew file mode 100644
iindex 0000000..bf22bc0
---- /dev/null
++++ b/scripts/make-link-previews.mjs
@@@ -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');
ddiff --git a/src/components/Hero.astro b/src/components/Hero.astro
iindex 9a7df55..2ee023a 100644
---- a/src/components/Hero.astro
++++ b/src/components/Hero.astro
@@@ -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 ? 1
min-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 ? 1
const 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 ? 1
else 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>
ddiff --git a/src/components/Nav.astro b/src/components/Nav.astro
iindex 023b7e0..e66501f 100644
---- a/src/components/Nav.astro
++++ b/src/components/Nav.astro
@@@ -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 section
const 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.
ddiff --git a/src/components/Network.astro b/src/components/Network.astro
iindex 66644b5..c496bf3 100644
---- a/src/components/Network.astro
++++ b/src/components/Network.astro
@@@ -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; }
ddiff --git a/src/components/ProjectGrid.astro b/src/components/ProjectGrid.astro
iindex cb7e41a..b70c8b0 100644
---- a/src/components/ProjectGrid.astro
++++ b/src/components/ProjectGrid.astro
@@@ -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); } }
ddiff --git a/src/components/ThemePicker.astro b/src/components/ThemePicker.astro
iindex d366043..ac21751 100644
---- a/src/components/ThemePicker.astro
++++ b/src/components/ThemePicker.astro
@@@ -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 {
ddiff --git a/src/data/link-previews.json b/src/data/link-previews.json
nnew file mode 100644
iindex 0000000..0dcc9fc
---- /dev/null
++++ b/src/data/link-previews.json
@@@ -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"
++ }
++}
ddiff --git a/src/data/profile.json b/src/data/profile.json
iindex 7870d2c..a3e79c7 100644
---- a/src/data/profile.json
++++ b/src/data/profile.json
@@@ -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.",
ddiff --git a/src/data/theme-pdf-manifest.json b/src/data/theme-pdf-manifest.json
iindex e10535c..dc3308c 100644
---- a/src/data/theme-pdf-manifest.json
++++ b/src/data/theme-pdf-manifest.json
@@@ -1,15 +1,15 @@
{
-- "amber": "/Alex_Frison_Resume_Themed_amber_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"
}
ddiff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
iindex bcdd36d..714a423 100644
---- a/src/layouts/Base.astro
++++ b/src/layouts/Base.astro
@@@ -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-ignore
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
window.__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
ddiff --git a/src/styles/global.css b/src/styles/global.css
iindex a18628c..f11bd31 100644
---- a/src/styles/global.css
++++ b/src/styles/global.css
@@@ -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; } }