Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ netlify/lib/access.mjs
data/projects.generated.mjs
data/background.generated.mjs
data/coinBubble.generated.mjs
data/seasons.generated.mjs
data/siteQr.generated.mjs
data/credits.generated.json
data/contributions.generated.json
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ These run before `npm run dev`, `npm test` and the builds. The files they write
- `build:access`: the sign-in allow-list, from `.github/CODEOWNERS` and `config.toml` → `netlify/lib/access.mjs`.
- `build:projects`: the Projects settings in `config.toml` → `data/projects.generated.mjs`.
- `build:background`: the topographic background settings in `config.toml` → `data/background.generated.mjs`.
- `build:seasons`: the seasonal themes in `config.toml` → `data/seasons.generated.mjs`.
- `build:credits`: the open-source credits page (`/credits`) → `data/credits.generated.json`. It credits every package in `package.json` (plus the libraries bundled with Nuxt and its Tailwind module, `INCLUDED` in `utils/credits.mjs`) at the version in `package-lock.json`, with the license, copyright line and GitHub link from `node_modules`. The page then refreshes from the `/api/credits` Netlify function, which reads the project's `package.json` and `package-lock.json` from 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`.
Expand Down Expand Up @@ -51,7 +52,7 @@ Non-fork repos show automatically. In `config.toml`, under `[projects]`:
- `featured` and `hackathon`: tags for repos that are already shown.

## Topographic background
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:
Faint purple-to-green contour lines (in the season's colours while a seasonal theme is on, see below) 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`: `true` or `false`.
- `data_saver`: what to do when the browser asks to save data. That's detected automatically: Data Saver or Lite mode (`navigator.connection.saveData`), the `prefers-reduced-data` media 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.
Expand All @@ -63,6 +64,16 @@ Faint purple-to-green contour lines sit behind every page and draw in as visitor
- `trails` (0–6): dashed trails that fade in at the end.
- `draw_seconds` (0–600): time on the page to draw everything. `0` shows it all at once. Reduced-motion settings also show it all at once.

## Seasonal themes
Every week of the year has a theme: a few themed emoji drift down (or float up) over the background on every page, and the topographic lines take the theme's two colours. October is spooky (Hacktoberfest, pumpkins, bats, Halloween), December has the winter holidays, and so on. Dated events take over while they run, like All Things Open in Raleigh with laptops, Linux penguins and Raleigh's acorns.
- The themes live in `config.toml` under `[seasons]`, checked by `utils/seasons.mjs`; the build fails on a bad value.
- `enabled`: `true` or `false`.
- `particles` (0–40): how many emoji at once. Phones show at most 8.
- `[[seasons.week]]`: one per week, in order from `from = "01-01"`. Each runs until the next one starts. Each has a `name`, up to 8 `emoji`, two `colors` (`"#rrggbb"`) and a `motion` (`"fall"`, the default, or `"rise"`).
- `[[seasons.event]]`: the same, with `from` and `to` dates (like `2026-10-19`). Events take over from the weekly theme for those days.
- The theme is picked in the visitor's browser from today's date (Eastern time), so it changes on its own with no redeploy (`composables/useSeason.js`).
- The emoji are CSS animations only (`components/SeasonalParticles.vue`), behind the page content and never in the way of a tap. They're hidden for visitors who prefer reduced motion or are saving data; the colours still change.

## Coin face (home page)
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.

Expand Down
100 changes: 100 additions & 0 deletions components/SeasonalParticles.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<template>
<div v-if="particles.length" :class="['season', `season--${theme.motion}`]" aria-hidden="true">
<span
v-for="p in particles"
:key="p.id"
class="season__particle"
:style="{
left: `${p.x}%`,
'--size': `${p.size}rem`,
'--duration': `${p.duration}s`,
'--delay': `${p.delay}s`,
'--sway': `${p.sway}s`,
'--spin': `${p.spin}deg`
}"
><span class="season__emoji">{{ p.emoji }}</span></span>
</div>
</template>

