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

@@ -35,6 +35,9 @@
--info: #2e7db5;
--info-bg: rgba(46, 125, 181, 0.07);
/* Threshold band (profile "more" reveal) -- darkens in light mode */
--threshold-tint: rgba(0, 0, 0, 0.035);
/* Overlay / depth */
--overlay: rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
@@ -117,6 +120,9 @@
--info: #60a5d6;
--info-bg: rgba(96, 165, 214, 0.08);
/* Threshold band (profile "more" reveal) -- lightens in dark mode */
--threshold-tint: rgba(255, 255, 255, 0.035);
/* Overlay / depth */
--overlay: rgba(0, 0, 0, 0.55);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
@@ -132,8 +138,27 @@
padding: 0;
}
/* The page below the profile "more" threshold is tinted. Painting it as the
root background rather than as an element means it covers the whole document
for free, and -- unlike an oversized absolutely-positioned box -- it cannot
extend the scrollable area. `--threshold-y` is the line's distance from the
top of the document, set from ProfileMore; with no threshold on the page the
100% fallback puts the stop at the bottom, so nothing is tinted. */
html {
background-color: var(--bg);
background-image: linear-gradient(
to bottom,
transparent 0,
transparent var(--threshold-y, 100%),
var(--threshold-tint) var(--threshold-y, 100%),
var(--threshold-tint) 100%
);
background-repeat: no-repeat;
}
body {
background: var(--bg);
/* Transparent, so the root gradient above shows through. */
background: transparent;
color: var(--fg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -190,22 +215,48 @@ nav {
}
.project-grid {
scrollbar-width: 10px;
scrollbar-width: thin;
}
}
@keyframes wiggle {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(-1deg); }
30% { transform: rotate(1deg); }
45% { transform: rotate(-1deg); }
60% { transform: rotate(1deg); }
75% { transform: rotate(-1deg); }
/* ---- project card selection ---- *
*
* Browsers with the View Transitions API morph the card between its grid slot
* and the full-width slot; `data-vt="off"` marks the fallback path, where the
* newly-selected card plays this enter animation instead.
*/
@keyframes card-select-in {
from {
opacity: 0;
transform: translateY(-8px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
.wiggle {
animation: wiggle 0.4s ease-in-out infinite;
[data-vt="off"] .project-card-selected {
animation: card-select-in 350ms var(--ease-out) both;
}
::view-transition-group(*) {
animation-duration: 380ms;
animation-timing-function: var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
[data-vt="off"] .project-card-selected {
animation: none;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}

View File

@@ -1,61 +1,159 @@
"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 { 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);
const { wiggling, progress, handlers } = useWiggle(500, () => {});
// 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);
}, []);
function handleSectionExpand(){
setMorePosition(morePosition + 1);
}
const toggle = useCallback(() => {
setArmed(true);
setOpen((prev) => !prev);
}, []);
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);
}
if (below.length === 0) {
return (
<>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
</>
);
}
return (
<div style={{ width: "100%" }}>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
{/* 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
className="more-section-container"
style={{
minHeight: "100px",
width: "100%",
overflow: "hidden"
}}
ref={bleedRef}
style={{ ...bleedStyle, position: "relative" }}
onMouseEnter={cross}
>
{PROFILE.moreSections.map((item: MoreSection, i)=>{
return (
<div
<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={{
// 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
transitionDelay: reducedMotion ? "0ms" : `${120 + i * STAGGER_MS}ms`,
}}
/>
))}
</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)",
@@ -63,65 +161,23 @@ export default function ProfileMore() {
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}>
}}
>
{item.title}
</h3>)}
<p style={{
</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",
}}>
<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>
</div>
>
{item.text}
</p>
</div>
);
}

View File

@@ -1,10 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import type { Project } from "@/data/content";
import SimpleGallery from "./SimpleGallery";
import Link from "next/link";
import { relative } from "path";
import "../styles/project-card.css";
type Props = {
@@ -12,13 +11,10 @@ type Props = {
visible: boolean;
selected: boolean;
setSelected: (selected: any) => void;
updateHeight: (projectId: number, heightPx: number) => void;
};
export default function ProjectCard({ project, visible, selected = false, setSelected, updateHeight}: Props) {
export default function ProjectCard({ project, visible, selected = false, setSelected }: Props) {
const [hovered, setHovered] = useState(false);
const [expandedImage, setExpandedImage] = useState(false);
const contentRef = useRef(null);
function projectTagsComponent(project: Project){
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
@@ -43,7 +39,6 @@ export default function ProjectCard({ project, visible, selected = false, setSel
function notSelectedComponent(){
return (
<div
ref={contentRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(project.id)}
@@ -88,29 +83,13 @@ export default function ProjectCard({ project, visible, selected = false, setSel
);
}
useEffect(() => {
if (!contentRef.current) return;
const observer = new ResizeObserver((entries) => {
const height = entries[0]?.borderBoxSize?.[0]?.blockSize
?? entries[0]?.contentRect.height;
if (height > 0) {
updateHeight(project.id, height);
}
});
observer.observe(contentRef.current);
return () => observer.disconnect();
}, [updateHeight, project.id]);
function selectedComponent(){
return (
<div
ref={contentRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="project-card"
className="project-card project-card-selected"
key={project.id}
style={{
minWidth: "10em",
maxWidth: "90vw",
@@ -145,11 +124,12 @@ export default function ProjectCard({ project, visible, selected = false, setSel
{project.description}
</p>
{contentRef.current && project.images && project.images.length > 0 && (
{project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title} />
)}
<div v-if={project.link} style={{display: "flex", gap: 12, flexWrap: "wrap", marginTop: 16}}>
{project.link && (
<div style={{display: "flex", gap: 12, flexWrap: "wrap", marginTop: 16}}>
<Link
className="expand-on-hover-button"
style={{
@@ -182,24 +162,17 @@ export default function ProjectCard({ project, visible, selected = false, setSel
borderRadius: "var(--radius-md)",
}} target="_blank" href={project.link} >Link</Link>
</div>
)}
</div>
);
}
function imgComponent(){
return (
<img src={project.images?.[0]} alt={`${project.title} screenshot`} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", maxHeight: 180}} />
<img src={`/${project.images?.[0]}`} alt={`${project.title} screenshot`} width={800} height={180} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", height: 180}} />
);
}
useEffect(() => {
if (!selected) {
if (expandedImage && contentRef.current) {
setExpandedImage(false);
}
}
});
if (selected) {
return selectedComponent();
} else {

View File

@@ -1,100 +1,114 @@
"use client";
import { PROJECTS } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { PROJECTS, type Project } from "@/data/content";
import {
useStaggerReveal,
useElementWidth,
usePrefersReducedMotion,
} from "@/hooks/useAnimations";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import ProjectCard from "@/components/ProjectCard";
import {
Layout,
Responsive,
useContainerWidth,
DefaultBreakpoints,
verticalCompactor,
} from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ROW_HEIGHT = 100;
const SMALL_BREAKPOINT = 768;
const GAP = 16;
function buildLayoutsFromHeights(activeId: number, heights: Record<string, number>): Record<DefaultBreakpoints, Layout> {
const selected = PROJECTS.find((p) => p.id === activeId);
const rest = PROJECTS.filter((p) => p.id !== activeId);
/** Used for a card whose real height hasn't been measured yet. Only affects
* column balance -- cards are in normal flow, so a bad guess can never overlap. */
const ESTIMATED_HEIGHT = 280;
const h = (id: number, fallback: number) =>
heights[id.toString()] ?? fallback;
/** Greedy shortest-column packing. Columns are equal width, so a card's height
* doesn't depend on which column holds it -- this is stable, not a feedback loop. */
function packColumns(projects: Project[], heights: Record<number, number>): Project[][] {
const columns: Project[][] = [[], []];
const columnHeights = [0, 0];
function twoCols(): Layout {
let items: Layout = [];
const selH = selected ? h(selected.id, 2) : 0;
if (selected) {
items = items.concat({
i: selected.id.toString(),
x: 0,
y: 0,
w: 2,
h: selH,
});
for (const project of projects) {
const col = columnHeights[0] <= columnHeights[1] ? 0 : 1;
columns[col].push(project);
columnHeights[col] += heights[project.id] ?? ESTIMATED_HEIGHT;
}
const colY = [selH, selH];
for (const p of rest) {
const pH = h(p.id, 3);
const col = colY[0] <= colY[1] ? 0 : 1;
items = items.concat({ i: p.id.toString(), x: col, y: colY[col], w: 1, h: pH });
colY[col] += pH;
}
return items;
}
return {
lg: twoCols(),
md: twoCols(),
sm: twoCols(),
// small screens get the horizontal scroll row
xs: twoCols(),
xxs: twoCols(),
};
return columns;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function Projects() {
const visible = useStaggerReveal(PROJECTS.length, 100);
const { width, containerRef, mounted } = useContainerWidth();
const { width, ref: containerRef, mounted } = useElementWidth<HTMLDivElement>();
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
const [selectedProject, setSelectedProject] = useState<number>(1);
const selectedRef = useRef(selectedProject);
selectedRef.current = selectedProject;
const [heights, setHeights] = useState<Record<number, number>>({});
const reducedMotion = usePrefersReducedMotion();
const [supportsViewTransition, setSupportsViewTransition] = useState(false);
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(() => buildLayoutsFromHeights(1, {}));
const measuredHeights = useRef<Record<string, number>>({});
useEffect(() => {
setSupportsViewTransition(typeof document.startViewTransition === "function");
}, []);
const updateHeight = useCallback((projectId: number, heightPx: number) => {
const key = projectId.toString();
const rowH = Math.max(1, Math.ceil(heightPx / ROW_HEIGHT));
const useTransitions = supportsViewTransition && !reducedMotion;
if (measuredHeights.current[key] === rowH) return;
measuredHeights.current[key] = rowH;
const selectProject = useCallback(
(id: number) => {
if (!useTransitions) {
setSelectedProject(id);
return;
}
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
}, []);
// flushSync is required: startViewTransition snapshots the DOM when the callback returns, so the state change has to be committed by then.
document.startViewTransition(() => flushSync(() => setSelectedProject(id)));
},
[useTransitions]
);
function setSelectedHandler(projectId: number) {
setSelectedProject(projectId);
measuredHeights.current = {};
setLayouts(buildLayoutsFromHeights(projectId, {}));
}
const selected = PROJECTS.find((p) => p.id === selectedProject);
const rest = PROJECTS.filter((p) => p.id !== selectedProject);
const columns = packColumns(rest, heights);
const indexOf = (project: Project) => PROJECTS.indexOf(project);
/* ---- height measurement (cosmetic: it only decides column balance) ---- */
const observers = useRef(new Map<number, ResizeObserver>());
const refCallbacks = useRef(new Map<number, (node: HTMLDivElement | null) => void>());
// Cached so each project keeps a stable ref identity across renders; ref re-fires when the node itself changes, which is what makes this survive the
// selected/unselected subtree swap inside ProjectCard.
const getCardRef = useCallback((id: number) => {
let cb = refCallbacks.current.get(id);
if (cb) return cb;
cb = (node: HTMLDivElement | null) => {
observers.current.get(id)?.disconnect();
observers.current.delete(id);
if (!node) return;
const observer = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? entries[0]?.contentRect.height ?? 0;
if (height <= 0) return;
setHeights((prev) =>
Math.abs((prev[id] ?? 0) - height) < 1 ? prev : { ...prev, [id]: height }
);
});
observer.observe(node);
observers.current.set(id, observer);
};
refCallbacks.current.set(id, cb);
return cb;
}, []);
useEffect(() => {
const active = observers.current;
return () => {
active.forEach((observer) => observer.disconnect());
active.clear();
};
}, []);
const header = (
<>
@@ -125,18 +139,22 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
</>
);
/* ---- small screen: horizontal scroll row ---- */
if (isSmall) {
return (
<div style={{ padding: "48px 0", minWidth: 0 }}>
{header}
<div
className={"project-grid"}
ref={containerRef}
data-vt={useTransitions ? "on" : "off"}
style={{ padding: "48px 0", minWidth: 0 }}
>
{header}
{isSmall ? (
/* ---- small screen: horizontal scroll row ---- */
<div
className="project-grid"
style={{
display: "flex",
overflowX: "auto",
gap: 16,
gap: GAP,
paddingBottom: 12,
scrollSnapType: "x mandatory",
WebkitOverflowScrolling: "touch",
@@ -151,54 +169,64 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
flexShrink: 0,
scrollSnapAlign: "start",
transition: "max-width 0.3s ease",
}}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
/>
</div>
))}
</div>
</div>
);
}
/* ---- normal: grid layout ---- */
return (
<div style={{ padding: "48px 0", minWidth: 400 }}>
{header}
<div ref={containerRef}>
{mounted && (
<Responsive
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
width={width}
compactor={verticalCompactor}
rowHeight={ROW_HEIGHT}
dragConfig={{ enabled: false }}
resizeConfig={{ enabled: false }}
}}
>
{PROJECTS.map((project, i) => (
<div
id={`project-${project.id}`}
key={project.id}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
setSelected={selectProject}
/>
</div>
))}
</Responsive>
)}
</div>
) : (
/* ---- wide: selected card full width, the rest packed into two columns ---- */
<div style={{ display: "flex", flexDirection: "column", gap: GAP }}>
{selected && (
<div style={{ viewTransitionName: `project-${selected.id}` }}>
<ProjectCard
project={selected}
visible={visible.has(indexOf(selected))}
selected
setSelected={selectProject}
/>
</div>
)}
<div style={{ display: "flex", gap: GAP, alignItems: "flex-start" }}>
{columns.map((column, colIndex) => (
<div
key={colIndex}
style={{
// min-width:0 is required -- flex items default to min-width:auto
// and would refuse to shrink below their content.
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: GAP,
}}
>
{column.map((project) => (
<div
key={project.id}
ref={getCardRef(project.id)}
style={{ viewTransitionName: `project-${project.id}` }}
>
<ProjectCard
project={project}
visible={visible.has(indexOf(project))}
selected={false}
setSelected={selectProject}
/>
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -31,39 +31,35 @@ export type Project = {
export const PROFILE = {
name: "Hunter W",
name: "Hunter",
title: "Software Engineer",
email: "contact@hwilliams.dev",
bio: `I build user experiences before I write code.
Experience with full-stack development across a variety of frameworks and languages including Vue,
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more.`,
portrait: "img/portrait.jpg",
bio: `Hi there. I consider myself a problem solver type and the adventurous sort. Lets take it apart, see how it works — then we have some options:
build something completely different out of it, take it to it's genuine limits, or just change a few things about it.
Looking for work. See below.`,
moreSections: [
{
title:"A little more about me",
text: `I graduated with a B.S. in Computer Science from Georgia Southern University in May 2025.
I have been working with AWS building analytics and monitoring dashboards with a sprinkle of prompt
engineering on major production projects.`
text: `
I'm an associate level (~1yr) software engineer with full-stack development experience across a variety of frameworks and languages including Vue,
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more. Graduated with a B.S. in May of 2025. Last seen at USAN building AWS deployed apps. `
},
{
text: "I make mods for video games such as Space Engineers and Rimworld in my free time."
title:"What do I do right now?",
text: "I'm building Flowdesk! Check it out on my projects page. I also build mods for video games such as Palworld, Space Engineers and Rimworld, and run webservers for various modding tools — like the U4ESS Object Explorer. I don't post my projects on github as much anymore to avoid being free training for AI, but my old stuff is still there and you can find any new open source projects of mine at git.hwilliams.dev/hwilliams."
},
{
title:"My goals",
text: `User experience. \n \n Nothing makes me happier than seeing someone excited to use the software I make, and the way that happens is by practicing
development techniques that make a good user experience. I especially enjoy peeling back the layers of old systems to explore how an existing
user experience can be improved. I love the words 'Wow, it used to be difficult to do this!'.
text: `Land the perfect user experience every time. \n \n Nothing makes me happier than seeing someone enjoy the experience they have using the things I make, and the way that happens is by practicing
development techniques that make a good user experience. I love to hear things like 'Your app makes it easy!', 'Your app feels good to use!', and 'Wow, it used to be difficult to do this!'.
`
},
{
title:"My dreams",
text: `Stop me if you've heard this one before: I left college with a burning passion for machine learning and artificial intelligence. It's still there, and despite
the trouble going on in the world right now I see a bright future for how these incredibly advanced statistical models can still be applied in new ways.
\n
title:"Footnote",
text: `If you've found this page trying to answer a question about my mods, please feel free to reach out.
`
},
{
title:"Wow, you're still here?",
text: `I appreciate you, but I am totally out of things to talk about.`
}
] satisfies MoreSection[],
moreSectionsStart: 1,
@@ -305,8 +301,17 @@ A real-world solution for event staff and volunteer management.
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
link: "https://github.com/FerrenF/CleanSpace",
year: "2025",
}, {
},{
id: 3,
slug: "ue4ss-explorer",
title: "Unreal: UE4SS Object Explorer",
description: "The UE4SS Object explorer implements a byte-level parser to organize unreal engine object dumps into visual data structures.",
tags: ["Nuxt", "Vue", "TypeScript", "Node"],
link: "https://ue4ss-explorer.gimme.pet",
year: "2026",
images: [ "img/projects/uoe/example.png"],
},{
id: 4,
slug: "personal-portfolio",
title: "Personal Portfolio Website",
description: "This very website! Built with Next.JS, React, and TypeScript. Featuring a custom CMS and a lot of custom-built components and hooks.",

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, type CSSProperties } from "react";
export function useStaggerReveal(count: number, baseDelay = 80) {
const [visible, setVisible] = useState<Set<number>>(new Set());
@@ -21,6 +21,101 @@ export function useStaggerReveal(count: number, baseDelay = 80) {
return visible;
}
/**
* Tracks the rendered width of an element. `mounted` stays false until the
* first measurement lands, so callers can avoid rendering a width-dependent
* layout against a zero/SSR width.
*/
export function useElementWidth<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 0;
setWidth(w);
setMounted(true);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { width, ref, mounted };
}
/**
* Lets a nested element break out to the full width of the viewport.
*
* The pure-CSS `left:50%; margin-left:-50vw` trick only works when every
* ancestor is horizontally centered, which is not true here -- Profile's
* content column is left-aligned inside the centered page wrapper. Measuring
* the real offset is exact from any nesting depth, and using
* `documentElement.clientWidth` instead of `100vw` excludes the scrollbar,
* so this can't induce a horizontal scroll.
*
* Returns a style that is a no-op until the first measurement lands.
*/
export function useFullBleed<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const [bleed, setBleed] = useState<{ width: number; offsetLeft: number } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const width = document.documentElement.clientWidth;
// Undo any margin this element is already carrying, so re-measuring is idempotent.
const current = parseFloat(el.style.marginLeft || "0") || 0;
const offsetLeft = el.getBoundingClientRect().left - current;
setBleed((prev) =>
prev && prev.width === width && Math.abs(prev.offsetLeft - offsetLeft) < 0.5
? prev
: { width, offsetLeft }
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(document.documentElement);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
const style: CSSProperties = bleed
? { width: bleed.width, marginLeft: -bleed.offsetLeft }
: { width: "100%", marginLeft: 0 };
return { ref, style, measured: bleed !== null };
}
/** Mirrors the user's `prefers-reduced-motion` setting, and follows changes to it. */
export function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduced(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return reduced;
}
export function useMountTransition(delay = 50) {
const [mounted, setMounted] = useState(false);
@@ -31,63 +126,3 @@ export function useMountTransition(delay = 50) {
return mounted;
}
export function useWiggle(durationMs = 1500, onComplete: () => void) {
const [wiggling, setWiggling] = useState(false);
const [progress, setProgress] = useState(0);
const startTime = useRef<number>(0);
const rafId = useRef<number>(0);
const completeTimer = useRef<ReturnType<typeof setTimeout>>(null);
const completedRef = useRef(false);
const stop = useCallback(() => {
setWiggling(false);
setProgress(0);
cancelAnimationFrame(rafId.current);
if (completeTimer.current) clearTimeout(completeTimer.current);
}, []);
const tick = useCallback(() => {
const elapsed = Date.now() - startTime.current;
const p = Math.min(1, elapsed / durationMs);
setProgress(p);
if (p < 1) {
rafId.current = requestAnimationFrame(tick);
}
}, [durationMs]);
const start = useCallback(() => {
completedRef.current = false;
startTime.current = Date.now();
setWiggling(true);
setProgress(0);
rafId.current = requestAnimationFrame(tick);
completeTimer.current = setTimeout(() => {
completedRef.current = true;
stop();
onComplete();
}, durationMs);
}, [durationMs, onComplete, tick, stop]);
const release = useCallback(() => {
if (!completedRef.current) stop();
}, [stop]);
// cleanup on unmount
useEffect(() => () => {
cancelAnimationFrame(rafId.current);
if (completeTimer.current) clearTimeout(completeTimer.current);
}, []);
const handlers = {
onPointerDown: start,
onPointerEnter: start,
onPointerUp: release,
onPointerLeave: release,
};
return { wiggling, progress, handlers };
}

201
package-lock.json generated
View File

@@ -12,9 +12,7 @@
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout": "^2.2.3",
"rehype-raw": "^7.0.0",
"rehype-rewrite": "^4.0.4",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
@@ -2583,22 +2581,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bcp-47-match": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
"integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2800,15 +2782,6 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2868,22 +2841,6 @@
"node": ">= 8"
}
},
"node_modules/css-selector-parser": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz",
"integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3057,19 +3014,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/direction": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz",
"integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==",
"license": "MIT",
"bin": {
"direction": "cli.js"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3757,12 +3701,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -4205,19 +4143,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-has-property": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz",
"integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
@@ -4271,33 +4196,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-select": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz",
"integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"bcp-47-match": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"css-selector-parser": "^3.0.0",
"devlop": "^1.0.0",
"direction": "^2.0.0",
"hast-util-has-property": "^3.0.0",
"hast-util-to-string": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"nth-check": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"unist-util-visit": "^5.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -4340,19 +4238,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -4935,6 +4820,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5365,6 +5251,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -6152,22 +6039,11 @@
"node": ">=18"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6478,6 +6354,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -6547,58 +6424,13 @@
"react": "^19.2.4"
}
},
"node_modules/react-draggable": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz",
"integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6658,23 +6490,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-rewrite": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.4.tgz",
"integrity": "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==",
"license": "MIT",
"dependencies": {
"hast-util-select": "^6.0.0",
"unified": "^11.0.3",
"unist-util-visit": "^5.0.0"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/rehype-stringify": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
@@ -6771,12 +6586,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",

View File

@@ -12,7 +12,6 @@
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout" : "^2.2.3",
"next-themes": "^0.4.6",
"remark": "^15.0.1",
"remark-parse": "^11.0.0",

BIN
public/img/portrait.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View File

@@ -1,18 +1,191 @@
/* ------------------------------------------------------------------ *
* Profile "more" threshold
*
* A full-bleed rule sits at the vertical centre of the handle. Everything
* below it is tinted -- darker in light mode, lighter in dark mode -- so the
* page reads as having a boundary you can cross. Crossing it reveals the
* remaining sections in one motion.
* ------------------------------------------------------------------ */
.drop-handle {
width: 100%;
height: 2em;
display: flex;
gap: 12;
margin: 0 0 40px 0;
flex-wrap: wrap;
justify-content: center;
align-items: center;
align-content: center;
border-radius: var(--radius-md);
transition: all 0.3s ease;
.threshold {
position: relative;
border: 1px solid var(--border);
bottom: 0px;
background-color: var(--bg-raised);
/* The rule is the vertical centre of this box, so the box is the hit area. */
width: 100%;
height: 4em;
display: flex;
align-items: center;
justify-content: center;
margin: 0 0 8px 0;
border: 0;
padding: 0;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.threshold:focus-visible {
outline: none;
}
/* The tint itself is painted as the root background (see globals.css) -- an
element tall enough to cover the rest of the page would extend the
document's scrollable area. ProfileMore only publishes the line's position
as `--threshold-y`. */
/* The rule itself. */
.threshold-rule {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 1px;
pointer-events: none;
transform: translateY(-0.5px);
background: linear-gradient(
to right,
transparent 0%,
var(--border) 12%,
var(--border-strong) 50%,
var(--border) 88%,
transparent 100%
);
transition: background var(--duration-slow) var(--ease-in-out);
}
.threshold[data-armed="true"] .threshold-rule {
background: linear-gradient(
to right,
transparent 0%,
var(--accent-bg) 6%,
var(--accent) 50%,
var(--accent-bg) 94%,
transparent 100%
);
}
/* A soft glow that blooms out of the centre when the threshold is armed. */
.threshold-glow {
position: absolute;
top: 50%;
left: 50%;
width: 60%;
height: 120px;
pointer-events: none;
transform: translate(-50%, -50%) scaleX(0.3);
opacity: 0;
background: radial-gradient(
ellipse at center,
var(--accent-bg) 0%,
transparent 70%
);
transition:
opacity var(--duration-slow) var(--ease-out),
transform var(--duration-slow) var(--ease-out);
}
.threshold[data-armed="true"] .threshold-glow {
opacity: 1;
transform: translate(-50%, -50%) scaleX(1);
}
/* The label pill. The rule passes behind it. */
.threshold-label {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
font-family: var(--mono);
font-size: var(--text-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
transition:
color var(--duration-normal) var(--ease-in-out),
border-color var(--duration-normal) var(--ease-in-out),
background var(--duration-normal) var(--ease-in-out),
transform var(--duration-slow) var(--ease-out);
}
/* Move the focus ring onto the pill -- the button itself is a full-width band. */
.threshold:focus-visible .threshold-label {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.threshold[data-armed="true"] .threshold-label {
color: var(--accent);
border-color: var(--accent);
background: var(--bg-raised);
transform: translateY(3px);
}
.threshold-chevron {
width: 11px;
height: 11px;
flex-shrink: 0;
transition: transform var(--duration-slow) var(--ease-out);
}
.threshold[data-armed="true"] .threshold-chevron {
transform: translateY(2px);
}
.threshold[data-open="true"] .threshold-chevron {
transform: rotate(180deg);
}
/* ---- the reveal ---- */
/* 0fr -> 1fr is the animatable stand-in for height:auto, so the sections can
size themselves to their real content without any JS measurement. */
.more-reveal {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 700ms var(--ease-out);
}
.more-reveal[data-open="true"] {
grid-template-rows: 1fr;
}
.more-reveal > .more-reveal-inner {
overflow: hidden;
min-height: 0;
}
.more-section {
opacity: 0;
transform: translateY(14px);
transition:
opacity 500ms var(--ease-out),
transform 500ms var(--ease-out);
}
.more-reveal[data-open="true"] .more-section {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.more-reveal,
.more-section,
.threshold-glow,
.threshold-label,
.threshold-chevron,
.threshold-rule {
transition-duration: 1ms !important;
}
.more-section {
transition-delay: 0ms !important;
transform: none;
}
}