@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');

.ibm-plex-sans-thin {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 100;
font-style: normal;
}

.ibm-plex-sans-extralight {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 200;
font-style: normal;
}

.ibm-plex-sans-light {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 300;
font-style: normal;
}

.ibm-plex-sans-regular {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 400;
font-style: normal;
}

.ibm-plex-sans-medium {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 500;
font-style: normal;
}

.ibm-plex-sans-semibold {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 600;
font-style: normal;
}

.ibm-plex-sans-bold {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 700;
font-style: normal;
}

.ibm-plex-sans-thin-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 100;
font-style: italic;
}

.ibm-plex-sans-extralight-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 200;
font-style: italic;
}

.ibm-plex-sans-light-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 300;
font-style: italic;
}

.ibm-plex-sans-regular-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 400;
font-style: italic;
}

.ibm-plex-sans-medium-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 500;
font-style: italic;
}

.ibm-plex-sans-semibold-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 600;
font-style: italic;
}

.ibm-plex-sans-bold-italic {
font-family: "IBM Plex Sans", sans-serif;
font-weight: 700;
font-style: italic;
}

.ibm-plex-mono-extralight {
font-family: "IBM Plex Mono", monospace;
font-weight: 200;
font-style: normal;
}

.ibm-plex-mono-light {
font-family: "IBM Plex Mono", monospace;
font-weight: 300;
font-style: normal;
}

.ibm-plex-mono-regular {
font-family: "IBM Plex Mono", monospace;
font-weight: 400;
font-style: normal;
}

.ibm-plex-mono-medium {
font-family: "IBM Plex Mono", monospace;
font-weight: 500;
font-style: normal;
}

.ibm-plex-mono-semibold {
font-family: "IBM Plex Mono", monospace;
font-weight: 600;
font-style: normal;
}

.ibm-plex-mono-bold {
font-family: "IBM Plex Mono", monospace;
font-weight: 700;
font-style: normal;
}

.ibm-plex-mono-thin-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 100;
font-style: italic;
}

.ibm-plex-mono-extralight-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 200;
font-style: italic;
}

.ibm-plex-mono-light-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 300;
font-style: italic;
}

.ibm-plex-mono-regular-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 400;
font-style: italic;
}

.ibm-plex-mono-medium-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 500;
font-style: italic;
}

.ibm-plex-mono-semibold-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 600;
font-style: italic;
}

.ibm-plex-mono-bold-italic {
font-family: "IBM Plex Mono", monospace;
font-weight: 700;
font-style: italic;
}

.ibm-plex-mono-thin {
font-family: "IBM Plex Mono", monospace;
font-weight: 100;
font-style: normal;
}

/* Palette -----------------------------------------------------------------
   Single source of truth: script.js and glitch-name.js read these off :root
   so the canvas ground and the DOM never drift apart. Dark ground so the
   RGB-split fringing has something to read against. */
:root {
	--ground: #0c0f14;      /* base, and what canvas fringes resolve toward */
	--ink: #e6e4df;         /* bone — body copy and the name */
	--muted: #9aa4b8;       /* years, secondary labels */
	--accent: #5ecfe0;      /* links — the cyan half of the glitch split */
	--accent-hot: #ff7d94;  /* hover — the magenta half */

	/* Ground gradient. script.js drifts the hue between the two bounds below
	   and breathes the saturation independently, so the ground moves through
	   blue -> violet and periodically falls back toward neutral dark gray.
	   Both stops stay inside the arc — nothing swings into green or yellow. */
	--hue-min: 205;
	--hue-max: 285;
	--grad-desat-min: 0.1;  /* saturation floor — 0 would be pure gray */

	/* Lightness is the AA constraint: the brightest ground is a fully
	   desaturated stop at --grad-lum-b. Raising these will start failing
	   contrast against --muted and --accent-hot first. */
	--grad-sat-a: 45%;
	--grad-lum-a: 9.5%;
	--grad-sat-b: 30%;
	--grad-lum-b: 12%;

	/* Scan lines inside the shard callout text. dim is how bright the dark
	   rows stay (higher = subtler); drift is the time to crawl one period. */
	--tout-scan-dim: 82%;
	--tout-scan-period: 3px;
	--tout-scan-drift: 0.4s;

	/* Pixel clouds on the ground canvas (script.js). They sit under all the
	   text, so the alpha is an AA constraint like the ground lightness: at
	   0.14 the brightest cloud pixel over the brightest ground still leaves
	   --muted and --accent-hot above 4.5:1. Re-check before raising it.
	   shift is how far the nearest layer travels at the viewport edge; far
	   layers move proportionally less. ease is the follow time constant. */
	--cloud-light: #c9d3e6;
	--cloud-shade: #6f8fc9;
	--cloud-alpha-near: 0.14;
	--cloud-alpha-far: 0.06;
	--cloud-pixel: 3px;
	--cloud-shift: 24px;
	--cloud-ease: 0.3s;

	/* Expanding the touts under the résumé: how long the columns take to
	   trade width and the shard takes to shrink. */
	--work-dur: 0.6s;

	/* Page gutter. Everything inside sits flush to it, so the inner 1rem
	   gutters on the masthead and résumé are gone rather than compounding. */
	--gutter: 2rem;
}