<script setup>
// This week's theme (config.toml [seasons]): a few themed emoji drifting down (or floating up)
// over the background on every page, behind the content and never in the way of a tap. Off for
// visitors who prefer reduced motion or are saving data. CSS animations only, so nothing runs
// on the main thread once they're placed.
import { particleLayout } from '~/utils/seasons.mjs'
import { wantsLessData } from '~/utils/background.mjs'
import { seasonParticles } from '~/composables/useSeason.js'

const theme = useSeason()
const particles = ref([])

onMounted(() => {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const lessData = wantsLessData({
saveData: navigator.connection?.saveData,
effectiveType: navigator.connection?.effectiveType,
reducedData: window.matchMedia('(prefers-reduced-data: reduce)').matches
})
if (reducedMotion || lessData) return
// useSeason picks the theme in its own onMounted, which has run by now
particles.value = particleLayout(theme.value, seasonParticles)
})
</script>

<style scoped>
/* Over the topographic lines, under everything else (see TopoBackground.vue) */
.season {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}

.season__particle {
position: absolute;
top: 0;
font-size: var(--size);
line-height: 1;
opacity: 0.55;
will-change: transform;
animation: season-fall var(--duration) linear var(--delay) infinite;
}

.season--rise .season__particle {
animation-name: season-rise;
}

.season__emoji {
display: inline-block;
animation: season-sway var(--sway) ease-in-out infinite alternate;
}

/* Phones: fewer at once */
@media (max-width: 640px) {
.season__particle:nth-child(n + 9) {
display: none;
}
}

@keyframes season-fall {
from { transform: translateY(-10vh) rotate(0); }
to { transform: translateY(110vh) rotate(var(--spin)); }
}

@keyframes season-rise {
from { transform: translateY(110vh) rotate(0); }
to { transform: translateY(-10vh) rotate(var(--spin)); }
}

@keyframes season-sway {
from { transform: translateX(-18px); }
to { transform: translateX(18px); }
}

@media (prefers-reduced-motion: reduce) {
.season {
display: none;
}
}
</style>
7 changes: 5 additions & 2 deletions components/TopoBackground.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<template v-if="topo">
<defs>
<linearGradient id="topo-gradient" gradientUnits="userSpaceOnUse" x1="0" y1="0" :x2="topo.width" :y2="topo.height">
<stop offset="0" stop-color="#c084fc" />
<stop offset="1" stop-color="#41b883" />
<stop offset="0" :stop-color="season?.colors[0] ?? '#c084fc'" />
<stop offset="1" :stop-color="season?.colors[1] ?? '#41b883'" />
</linearGradient>
</defs>
<path
Expand Down Expand Up @@ -41,6 +41,9 @@ import { topoOptions, wantsLessData } from '~/utils/background.mjs'
// config.toml [background], checked at build time (scripts/build-background.mjs)
import settings from '~/data/background.generated.mjs'

// This week's theme (config.toml [seasons]) colours the lines; purple to green otherwise
const season = useSeason()

// The lines draw in as the visitor stays: the whole pattern takes this long with the tab visible
const FULL_DRAW_MS = settings.drawSeconds * 1000
const PAINT_EVERY_MS = 100 // the draw is slow, so ten updates a second is plenty
Expand Down
15 changes: 15 additions & 0 deletions composables/useSeason.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Today's seasonal theme (utils/seasons.mjs, config.toml [seasons]), or null. Picked in the
// browser once the page is up: the prerendered page has no theme, so it fits any day.
import settings from '~/data/seasons.generated.mjs'
import { themeFor } from '~/utils/seasons.mjs'
import { dayIn } from '~/utils/coinBubble.mjs'

export const useSeason = () => {
const theme = useState('season:theme', () => null)
onMounted(() => {
theme.value ??= themeFor(settings, dayIn(new Date()))
})
return theme
}

export const seasonParticles = settings.particles
Loading
Loading