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
44 lines
1.5 KiB
TypeScript
44 lines
1.5 KiB
TypeScript
'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
|
|
}
|