/* No overflow lock. The ground canvas is position: fixed, so it stays put as
   the page scrolls over it — the reason the lock existed in the first place
   (a full-viewport canvas that would scroll away) no longer applies. */
body {
	margin: 0;
	background: var(--ground);
	color: var(--ink);
	font-family: "IBM Plex Mono", monospace;

	/* Masthead across the top, then résumé left / #world right. The ground
	   canvas is position: fixed so it sits outside this grid entirely — keep
	   any transform/filter off body or it becomes the canvas's containing
	   block and the background stops covering the viewport.
	   Items stretch by default, which is what lets #left-coast fill the
	   second row and centre its content; #world opts out with align-self. */
	display: grid;
	/* The shard gets two thirds — its callouts need room beside the
	   silhouette. min-content on the résumé side stops it shrinking past its
	   longest unbreakable piece (an employer segment plus a project link);
	   below that the 900px breakpoint takes over. */
	grid-template-columns: minmax(min-content, 1fr) minmax(0, 2fr);
	grid-template-rows: auto 1fr;
	/* border-box, or the padding stacks on top of 100vh and every page
	   picks up 4rem of scroll it doesn't need. */
	box-sizing: border-box;
	min-height: 100vh;
	/* Wider side margins than top/bottom — the shard needs room to breathe
	   horizontally more than the masthead does vertically. */
	padding: var(--gutter) calc(var(--gutter) * 2);
	transition: grid-template-columns var(--work-dur) ease;
  }

/* Touts open: the résumé takes the larger share so the sentences have room,
   and the shard steps back. Only above the stacking breakpoint — below it
   there's one column and nothing to trade. */
@media (min-width: 901px) {
	body.is-work-open {
		grid-template-columns: minmax(min-content, 3fr) minmax(0, 2fr);
	}
}
canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: -1; /* Send canvas to the background */
}

h1 {
	font-size: 1.75rem;
	margin-bottom: 0.25rem;
}

a {
	text-decoration: none;
	color: var(--accent);
	transition: color 120ms linear;
}

a:hover,
a:focus-visible {
	color: var(--accent-hot);
}

/* The UA default ring is visible here, but it differs per browser and was
   never chosen against this palette. Pin it down. */
a:focus-visible {
	outline: 2px solid var(--accent-hot);
	outline-offset: 3px;
	border-radius: 2px;
}

.side__header {
		padding: 0;
	}

.side__header--sub {
			font-size: 1.25rem;
		}

.side__content {
		/* Was calc(50vh - 28px) top and bottom, which vertically centred the
		   block — fine on a tall window, but on a short one it pushed the
		   whole résumé past the fold. Fixed offset instead: starts below the
		   header and grows downward at any viewport height.
		   The horizontal gutter comes from the body padding now. */
		padding: 4rem 0 0 0;
	}

.side__content .grid {
			display: grid;
			/* Two columns: employer, then projects. Each year range sits on its
			   own row above its entry, spanning both. The project column sizes
			   to its longest link so links never wrap; the employer takes what's
			   left and wraps at its bullets. Being this narrow is what frees up
			   the width for #world. The employer is capped so that when the touts
			   open and the résumé widens, the links stay beside their employer
			   instead of drifting off to the far edge.
			   The trailing 1fr takes up the slack, and it has a second job: the
			   tout lists span every column, and a spanning item that crosses a
			   flexible track is left out of track sizing. Without it, the
			   lists' long sentences would inflate the max-content projects
			   column instead. */
			grid-template-columns: minmax(0, 26rem) max-content minmax(0, 1fr);
			gap: 0.25rem 1.5rem;
		}

/* Class names predate the two-column layout; they map onto it as
		   "full width", "employer" and "projects". */

