Changed the behavior of the 'more' bar into something less annoying, and re-did the entire project grid component chain.

This commit is contained in:
Hunter
2026-08-11 20:22:54 -04:00
parent 9840ecfcf2
commit 65bd3ec705
11 changed files with 709 additions and 580 deletions

View File

@@ -1,127 +1,183 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { MoreSection, PROFILE } from "@/data/content";
import { useStaggerReveal, useWiggle } from "@/hooks/useAnimations";
import "@/styles/more-section.css"
import { useFullBleed, usePrefersReducedMotion } from "@/hooks/useAnimations";
import "@/styles/more-section.css";
/**
* Publishes the threshold rule's distance from the top of the document as
* `--threshold-y`, which the root background gradient reads to tint everything
* below it. Keeping this on the background instead of in an element means it
* covers the rest of the page without adding to the scrollable area.
*/
function useThresholdTint(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const root = document.documentElement;
const publish = () => {
const rect = el.getBoundingClientRect();
// The rule sits at the element's vertical centre.
const y = rect.top + window.scrollY + rect.height / 2;
root.style.setProperty("--threshold-y", `${Math.round(y)}px`);
};
publish();
const observer = new ResizeObserver(publish);
observer.observe(root);
observer.observe(el);
return () => {
observer.disconnect();
root.style.removeProperty("--threshold-y");
};
}, [ref]);
}
/** Per-section stagger once the threshold opens. */
const STAGGER_MS = 90;
export default function ProfileMore() {
const [delayTimer, setDelayTimer] = useState<number | null>(null);
const [hovered, setHovered] = useState<boolean | null>(null);
const [morePosition, setMorePosition] = useState<number>(PROFILE.moreSectionsStart ?? 0);
const [open, setOpen] = useState(false);
const [armed, setArmed] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const { wiggling, progress, handlers } = useWiggle(500, () => {});
function handleSectionExpand(){
setMorePosition(morePosition + 1);
const { ref: bleedRef, style: bleedStyle } = useFullBleed<HTMLDivElement>();
useThresholdTint(bleedRef);
// Sections before `moreSectionsStart` are always visible; the rest are the
// payload that lives below the threshold.
const start = PROFILE.moreSectionsStart ?? 0;
const above = PROFILE.moreSections.slice(0, start);
const below = PROFILE.moreSections.slice(start);
// Crossing the line opens it. The reveal then latches -- crossing back up
// leaves it open, so a stray hover never yanks content out from under someone
// mid-read. Collapsing is deliberate only: a click on the label.
const cross = useCallback(() => {
setArmed(true);
setOpen(true);
}, []);
const toggle = useCallback(() => {
setArmed(true);
setOpen((prev) => !prev);
}, []);
if (below.length === 0) {
return (
<>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
</>
);
}
function handleEnter(){
if (delayTimer) return;
setHovered(true);
let timer = window.setTimeout(() => {
handleSectionExpand();
}, 500);
setDelayTimer(timer);
}
function handleLeave() {
if (delayTimer){
clearTimeout(delayTimer);
setDelayTimer(null);
}
setHovered(false);
}
return (
<div
className="more-section-container"
style={{
minHeight: "100px",
width: "100%",
overflow: "hidden"
}}
>
{PROFILE.moreSections.map((item: MoreSection, i)=>{
return (
<div
key={i}
style={{
// display: i < morePosition ? "block" : "none",
opacity: i < morePosition ? 1.0 : 0.0,
transition: "all 0.3s ease",
overflow: "hidden",
minHeight: i < morePosition ? "5em" : "0em",
height: i < morePosition ? "auto" : "0",
}}>
{item.title && (<h3
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}>
{item.title}
</h3>)}
<p style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}>{item.text}</p>
</div>
);
})}
<div {...handlers} className={`drop-handle ${wiggling ? "wiggle" : ""}`}
onMouseEnter={
() => handleEnter()
}
onMouseLeave={() => handleLeave()}
onTouchStart={
() => handleEnter()
}
onTouchEnd={()=>handleLeave()}
onClick={() => {
if (delayTimer){
clearTimeout(delayTimer);
}
handleSectionExpand();
}}
style={{
opacity: morePosition < PROFILE.moreSections.length ? 1.0 : 0.0,
backgroundColor: hovered ? "var(--surface)" : "var(--bg-raised",
width: "100%",
height: "2em",
display: "flex",
gap: 12,
margin: "0 0 40px 0",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
bottom: hovered ? "-10px" : "0px",
position: "relative",
}}>
<div style={{ width: "100%" }}>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
<svg height={"30px"} width={"30px"} fill="var(--fg-secondary)" version="1.1" id="Capa_1" style={{scale:"0.5"}} >
<g>
<path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0
l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z"/>
</g>
</svg>
<span style={{
fontFamily: "var(--sans)",
fontSize: 12,
lineHeight: 0.5,
color: "var(--fg-secondary)",
alignSelf: "center",
textTransform: "uppercase"
}}>more?</span>
{/* The threshold. The rule sits at this box's vertical centre, and the
rule and glow bleed to the viewport edges via the measured offset.
Entering the band at all is what counts as crossing it. */}
<div
ref={bleedRef}
style={{ ...bleedStyle, position: "relative" }}
onMouseEnter={cross}
>
<button
type="button"
className="threshold"
data-armed={armed || open}
data-open={open}
aria-expanded={open}
aria-controls="profile-more-reveal"
onFocus={() => setArmed(true)}
onBlur={() => setArmed(open)}
onClick={toggle}
>
<span className="threshold-glow" aria-hidden="true" />
<span className="threshold-rule" aria-hidden="true" />
<span className="threshold-label">
<svg
className="threshold-chevron"
viewBox="0 0 31 31"
fill="currentColor"
aria-hidden="true"
>
<path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z" />
</svg>
{open ? "less" : "more?"}
</span>
</button>
</div>
{/* Everything below the line, revealed in one motion. */}
<div id="profile-more-reveal" className="more-reveal" data-open={open}>
<div className="more-reveal-inner">
<div style={{ paddingTop: 32 }}>
{below.map((item, i) => (
<Section
key={i}
item={item}
revealed
style={{
transitionDelay: reducedMotion ? "0ms" : `${120 + i * STAGGER_MS}ms`,
}}
/>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function Section({
item,
revealed = false,
style,
}: {
item: MoreSection;
revealed?: boolean;
style?: CSSProperties;
}) {
return (
<div className={revealed ? "more-section" : undefined} style={style}>
{item.title && (
<h3
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{item.title}
</h3>
)}
<p
style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}
>
{item.text}
</p>
</div>
);
}