A personal hub for a solo founder running several ventures in parallel. Built with Astro, TypeScript, hand-written CSS, and GSAP for motion — layered as a strict progressive enhancement over a site that is complete without it.
npm install
npm run dev # local dev server at http://localhost:4321
npm run build # static build → dist/
npm run preview # serve the built site locallynpm run contrast # WCAG 2.2 AA contrast check for the token palette
npm run build && npm run a11y # axe scan of every page, light + dark
npm run build && npm run keyboard # automated keyboard walkthrough:
# skip link first + working, tab order = DOM order,
# visible focus indicator on every stop, no traps
npm run check # astro/TypeScript check
npm run og # regenerate public/og.png from scripts/og.htmlThe build output in dist/ is fully static — deploy to Vercel or Netlify
with zero config:
- Vercel: import the repo; framework preset "Astro" is auto-detected.
- Netlify: build command
npm run build, publish directorydist.
Once the domain is decided, set site in astro.config.mjs — the
canonical link, og:url, and og:image/Twitter-card tags light up
automatically (they're gated so no broken relative URLs ship before then).
The social preview image is public/og.png, generated from
scripts/og.html — after changing the name or tagline, keep that file in
sync with src/data/site.ts and re-run npm run og.
Everything marked TODO lives in src/data/site.ts — one file:
-
tagline— three positioning-line options are in the comment; pick one:- "One person. Several bets." (currently live)
- "Building several things at once — on purpose."
- "The portfolio is the point."
- Trellais URL
- Panodash GitHub URL
- Stacking Sense URL
- MWCAPCON URL
- Geeqoid URL
- YouTube URL — the last empty entry in
SOCIALS, which drives the footer icon row (GitHub, LinkedIn, X, Instagram, and Facebook are filled in). Clearing a url hides that icon; to add a platform, add an entry plus its glyph insrc/components/SocialIcon.astro. -
NEWSLETTER.formAction— Beehiiv embed-form action URL (renders an inline subscribe form) and/orsubscribeUrl(fallback link). Also confirm the subscribe target is Stacking Sense.
Links render only when a URL is present — nothing is ever a dead link, so partially filled data is always safe to ship.
Placeholder posts live in POSTS in src/data/site.ts. To swap in a real
feed later, replace that array with a build-time fetch — e.g. in
src/pages/index.astro's frontmatter, fetch the Beehiiv RSS feed and map it
to the same Post shape (title, date, readMinutes, url). Astro runs
frontmatter at build time, so the site stays fully static. The PostItem
component needs no changes.
src/
data/site.ts ← all content + TODOs (start here)
layouts/Base.astro ← <head>, header/nav, footer, skip link
components/ ← VentureCard, MediaItem, PostItem, SocialIcon
pages/index.astro ← the one-pager
pages/writing.astro ← "view all" writing page
styles/global.css ← design tokens, type scale, all styling
scripts/
contrast.mjs ← WCAG contrast check for the palette
a11y.mjs ← axe scan of the built site
Two variable grotesks, both self-hosted via Fontsource, wired through
--font-display and --font-body in src/styles/global.css:
| Role | Face | Weight |
|---|---|---|
h1 (hero name) |
Familjen Grotesk | 560, pulling to 700 |
| Wordmark | Familjen Grotesk | 700 |
| Section heads, card titles | Familjen Grotesk | 400 |
| Subtitles (hero tagline, section notes) | Hanken Grotesk | 400 |
| Body copy | Hanken Grotesk | 400 |
Familjen Grotesk's wght axis stops at 700. The hero h1 deliberately
rests below that ceiling, at 560, so the kinetic hero has headroom to pull
into: the entrance runs 400 → 560 and the pointer wave bulges toward 700
(WGHT_START / WGHT_REST / WGHT_BULGE in src/scripts/motion.ts).
Keep WGHT_REST under WGHT_BULGE — if they meet, the wave silently
stops doing anything. Hanken Grotesk runs the full 100–900, so body
weights have room either way.
Both fallbacks are Arial (Liberation Sans on Linux), with size-adjust
and ascent/descent overrides measured from each webfont's own tables —
re-measure them if either face is swapped.
Motion state is a single attribute, data-motion="on|off" on <html>,
set pre-paint by an inline script in src/layouts/Base.astro:
an explicit choice (localStorage, set by the header's Motion toggle) wins,
otherwise the OS prefers-reduced-motion setting decides. Everything keys
off that one state:
- CSS: all transition durations run through
--dur-*custom properties that collapse to0swhen motion is off — state changes (hover colors, borders) still happen, instantly. The hover lift distance is a variable (--lift) that collapses to0px. - JS (
src/scripts/motion.ts, the only client JS on the site): GSAP + ScrollTrigger reveals, the kinetic variable-font hero, magnetic elements, and card spotlights — all check the attribute and tear down cleanly when toggled off. - View transitions are cross-document (CSS
@view-transition) — real page navigations with native focus/history semantics; no client router. Disabled under reduced motion; instant when toggled off. - Lenis / scroll hijacking is deliberately not used — CSS
scroll-behavior: smooth(gated on motion state) covers anchor jumps without touching native scrolling.
Safety properties, verified by npm run a11y and npm run keyboard in
both motion states:
- Reveals animate
opacity/transformonly — content never leaves the accessibility tree, and under reduced motion everything is simply present, immediately. - Reveal targets are pre-hidden only while
motion-pendingis set; a failsafe timeout removes it, so a failed JS load can never hide content. - A
focusinlistener completes any reveal a keyboard user tabs into — focus is never on an invisible element (asserted in the keyboard test). - The hero letter animation locks letter widths during its weight wave and
metric-matched font fallbacks (
size-adjust/ascent-override) cover the webfont swap — measured CLS ≈ 0 (0.003 with font loads held back 400 ms, the worst case for the swap). - The motion bundle (~47 KB gz) is a deferred module; first paint never waits on it.
Evergreen browsers get everything. Older browsers degrade by design:
color-mix() accents fall back to solid colors, @view-transition
(Chrome/Edge, Safari 18.2+) falls back to normal navigation, inset has
longhand fallbacks, and :focus-visible-less browsers keep their native
focus rings (outlines are never removed). No polyfills shipped.
- Semantic landmarks, one
h1per page, correct heading order. - Skip link,
:focus-visiblestyles, logical tab order. - WCAG 2.2 AA contrast — per-venture accent colors have separate
light/dark-mode values tuned to pass on their backgrounds
(see
scripts/contrast.mjs). prefers-color-scheme,forced-colors, andprefers-reduced-data(falls back to system fonts) respected;prefers-reduced-motionwill gate all motion added in later phases.- Small-text links carry extra padding to meet WCAG 2.5.8 target size.
- No client-side JS required for any content or navigation.