.side__content .colspan-all {
			grid-column: 1 / -1;
		}

.side__content .colspan-3 {
			grid-column: 1;
		}

.side__content .colspan-1 {
			grid-column: 2;
		}

.side__content div {
			padding: 0;

		}

/* Each segment stays whole and the bullet rides on the end of the
			   one before it, so a wrap breaks only between segments and never
			   starts a line with a bullet. */

.side__content div.employer > span {
				white-space: nowrap;
			}

.side__content div.year-list {
				grid-column: 1 / -1;
				color: var(--muted);
				font-size: 0.8125rem;
				white-space: nowrap;
			}

/* Separates one entry from the next; the row gap alone would
				   leave a year sitting as close to the entry above as to its own. */

.side__content div.year-list:not(:first-child) {
					margin-top: 1.25rem;
				}

.side__content div ul {
				margin: 0;
				padding: 0;
			}

.side__content div ul li {
					list-style-type: none;
				}
/* Contained glitch text ---------------------------------------------------
   The h1 keeps owning layout; the canvas is painted over the box it reserves.
   Overrides the global full-viewport `canvas` rule above. */
/* Shrink-wraps to the text (so the canvas box matches the glyphs) while still
   taking its own line — inline-block would sit two instances side by side. */
.glitch {
	position: relative;
	display: block;
	width: -moz-fit-content;
	width: fit-content;
	margin-bottom: 0.25rem;
}

.glitch__text {
	margin: 0;
}

/* Once the canvas takes over, hide the glyphs but keep the text in the
   accessibility tree and selectable. No JS / no canvas = plain heading. */
.glitch.is-live .glitch__text {
	color: transparent;
}

.glitch__canvas {
	position: absolute;
	top: 0;
	left: 0;
	width: auto;
	height: auto;
	z-index: 1;
	pointer-events: none;
}

/* #world ------------------------------------------------------------------
   Placeholder region for the rotating object. Sticky and full-height so the
   object stays in view while the résumé scrolls past it. align-items: start
   on the body grid is what lets it stick rather than stretch to the row. */
#world {
	position: sticky;
	top: var(--gutter);
	/* align-self: start opts out of the row stretch — a stretched item fills
	   its area and sticky then has nowhere to travel. 100% keeps it inside
	   the area on a short page (no stray scroll from the masthead row);
	   100vh caps it once the résumé makes the row taller, which is when
	   sticky actually needs the headroom. */
	align-self: start;
	height: min(calc(100vh - var(--gutter) * 2), 100%);
	display: grid;
	place-items: center;
}

.world__stage {
	/* The canvas takes these dimensions; hotspot buttons get positioned
	   against this box, so it needs to be the positioning context.
	   Full column width, not a square: the shard is tall and thin, so its
	   size is set by the height (rock.js fits to the smaller of the two) and
	   the extra width is room for callouts beside the silhouette. 85vh keeps
	   it fully visible on short screens, 56rem stops it towering on tall
	   ones. Bleeding past the column would need clipping somewhere, and that
	   fights the sticky positioning. */
	position: relative;
	/* border-box, or the 1px placeholder border pushes the box 2px past the
	   column and the page picks up a horizontal scrollbar. */
	box-sizing: border-box;
	width: 100%;
	height: min(85vh, 56rem);
	/* The shard fits to the stage's height, so animating the height is what
	   animates the shard; rock.js redraws on every resize step. */
	transition: height var(--work-dur) ease;
	display: grid;
	place-items: center;
	border: 1px dashed color-mix(in srgb, var(--muted) 35%, transparent);
	border-radius: 2px;
}

.world__placeholder {
	margin: 0;
	color: var(--muted);
	font-size: 0.875rem;
	letter-spacing: 0.08em;
	text-transform: uppercase;
}

@media (min-width: 901px) {
	.is-work-open .world__stage {
		height: min(60vh, 40rem);
	}
}

/* Name and title, out of the centred band and pinned to the upper left.
   Spans both columns so the row exists across the full width, but its
   contents stay flush left. */
.masthead {
	grid-column: 1 / -1;
	display: flex;
	/* Name left, contact right. Wrapping matters on narrow screens — the two
	   groups drop onto separate lines rather than crushing together. */
	justify-content: space-between;
	align-items: flex-start;
	flex-wrap: wrap;
	gap: 0.5rem 1.5rem;
}

.masthead__contact {
	display: flex;
	gap: 1.25rem;
	font-size: 0.9375rem;
	/* Sits on the name's baseline rather than the top of its box. */
	padding-top: 0.4rem;
}

