Hello World, Hansel!
npm install
npm run dev # http://localhost:3000
npm run generate # static build into .output/public (what Netlify deploys)
netlify dev # run the site *and* the Netlify functions locallyThese run before npm run dev, npm test and the builds. The files they write are git-ignored:
build:access: the sign-in allow-list, from.github/CODEOWNERSandconfig.toml→netlify/lib/access.mjs.build:projects: the Projects settings inconfig.toml→data/projects.generated.mjs.build:background: the topographic background settings inconfig.toml→data/background.generated.mjs.build:credits: the open-source credits page (/credits) →data/credits.generated.json. It credits every package inpackage.json(plus the libraries bundled with Nuxt and its Tailwind module,INCLUDEDinutils/credits.mjs) at the version inpackage-lock.json, with the license, copyright line and GitHub link fromnode_modules. The page then refreshes from the/api/creditsNetlify function, which reads the project'spackage.jsonandpackage-lock.jsonfrom GitHub (main, so merged dependency changes show up without a deploy; it falls back to the deployed copies) and each package's details from the npm registry and jsDelivr (cached by Netlify's CDN for a day). Nothing to update by hand when dependencies change.sync:contributions: the GitHub contribution calendar for the Projects page →data/contributions.generated.json.sync:readme: the GitHub profile README for the Bio page →content/bio/github-readme.generated.md.sync:devto: dev.to posts (see below). Runs beforenpm run buildandnpm run generate.
If a sync can't reach GitHub or dev.to, the last copy is kept (or an empty one written) and the build carries on.
| Variable | Used for | Needed? |
|---|---|---|
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
Owner sign-in with a GitHub OAuth App. Callback: https://www.hanselwei.dev/api/auth/github/callback |
For sign-in |
GITHUB_TOKEN |
Projects contribution graph. Read-only, with no permissions selected | Optional |
DEVTO_USERNAME, DEVTO_API_KEY |
dev.to mirror (see Blog) | Optional |
BIO_PHOTO_KEY |
Decrypts the Bio page photo (netlify/lib/bioPhoto.sealed.mjs). Secret, functions scope |
For the photo |
CSP_MODE |
enforce or report-only, overriding the default Content-Security-Policy mode |
Optional |
The session key is derived from GITHUB_CLIENT_SECRET, so changing that secret signs everyone out.
- Owner sign-in is GitHub OAuth (
/api/auth/github). Sign-in is invite-only: accounts listed in.github/CODEOWNERSget a session, and their verified emails must match an entry inconfig.toml([access.github], plus[access] additionalfor extra emails). - Sessions last an hour, and an idle hour signs you out.
Non-fork repos show automatically. In config.toml, under [projects]:
wip: marks repos as work in progress (everything else is active).forks: forks to show.featuredandhackathon: tags for repos that are already shown.
Faint purple-to-green contour lines sit behind every page and draw in as visitors stay. They're built in the browser at the screen's size, in a Web Worker (workers/topo.worker.mjs, utils/topo.mjs). Tune them in config.toml under [background]; the build fails on a value out of range:
enabled:trueorfalse.data_saver: what to do when the browser asks to save data. That's detected automatically: Data Saver or Lite mode (navigator.connection.saveData), theprefers-reduced-datamedia query, or a 2G or slower connection."sparse"(the default): a lighter pattern with half the contours, larger hills, no trails and a coarser grid, about a quarter of the size."skip": no background."full": ignore the hint and draw the full pattern.
contours(8–60): how many contour lines. More is denser.feature_size(80–800): the rough size of a hill or valley in pixels. Smaller is busier.index_every(0–20): every Nth line is heavier.0turns them off.trails(0–6): dashed trails that fade in at the end.draw_seconds(0–600): time on the page to draw everything.0shows it all at once. Reduced-motion settings also show it all at once.
The coin opens on the Bitmoji. Each time a flip lands heads up, the face shows the next photo, alternating between the GitHub avatar and the hanselwei.link Gravatar (utils/coinFaces.mjs). The pull-down reset brings the Bitmoji back. A photo that can't load is skipped, so the coin never goes blank. On Netlify the photos go through its Image CDN (netlify.toml [images], which allows only these two images): resized to the coin and sent as WebP or AVIF.
When a press-and-hold on the coin lands it heads up (the profile picture), a blue iMessage-style speech bubble pops up under it: "typing…" for a moment, then the message. It fades after 8 seconds, but not while it's hovered or focused. After that, the coin's tails side shows an engraved QR code instead of "HW". It's for events. Set it in config.toml [coin_bubble] each year:
enabled: true or false.text: plain text, up to 80 characters.until: the event's last day.link_textandlink: the part of the text to link, and where it goes (https only).{year}inlinkis the year ofuntil, e.g.https://{year}.allthingsopen.org.qr: where the tails-side QR points (https only).home_titleandfooter_code: plain text for the event, shown as the home page title (on its own, in place of "Hansel Wei") and as a line at the top of the footer on every page.
It shows through the end of until (Eastern time), then switches itself off without a redeploy. npm run build:coin-bubble (before tests and dev) writes data/coinBubble.generated.mjs, with the QR already turned into an SVG path (utils/coinQr.mjs, using uqr), and fails the build on a bad setting (utils/coinBubble.mjs).
components/AboutCard.vue holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in the component's education, experience and achievements arrays. Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (utils/translate.js); English readers don't see it. A link to /#xp (the Bio page has one) opens the card on XP (utils/aboutCard.js).
The social links under the card are in components/Socials.vue. Tapping Instagram opens a small menu with both accounts: hansel.rocks (main) and hansel.codes (code and software engineering only). A pill gets a menu from its choices list (components/SocialFlipLink.vue). The addresses are in utils/outbound.js. On phones and tablets each link opens a new tab through /out/<id> (pages/out/[id].vue), which goes on to the link; when the phone hands the link to an app instead, that tab closes itself once you come back, so no blank tab is left behind.
plugins/navigation.client.js adds keyboard and game controller navigation on every page, detected from what the visitor uses. A focus ring shows only after a keyboard or controller is used (html[data-input]), and a mouse or touch hides it again.
- Arrow keys or the D-pad (or left stick) move focus to the nearest link or button in that direction (
utils/spatialNav.js). With nothing focused yet, arrow keys scroll the page as usual, and a controller starts at the top of the screen. - Enter or A activates. Escape or B closes an open menu, clears a search, turns the About card back to JSON, or goes back a page within the site.
- PageUp/PageDown or the bumpers switch the About card's tabs, or step the years on the XP slider.
- The right stick scrolls the page. Controller input is read in
utils/gamepad.js(standard mapping). Only standard-mapping controllers count, and each one does nothing until a button is pressed or a stick moved. Phones list fingerprint readers and sensors as gamepads (Chrome and Samsung Internet on Android can even give them the standard mapping), with sticks stuck at one end, which used to pin Android pages in place. So on a phone only a fresh button press wakes a controller, and any touch on the screen puts it back to sleep: a finger always wins. - With a keyboard or controller in use, the mouse wheel moves focus to the next or previous control while it's on or near the screen; past long text it scrolls as usual. A click hands the wheel back to plain scrolling.
- With a controller, focus gets a sparkling rainbow border with twinkling corners (a still one with reduced motion).
- The coin's press-and-hold still needs a real press (pointer, Space or Enter), since it doubles as the human check for the contact card.
content/bio/bio.md: hand-written markdown, withtitleanddescriptionin its front matter.- Below it, the GitHub profile README (
darkmastermindz/darkmastermindz) is synced on every build. - A photo is pinned at the top, framed as an instant print with a strip of 35mm film edge (
components/PinnedPhoto.vue). It's served only to the page itself, by the/api/bio-photoNetlify function:- The repo is public, so it holds only an encrypted copy; the function decrypts it with
BIO_PHOTO_KEY. What's encrypted is already reduced: 640px wide, quality 60, no camera metadata, a fainthanselwei.devwatermark. - The function answers only a same-origin
fetchfrom the page (fetch metadata, or a same-site referrer), so direct visits, other sites and<img>embeds get a 403. Nothing is cached, and it sendsnoindex, noimageindex, noai, noimageai. - The page draws it into a canvas (no image to save, drag or long-press, right-click blocked), and
robots.txtturns away AI crawlers. - These are deterrents: a screenshot still works, and anyone can forge request headers.
- To change the photo: reduce it, then
BIO_PHOTO_KEY=… node scripts/encrypt-bio-photo.mjs photo.jpg.
- The repo is public, so it holds only an encrypted copy; the function decrypts it with
Tag and filter chips use components/ChipStrip.vue everywhere (blog, Curiosity Feed, projects, gear):
- Tags show as hashtags (
#FirstAid,#featured); filters still use the raw tag. Searches accept the hashtag (#crashpadfinds "Crash Pad"). - A strip wraps naturally up to 3 rows on phones and 4 on wider screens. Longer lists become that many balanced rows that scroll sideways (
utils/chips.js).
npm testruns Node's built-in test runner overtests/**/*.test.mjs. App logic is intests/app/, and injection and security checks are intests/security/. Shared attack payloads live intests/security/payloads.mjs.- Outside input (markdown, front matter, URLs, GitHub data, the profile README) renders only through
renderMarkdownandsafeUrl, and the security tests check that. npm run test:androidruns the browser tests intests/e2e/(Playwright) on every Android phone, tablet and foldable in Playwright's device list, in portrait and landscape, plus iPhone Duo: 103 device profiles. Runnpm run generatefirst;scripts/serve-static.mjsserves the built site the way Netlify does, with its Content-Security-Policy. SetPLAYWRIGHT_CHROMIUM_PATHto use a Chromium you already have, or runnpx playwright install chromium.- With a finger (real touch events), every page must scroll down and back up and stay where it's left, nothing may scroll sideways, swipes that start on the coin, About card, social links and menu must scroll the home page, and a tap must still work.
- Every test runs with a phantom "controller" like the ones Android phones report (a fingerprint reader with the standard mapping, its stick stuck at -1 and a button held down), the cause of pages that wouldn't scroll on Android. Each test also checks the app really started, so a page whose scripts didn't run can't pass.
- The Browser tests workflow runs them on pull requests that change the front end. It builds the site once, then runs each category on its own runners at the same time: Accessibility (6), Touch (6) and Foldables (2). A red check names its category. To run one category locally, name its spec, for example
npx playwright test tests/e2e/a11y.spec.mjs.
tests/e2e/a11y.spec.mjsruns axe-core (WCAG 2.1 A and AA) on every page and on the home page's card states and Instagram menu, on a phone and a tablet. It runs withnpm run test:android, and as the Accessibility check in CI.
Folding the phone changes the layout, live, with no reload, and nothing you read or tap sits on the fold (everything keeps at least 1rem clear of it, even on seamless folds).
| Fold | Home | Other pages | Where it works |
|---|---|---|---|
| Closed, or any other phone, tablet or computer | The usual layout, unchanged | Unchanged | everywhere |
| Tabletop (a crease across, the top half tilted back like a laptop) | Top half: the coin and name. Bottom half: the About card and links, which scroll under the coin, and the menu along the bottom | Reading on the top half; the menu on the bottom half, like a keyboard | Chrome / Samsung Internet; Safari with its preview flags (below) |
| Book (a crease down the middle) | Left page: the menu, coin and name. Right page: the card, links and buttons | The menu down the left page, reading on the right. Pages turn like a book (below) | Chrome / Samsung Internet; Safari with its preview flags |
| Open flat | Two columns: the coin and name beside the card and links | Unchanged | Android foldables, and iPhone Duo's inner screen held sideways (Safari, no flags needed) |
- Detecting the fold. A small script in the page head (
utils/foldMode.mjs, allowed by the CSP by hash) sets<html data-fold="tabletop|book|flat">and the--fold-*variables before the page paints, and again on every resize and fold.layouts/default.vue,components/AppNav.vueandpages/index.vuelay out from those.- Chrome and Samsung Internet (138+) report where the fold is: two viewport segments (the Viewport Segments API).
- iPhone Duo in Safari. Safari 27.1 has the same APIs only as a developer preview: Settings → Apps → Safari → Advanced → Feature Flags → Viewport Segments API and Device Posture API. With segments on, it works as in Chrome. With only the posture flag, Safari says "folded" but not where, so the fold is taken as the middle of the screen (iPhone Duo's hinge is central), with a wider margin. Without the flags, Safari can't tell the page the phone is half-folded.
- iPhone Duo is recognised by its size, since it's the only iPhone whose page is 500px or more both ways (
data-device="iphone-duo"). Its inner screen held sideways gets the two-column layout, two even columns with the gap on the hinge. The size is the page's own (the layout viewport), not iOS'sinnerWidth/innerHeight, which shrink when zoomed in or typing and grow as Safari's toolbar slides away. An iPhone that says "folded" on a smaller screen is on the outer one, which has no fold. iPads (whose Safari says it's a Mac) never get a foldable layout. Upright, and on the outer screen, the usual layout fits, as Apple's iPhone Duo guidelines suggest: let the layout grow rather than redesign it, keep content clear of the fold, use an even number of columns, and keep the menu at the side in book like the system's own controls.
- Just for iPhone Duo (
data-device,utils/foldMode.mjs):- Front screen (382px wide upright in Safari, a width no other iPhone has): a laptop shuts its lid and turns it to face you, with the site's QR code where the logo would be (
components/DuoLaptopLid.vue). The QR is built at build time (data/siteQr.generated.mjs, fromscripts/build-coin-bubble.mjs). With reduced motion it shows only the shut lid. - Inner screen: a "✨ Hello, iPhone Duo" badge with a turning rainbow edge, and Fold Pong (
components/FoldPong.vue, rules inutils/foldPong.mjs). It's you against the computer, first to 5, with the fold as the net: left vs right held sideways, bottom vs top upright. Drag anywhere or use the arrow keys; Escape closes. Neither shows while part-folded, where the hero only has half the screen.
- Front screen (382px wide upright in Safari, a width no other iPhone has): a laptop shuts its lid and turns it to face you, with the site's QR code where the logo would be (
- Turning the page (book only): going to another page turns the right page over onto the left, like Apple Books, and going back turns it back (
plugins/pageTurn.client.js, the View Transitions API; animations inlayouts/default.vue). The menu's sections are the chapters, in order (utils/pageTurn.mjs), and the corners of the right page turn to the previous and next one (components/BookPager.vue). With reduced motion, pages change as usual. - Tests.
tests/e2e/foldable.spec.mjsfolds a Galaxy Z Fold 7 and Flip 7 through DevTools (with and without a hinge gap). It checks each layout, that nothing visible is on the fold at the top, scrolled and at the bottom, and that pages turn both ways.- The iPhone Duo profiles (
playwright.config.mjs, Safari's user agent and page sizes) check the open-flat layout and the posture-only fold, and that no other device gets a foldable layout. tests/app/fold-mode.test.mjscovers the detection rules and the page order.
One collection: every post is a Markdown file in content/blog/ and lives at /blog/<slug>.
A series label decides where it's listed:
- Posts (
/blog): one-shots, noseries. - Curiosity Feed (
/blog/feed): time-series updates. Give the post aseries(e.g.series: "Climbing Log"); entries are numbered in date order and shown in full, newsletter style.
---
title: "My post"
date: "2026-10-17"
description: "One-line summary"
tags: [netlify, nuxt]
series: "Climbing Log" # optional: puts it in the Curiosity Feed
link: "https://…" # optional: something you're sharing
cover_image: "https://…" # optional
draft: true # optional, hides the post
---RSS: /rss.xml (everything) and /feed.xml (Curiosity Feed only).
- dev.to mirror:
npm run sync:devtopulls published dev.to articles intocontent/blog/devto/. It runs automatically before every build and mirrors dev.to/@hansel (override withDEVTO_USERNAME; optionalDEVTO_API_KEY).
- Edit inventories in
data/inventory.mjs. netlify/functions/inventory.mjsserves them:GET /api/inventory: all listsGET /api/inventory/outdoor?section=Climbing&tag=Rappel&status=owned&q=black: filtered items
/gear/:listpages are prerendered from the same data, then refreshed from the function in the browser.
public/manifest.webmanifest, public/sw.js and the icons in public/icons/ make the site installable:
- Android / Chrome: menu → Install app
- iOS / Safari: Share → Add to Home Screen. The installed app uses an opaque status bar (
apple-mobile-web-app-status-bar-style: black) and starts below it. iOS reads that when the app is added, so remove and re-add the app after changing it. When it runs installed, the page getshtml.is-installed(utils/installed.js) and the nav gets a little more room.
The source code is licensed under the BSD 3-Clause License. The site's Personal Content is not covered by it, as set out in CONTENT-NOTICE.md: writing, photographs and other likenesses, personal information, and personal lists and data. That includes content/, data/inventory.mjs, data/hobbyFacts.mjs and netlify/lib/bioPhoto.sealed.mjs. It's all rights reserved, may not be reproduced or distributed without written permission, and must be removed from forks. The same terms are on /terms.