Redesign the resume site as a 3D-editor viewport
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:
+39
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user