/* Icon-only links are far too small a target on touch. Padding grows the hit
   area and the negative margin cancels the layout shift, so the links sit
   exactly where they look like they sit. Wider inline padding than the text
   version needed, since an icon is narrower than a word. */
.masthead__contact a {
	display: inline-flex;
	padding: 0.6rem 0.5rem;
	margin: -0.6rem -0.5rem;
}

/* currentColor is the point of inlining these rather than using <img>: the
   icons pick up the link colour and its hover transition for free. */
.masthead__contact svg {
	width: 20px;
	height: 20px;
	display: block;
}

/* Centre the résumé group vertically against the shard. body uses
   align-items: start (which #world's sticky positioning depends on), so the
   column doesn't stretch on its own — min-height gives it something to
   centre within. min-height rather than height, so a group taller than the
   viewport grows the column instead of overflowing past the top where it
   couldn't be scrolled to. */
#left-coast {
	display: flex;
	flex-direction: column;
	justify-content: center;
}

/* Touts under the résumé --------------------------------------------------
   Collapsed to nothing until body.is-work-open (end of rock.js). The 0fr -> 1fr
   row is what lets the height animate to its natural size; visibility
   keeps the collapsed text out of the accessibility tree, flipping only
   once the close has finished so it doesn't vanish mid-animation. */
.work {
	display: grid;
	grid-template-rows: 0fr;
	visibility: hidden;
	transition: grid-template-rows var(--work-dur) ease, visibility 0s var(--work-dur);
}

.is-work-open .work {
	grid-template-rows: 1fr;
	visibility: visible;
	transition: grid-template-rows var(--work-dur) ease, visibility 0s;
}

.work__inner {
	overflow: hidden;
	min-height: 0;
}

.work__project {
	padding-top: 1rem;
}

.work__name {
	margin: 0 0 0.5rem;
	color: var(--muted);
	font-size: 0.8125rem;
	font-weight: normal;
}

.side__content .work__touts li {
	max-width: 36rem;
	margin-bottom: 0.6rem;
	padding-left: 0.75rem;
	border-left: 2px solid transparent;
	font-size: 0.9375rem;
	line-height: 1.45;
	transition: border-color 120ms linear;
}

/* .is-lit: its face is under the pointer on the shard. Hovering the tout
   itself does the reverse and turns the shard to it. */
.side__content .work__touts li:hover,
.side__content .work__touts li.is-lit {
	border-left-color: var(--accent-hot);
}

/* .is-target: just opened from the shard; a brief wash says "this one". */
.side__content .work__touts li.is-target {
	animation: work-target 2s ease-out;
}

@keyframes work-target {
	from { background-color: color-mix(in srgb, var(--accent-hot) 18%, transparent); }
	to   { background-color: transparent; }
}

.work-toggle {
	margin-top: 1.5rem;
	padding: 0.4rem 0;
	border: 0;
	background: none;
	color: var(--accent);
	font-size: 0.8125rem;
	letter-spacing: 0.04em;
	cursor: pointer;
	transition: color 120ms linear;
}

.work-toggle::before {
	content: "+ ";
	content: "+ " / "";
}

.work-toggle[aria-expanded="true"]::before {
	content: "\2212  ";
	content: "\2212  " / "";
}

.work-toggle:hover,
.work-toggle:focus-visible {
	color: var(--accent-hot);
}

.work-toggle:focus-visible {
	outline: 2px solid var(--accent-hot);
	outline-offset: 3px;
	border-radius: 2px;
}

/* Opening from a #work link: land expanded without playing the animation. */
.is-work-instant,
.is-work-instant * {
	transition: none !important;
}

@media (prefers-reduced-motion: reduce) {
	body,
	.world__stage,
	.work,
	.is-work-open .work {
		transition: none;
	}

	.side__content .work__touts li.is-target {
		animation: none;
		border-left-color: var(--accent-hot);
	}
}

/* Single column before the two halves get too narrow to be useful. The
   résumé's own two columns still fit at phone width, so it keeps them. */
@media (max-width: 900px) {
	:root {
		--gutter: 1rem;
	}

	body {
		grid-template-columns: minmax(0, 1fr);
		grid-template-rows: auto;
	}

	#world {
		position: static;
		height: auto;
		padding: 0 0 4rem;
	}

	.world__stage {
		/* Stacked, the column is the full page width — square again, or the
		   stage would be a very tall strip. */
		width: min(100%, 28rem);
		height: auto;
		aspect-ratio: 1;
	}

	#left-coast {
		min-height: 0;
		display: block;
	}

	.side__content {
		padding-bottom: 3rem;
	}
}

