Redesign the resume site as a 3D-editor viewport
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

New design: studio backdrop with a perspective floor and X/Z axis lines, an
orientation gizmo that orbits on scroll, Bricolage Grotesque and Instrument
Sans (bundled, no build-time network). Projects are laid out as plates by a
row planner that keeps every row full for any count; a demo image from the
repo root is shown when present, otherwise a generated voxel sculpture.
Text and projects still come live from Gitea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
Emil
2026-09-20 21:37:35 +03:00
co-authored by Claude Sonnet 5
parent 32c57a5ada
commit b5ffc67a82
23 changed files with 1440 additions and 374 deletions
+39
View File
@@ -0,0 +1,39 @@
export type Span = 2 | 3 | 4 | 6
/**
* Column spans (out of 6) for `count` projects, so that every row is exactly full whatever the count:
* rows of two or three, a feature row first, and never a lone project in the last row (a single project
* spans the whole width). Two-project rows alternate 4+2 and 2+4 for rhythm.
*/
export function planSpans(count: number): Span[] {
if (count <= 0) return []
if (count === 1) return [6]
const sizes: number[] = []
const pattern = [2, 3, 3]
let remaining = count
for (let i = 0; remaining > 0; i++) {
const size = Math.min(pattern[i % pattern.length], remaining)
sizes.push(size)
remaining -= size
}
const last = sizes.length - 1
if (sizes[last] === 1) {
if (sizes[last - 1] === 3) {
sizes[last - 1] = 2
sizes[last] = 2
} else {
sizes[last - 1] = 3
sizes.pop()
}
}
const spans: Span[] = []
let pairs = 0
for (const size of sizes) {
if (size === 3) spans.push(2, 2, 2)
else spans.push(...(pairs++ % 2 === 0 ? ([4, 2] as const) : ([2, 4] as const)))
}
return spans
}
+5
View File
@@ -20,6 +20,8 @@ export type Resume = {
}
// A public Gitea repository tagged `resume`, merged with the optional notes from data/resume.json.
export type Demo = { url: string; width: number; height: number }
export type Project = {
name: string
title?: string
@@ -33,6 +35,9 @@ export type Project = {
language?: string
stars?: number
updated?: string
rank?: number
// Image copied from `demo.*` in the repo root by the server sync; the size lets the layout reserve space.
demo?: Demo
}
// Shape of /data/resume.json, written every minute by the server-side sync.
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { useEffect, useState } from 'react'
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
/** Scroll position of the page as 0..1. Updates at most once per frame; frozen when the user prefers reduced motion. */
export function useScrollProgress(): number {
const [progress, setProgress] = useState(0)
useEffect(() => {
if (reducedMotion()) return
let frame = 0
const update = () => {
frame = 0
const max = document.documentElement.scrollHeight - window.innerHeight
setProgress(max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0)
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update)
}
update()
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule)
return () => {
window.removeEventListener('scroll', schedule)
window.removeEventListener('resize', schedule)
if (frame) cancelAnimationFrame(frame)
}
}, [])
return progress
}
/** True once the page has scrolled past the very top; only changes state when that flips. */
export function useScrolled(threshold = 8): boolean {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const update = () => setScrolled(window.scrollY > threshold)
update()
window.addEventListener('scroll', update, { passive: true })
return () => window.removeEventListener('scroll', update)
}, [threshold])
return scrolled
}