/* Rock wireframe ----------------------------------------------------------
   Overrides the global full-viewport `canvas` rule. The mount is the
   positioning context, which is also what the hotspot buttons will need. */
.rock__canvas {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	z-index: 0;
}

/* Drop the placeholder scaffolding once the renderer takes over. */
.world__stage.is-live {
	border-color: transparent;
}

.world__stage.is-live .world__placeholder {
	display: none;
}

/* Shard callouts ----------------------------------------------------------
   Positioned by rock.js inside .world__stage. Opacity is driven per frame from
   the same easing as the leader line on the canvas, so the two can't drift
   apart — which is why there's no CSS transition here. pointer-events: none
   so the text can't steal hover from the faces underneath it. */
.rock__callout {
	position: absolute;
	z-index: 1;
	margin: 0;
	color: var(--ink);
	font-size: 0.9375rem;
	line-height: 1.45;
	pointer-events: none;
}

/* Scan lines through the glyphs only. The stripes are the text's own fill,
   clipped to the letterforms, so they never bleed into the gaps between words
   or darken the shard underneath. Kept faint (--tout-scan-dim) because
   stronger stripes cost legibility. Behind @supports because without
   background-clip: text, transparent text would simply vanish.

   They crawl downward like the old background lines, but slower: those moved
   30px/s, which is fine on a faint ground and shimmers inside text you're
   reading. Speed is one period per --tout-scan-drift. */
@supports (((-webkit-background-clip: text) or (background-clip: text)) or (-webkit-background-clip: text)) {
	.rock__callout {
		color: transparent;
		background-image: repeating-linear-gradient(
			to bottom,
			var(--ink) 0 calc(var(--tout-scan-period) - 1px),
			color-mix(in srgb, var(--ink) var(--tout-scan-dim), transparent)
				calc(var(--tout-scan-period) - 1px) var(--tout-scan-period)
		);
		-webkit-background-clip: text;
		background-clip: text;
		animation: tout-scan var(--tout-scan-drift) linear infinite;
	}
}

/* Touch only: "Tap the shard again…" under the tout. Its own colour paints
   over the clipped scan-line fill, so it reads as a quieter label. */
.rock__hint {
	display: block;
	margin-top: 0.4rem;
	color: var(--muted);
	font-family: "IBM Plex Mono", monospace;
	font-size: 0.75rem;
	letter-spacing: 0.04em;
}

/* Mouse and pen: a small label riding the cursor over a face. Positioned by
   transform from rock.js, so moving it never triggers layout. */
.rock__tip {
	position: absolute;
	top: 0;
	left: 0;
	z-index: 2;
	padding: 0.25rem 0.5rem;
	border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent);
	border-radius: 2px;
	background: color-mix(in srgb, var(--ground) 88%, transparent);
	color: var(--ink);
	font-family: "IBM Plex Mono", monospace;
	font-size: 0.75rem;
	letter-spacing: 0.04em;
	white-space: nowrap;
	pointer-events: none;
	opacity: 0;
	transition: opacity 120ms linear;
}

.rock__tip.is-on {
	opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
	.rock__tip {
		transition: none;
	}
}

@keyframes tout-scan {
	to {
		background-position: 0 var(--tout-scan-period);
	}
}

@media (prefers-reduced-motion: reduce) {
	.rock__callout {
		animation: none;
	}
}

/* Résumé links that answer the shard ----------------------------------------
   rock.js adds .is-glitching while a matching tout flickers in, then
   .is-touted while it stays up; both take the hover colour. data-project
   must match a tout's data-tout exactly; rock.js copies each
   link's text into data-text.

   inline-block so the glitch copies (absolutely positioned pseudo-elements)
   wrap exactly like the link itself — these wrap onto two lines at common
   widths. The copies use the CSS alt-text syntax (content: … / "") so screen
   readers don't hear the link name three times; browsers without it fall
   back to the first declaration. */
[data-project] {
	position: relative;
	display: inline-block;
}

[data-project]::before,
[data-project]::after {
	content: attr(data-text);
	content: attr(data-text) / "";
	position: absolute;
	inset: 0;
	opacity: 0;
	pointer-events: none;
}

/* Lit from the first glitch frame until the tout leaves — the same colour the
   link takes on hover, so the pairing reads as "this one". */
[data-project].is-glitching,
[data-project].is-touted {
	color: var(--accent-hot);
}

/* The link itself jumps and drops out in bands while two offset copies — cyan
   and white — tear across it. transform and clip-path don't affect layout, so
   none of this can re-wrap the link or move the résumé. */
[data-project].is-glitching {
	animation: link-glitch-base var(--link-glitch-dur, 1s) steps(1, end) forwards;
}

[data-project].is-glitching::before {
	color: var(--accent);
	animation: link-glitch-a var(--link-glitch-dur, 1s) steps(1, end) forwards;
}

[data-project].is-glitching::after {
	color: var(--ink);
	animation: link-glitch-b var(--link-glitch-dur, 1s) steps(1, end) forwards;
}

/* Each step holds until the next (steps(1)), so the bands jump rather than
   slide — the same choppy channel-split as the name. The tail (last ~20%)
   settles, matching the tout text holding steady before it finishes. */
@keyframes link-glitch-base {
	0%   { transform: translateX(-4px) skewX(-12deg); clip-path: inset(0 0 45% 0); }
	8%   { transform: translateX(5px);  clip-path: inset(40% 0 0 0); opacity: 0.35; }
	16%  { transform: translateX(-2px); clip-path: none; opacity: 1; }
	24%  { transform: translateX(6px) skewX(10deg); clip-path: inset(20% 0 30% 0); }
	32%  { transform: translateX(-5px); clip-path: inset(0 0 60% 0); opacity: 0.5; }
	40%  { transform: none; clip-path: none; opacity: 1; }
	48%  { transform: translateX(3px);  clip-path: inset(55% 0 0 0); }
	56%  { transform: translateX(-3px) skewX(-6deg); clip-path: none; opacity: 0.6; }
	64%  { transform: translateX(2px);  opacity: 1; }
	72%  { transform: translateX(-1px); clip-path: inset(0 0 30% 0); }
	80%  { transform: none; clip-path: none; opacity: 1; }
	100% { transform: none; clip-path: none; opacity: 1; }
}

@keyframes link-glitch-a {
	0%   { opacity: 1; transform: translateX(-9px); clip-path: inset(8% 0 62% 0); }
	8%   { transform: translateX(7px);  clip-path: inset(55% 0 18% 0); }
	16%  { transform: translateX(-12px); clip-path: inset(0 0 74% 0); }
	24%  { transform: translateX(10px); clip-path: inset(68% 0 6% 0); }
	32%  { transform: translateX(-6px); clip-path: inset(30% 0 44% 0); }
	40%  { transform: translateX(8px);  clip-path: inset(80% 0 0 0); }
	48%  { transform: translateX(-10px); clip-path: inset(42% 0 38% 0); }
	56%  { transform: translateX(5px);  clip-path: inset(10% 0 55% 0); }
	64%  { transform: translateX(-4px); clip-path: inset(62% 0 10% 0); }
	72%  { opacity: 1; transform: translateX(3px); clip-path: inset(25% 0 50% 0); }
	80%  { opacity: 0; transform: none; clip-path: none; }
	100% { opacity: 0; transform: none; clip-path: none; }
}

@keyframes link-glitch-b {
	0%   { opacity: 0.9; transform: translateX(8px);  clip-path: inset(60% 0 12% 0); }
	8%   { transform: translateX(-11px); clip-path: inset(12% 0 64% 0); }
	16%  { transform: translateX(6px);  clip-path: inset(72% 0 4% 0); }
	24%  { transform: translateX(-8px); clip-path: inset(24% 0 52% 0); }
	32%  { transform: translateX(12px); clip-path: inset(0 0 80% 0); }
	40%  { transform: translateX(-5px); clip-path: inset(48% 0 30% 0); }
	48%  { transform: translateX(9px);  clip-path: inset(16% 0 66% 0); }
	56%  { transform: translateX(-7px); clip-path: inset(70% 0 8% 0); }
	64%  { transform: translateX(4px);  clip-path: inset(35% 0 40% 0); }
	72%  { opacity: 0.9; transform: translateX(-3px); clip-path: inset(5% 0 70% 0); }
	80%  { opacity: 0; transform: none; clip-path: none; }
	100% { opacity: 0; transform: none; clip-path: none; }
}

@media (prefers-reduced-motion: reduce) {
	[data-project].is-glitching,
	[data-project].is-glitching::before,
	[data-project].is-glitching::after {
		animation: none;
	}
}
