/* ---------- site-wide accent gradient: background wash + bottom glow ----------
   inspired by codepen.io/filipz/pen/NPqJJyz's background-circles + bottom
   spectrum technique, but restrained to ONE calm accent hue (a muted
   indigo/blue) rather than the pen's full rainbow, to stay in step with
   the rest of the site's black/off-white palette -- a quiet point of
   colour, not a loud wash (per explicit direction: "차분한 단색 포인트").
   blurred on the blobs' own painted gradient, never via backdrop-filter --
   the same bug caught earlier with .page-bloom: backdrop-filter samples
   and blurs the real page behind it (including text), where filter:blur()
   on an element's own background only blurs that element's own pixels. */
.page-accent-bg {
  position: fixed; inset: 0; z-index: 0;
  pointer-events: none; overflow: hidden;
}
.page-accent-blob {
  position: absolute;
  border-radius: 50%;
  /* blur radius scales with the blob's own vw-based size instead of a
     fixed px value -- a fixed blur(90px) against a blob that's 46vw/50vw
     wide shrinks the blob itself at narrow viewports (720px -> 187.5px
     going from a 1440px to a 375px frame, measured) while the blur stays
     90px regardless, so the blur-to-size RATIO balloons (0.125 -> 0.48,
     measured) and spreads what little colour there is across an area far
     larger than the shape itself -- which is what was reading as "barely
     visible" on mobile. Clamped to roughly the same ratio at both ends
     (~0.12-0.13, matching the original desktop look) instead of a fixed
     px number. */
  filter: blur(clamp(24px, 6vw, 90px));
  opacity: 0.14;
}
.page-accent-blob-1 {
  top: -14%; left: -10%; width: 46vw; height: 46vw;
  background: radial-gradient(circle, #6f93e8 0%, #2c3f7a 55%, transparent 75%);
}
.page-accent-blob-2 {
  bottom: -16%; right: -12%; width: 50vw; height: 50vw;
  background: radial-gradient(circle, #8fb4ff 0%, #3652a3 55%, transparent 75%);
}

/* sits at the very end of the document flow (after .about, before the
   cursor dot) so it's only ever seen once the page is scrolled all the
   way down -- no scroll-trigger JS needed, it's just where it lives */
.page-bottom-glow {
  position: relative; z-index: 0;
  height: 28vh;
  pointer-events: none; overflow: hidden;
}
.page-bottom-glow::before {
  content: "";
  position: absolute; inset: 0;
  background: radial-gradient(70% 100% at 50% 100%, rgba(96, 134, 222, 0.30), transparent 72%);
  filter: blur(36px);
}

/* ---------- About-section mouse-follow photo trail ----------
   small photos spawn at the cursor while it's inside the About section
   only, inspired by codepen.io/filipz/pen/yyNGpmv's cursor image-trail --
   scaled down (96x96 vs. the pen's 160-340px -- "크기는 너무 크지않게",
   and square to match the source selfies' own 1:1 crop) and simplified to
   one reveal mode. the trail container and each photo are position:fixed
   to the VIEWPORT (not clipped by .about's own box), so a photo already
   mid-animation is never cut off just because the cursor has since left
   the section -- only SPAWNING a new photo is gated by the cursor being
   inside .about's live bounding rect (see initAboutHoverTrail in
   js/home.js); nothing here ever applies overflow:hidden to a photo. */
.about-hover-trail {
  position: fixed; inset: 0; z-index: 40;
  pointer-events: none;
}
.about-hover-img {
  position: fixed; top: 0; left: 0; z-index: 40;
  width: 96px; height: 96px;
  object-fit: cover;
  pointer-events: none;
  border-radius: 50%;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25);
  opacity: 0;
}
/* used to force-hide the whole trail on narrow/coarse-pointer (i.e.
   touch) devices here -- now that js/home.js's initAboutHoverTrail
   also drives spawn() from touchmove, touch devices are meant to show
   this too, so that blanket hide is gone. 96px images are left as-is
   for touch; no evidence yet that they read wrong at phone width. */

/* ---------- hero ---------- */
.hero { position: relative; height: 100dvh; }
.hero-stage { position: relative; height: 100%; overflow: hidden; }

.intro {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
}
.tilt-outer { position: relative; z-index: 1; }
.tilt-inner {
  font-size: clamp(3.5rem, 13vw, 10rem); font-weight: 500; letter-spacing: -0.04em; line-height: 1;
  white-space: nowrap; text-align: center;
  filter: url(#goo-text);
}
.tilt-inner .goo-ch { will-change: transform; }

/* sized/positioned by JS (js/home.js buildHero) -- starts FULLSCREEN
   (hosting the infinite-canvas scene, see .hero-canvas below), scales
   down into the "Djang"/"E" seam, then regrows from there, holds, and
   exits downward -- see buildHero's phase comments for the full
   sequence */
.hero-box {
  position: absolute; top: 50%; left: 50%;
  z-index: 2;
  /* plain colour, then one real cover photo behind it: the WebGL tile
     grid (js/home.js initHeroCanvas) is what's meant to fill this box,
     but it only mounts if THREE/WebGL are actually available (it bails
     silently -- try/catch -- if not) and its own textures load async on
     top of that, so there's a real window (a slow/blocked WebGL context,
     or just the textures not in yet) where nothing is painted on top of
     this box's own background. A flat colour alone reads as "no cards"
     in exactly that window; one static cover photo behind the canvas
     means there's always at least one real image showing instead of a
     blank rectangle, with zero visual difference once the canvas does
     mount (it fully covers the box at every size -- see .hero-canvas
     below). Not a confirmed fix for any specific reported case, just a
     safety net under genuinely different failure window. */
  background: #fff url(../assets/covers/cover-01.jpg) center / cover no-repeat;
  overflow: hidden;
}
/* the infinite-canvas WebGL scene (js/home.js initHeroCanvas) lives as a
   plain child of .hero-box, but is deliberately sized to the VIEWPORT
   (100vw/100vh, centred), not to .hero-box's own shrinking width/height
   -- .hero-box's overflow:hidden does the visual cropping as it shrinks,
   like an aperture closing over a fixed scene, rather than the canvas
   itself being resized every scroll-scrubbed frame of phase A/B/C's box
   tween. Tying the canvas's own size to the box (width/height:100%) was
   tried first and visibly flickered: a WebGL drawing-buffer resize
   (renderer.setSize) is a real framebuffer reallocation, firing on every
   tick of a continuous width/height tween is not something a browser
   can keep glitch-free. Fixed-size + pure CSS clipping has nothing to
   reallocate, so there's nothing left to flicker -- and it also reads
   as the right motion for "a box shrinking over a stable scene" (the
   scene itself never warps/reflows as the box closes over it), which a
   resizing canvas never quite did either. */
.hero-canvas {
  position: absolute; top: 50%; left: 50%;
  width: 100vw; height: 100vh;
  transform: translate(-50%, -50%);
  display: block;
  will-change: opacity;
}
@media (max-width: 760px), (pointer: coarse) {
  /* drag-to-pan is desktop-only (see initHeroCanvas); on touch there's
     nothing to grab, so the cursor affordance some browsers still infer
     from a plain canvas is suppressed */
  .hero-canvas { cursor: default !important; }
}
/* the single line that sweeps right-to-left after the hero box's own
   leftward exit (js/home.js buildHero). Offstage start/end x are
   measured and set in JS; this only lays out its natural rest spot.
   Per-char scatter (.ch yPercent/rotation) + the goo filter resolve as
   each letter crosses the screen. */
.row {
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  display: flex; align-items: center;
  pointer-events: none;
}
.row-text {
  white-space: nowrap; font-weight: 500;
  font-size: clamp(2.75rem, 11vw, 13rem);
  line-height: 1.05; letter-spacing: -0.04em;
  padding-left: var(--gutter);
  filter: url(#goo-text-row);
  will-change: transform;
}
.row-text .ch { display: inline-block; }

/* ---------- work ----------
   Modelled on the supplied reference (a big "PROJECTS [nn]" headline,
   then one large row per project: info column left, big image right),
   kept in this site's own light palette. Plain document flow -- no pin;
   motion (title rise, row reveal, image grow) is all in
   js/home.js buildWork. */
.work {
  position: relative; z-index: 1;
  padding: clamp(96px, 14vh, 180px) var(--gutter) clamp(96px, 14vh, 180px);
}
.work-head {
  display: grid; grid-template-columns: 1fr auto; align-items: end;
  row-gap: clamp(8px, 1.2vw, 18px);
}
.work-kicker, .work-range {
  font-size: 13px; letter-spacing: 0.02em;
  text-transform: uppercase;
}
.work-range { justify-self: end; }
.work-title {
  grid-column: 1 / -1;
  display: flex; align-items: flex-start;
  margin: 0;
  font-size: clamp(4rem, 17.5vw, 21rem); font-weight: 600;
  line-height: 0.82; letter-spacing: -0.065em;
  text-transform: uppercase;
}
/* mask for the letter-rise (js: SplitText chars from yPercent:110) */
.work-title-word { display: block; overflow: hidden; padding: 0 0.08em 0.04em 0; margin-left: -0.04em; }
.work-title-word .wt-ch { display: inline-block; }
.work-count {
  font-size: 0.2em; font-weight: 400; letter-spacing: -0.02em;
  line-height: 1; margin-left: 0.18em; margin-top: 0.08em;
  vertical-align: top;
}

/* ---------- work gallery (js/home.js buildGallery) ----------
   full-viewport pinned stage; tiles are absolutely positioned by JS
   (overview mosaic + one editorial layout per project). The row list
   above stays as the accessible link list but is visually hidden while
   the gallery runs. */
.wg { display: none; }
.has-gallery .wg {
  display: block;
  margin: clamp(32px, 5vw, 72px) calc(-1 * var(--gutter)) 0;
}
.has-gallery .work-list {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; margin: 0;
}
.has-gallery { padding-bottom: 0; }
/* solid page colour so the title's difference blend (.wg-title) has a
   real backdrop to invert against -- over the transparent section it
   would just stay white */
.wg-stage { position: relative; height: 100vh; height: 100svh; overflow: hidden; background: var(--bg); }
.wg-tiles { position: absolute; inset: 0; }
.wg-t {
  position: absolute; display: block; overflow: hidden;
  background: #e9e8e4; will-change: transform, opacity;
}
.wg-t img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .6s cubic-bezier(.2,.7,.2,1); }
.wg-p:hover img { transform: scale(1.04); }
/* glitch copies: plain (uncoloured) copies of the image, hidden at
   rest, shown only as shifted slices during a glitch in/out */
.wg-g {
  position: absolute; inset: 0; opacity: 0; pointer-events: none;
  background: center / cover no-repeat;
}
.wg-title {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  z-index: 2; text-align: center; pointer-events: none; white-space: nowrap;
  mix-blend-mode: difference;
}
.wg-name {
  display: inline-block; min-height: 1.1em;
  font-size: clamp(1.35rem, 2.4vw, 2.6rem); font-weight: 500;
  letter-spacing: -0.025em; line-height: 1.1;
  /* white + difference: reads black over the light page, white where it
     crosses a dark image (the editorial layouts let them overlap) */
  color: #fff;
}
/* desktop: preview images and the title are display-only so leaving the
   hovered thumbnail always closes the preview (js onOver); on touch the
   preview images stay tappable (second tap opens the project) */
@media (hover: hover) { .wg-p { pointer-events: none; } }
.wg-meta { margin: 6px 0 0; font-size: 13px; color: #fff; opacity: 0.6; min-height: 1em; }
.wg-count, .wg-hint {
  position: absolute; bottom: 18px; margin: 0; z-index: 2;
  font-size: 12px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums;
}
.wg-count { left: var(--gutter); }
.wg-hint { right: var(--gutter); color: #6b6b67; text-transform: uppercase; }

.work-list { list-style: none; margin: clamp(40px, 6vw, 96px) 0 0; padding: 0; }
.work-item + .work-item { margin-top: clamp(20px, 2.4vw, 36px); }
.work-row {
  display: grid; grid-template-columns: repeat(12, 1fr);
  column-gap: clamp(16px, 2vw, 32px);
  color: inherit; text-decoration: none;
}
.work-info {
  grid-column: 1 / span 5;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: 4px 0 clamp(12px, 1.4vw, 20px);
  border-bottom: 1px solid var(--line);
}
.work-info-top {
  display: grid; grid-template-columns: auto 1fr auto; column-gap: 0.6em; row-gap: 8px;
  align-items: start;
}
.work-no {
  /* scales with the title instead of a fixed 12px, and sits on the
     title's cap line (not its baseline), so it reads as an index to
     the name rather than a speck at its foot */
  font-size: clamp(0.85rem, 1.1vw, 1.2rem); font-weight: 500; line-height: 1;
  margin-top: 0.3em; color: var(--muted); font-variant-numeric: tabular-nums;
}
.work-name {
  margin: 0;
  font-size: clamp(1.75rem, 3vw, 3.4rem); font-weight: 500;
  letter-spacing: -0.035em; text-transform: uppercase; line-height: 1;
}
.work-year { font-size: 12px; color: var(--muted); grid-column: 3; grid-row: 1; }
/* title letters (js/home.js setupTitleFx): fixed-width boxes so the
   glyph scramble never shifts the word sideways */
.work-name .wn-w { display: inline-block; white-space: nowrap; }
.work-name .wn-ch { display: inline-block; text-align: center; position: relative; }
/* "blob-tracking" box + width readout on some letters while the title
   is live (as in the reference: thin 1px box in its own colour, which
   is independent of the letter's colour, and a small "△x=NNpx" label
   in that same colour just under the box) */
.work-name .wn-ch.is-box::before {
  content: ""; position: absolute; inset: 0;
  border: 1px solid var(--bc);
  pointer-events: none;
}
.work-name .wn-ch.is-box::after {
  content: attr(data-dx);
  position: absolute; left: 0; top: 100%;
  margin-top: 3px;
  font-size: 11px; font-weight: 500; line-height: 1;
  letter-spacing: -0.02em; text-transform: none;
  color: var(--bc); white-space: nowrap;
  pointer-events: none;
}
.work-disc {
  grid-column: 2 / -1; margin: 0;
  font-size: 12px; letter-spacing: 0.03em; text-transform: uppercase; color: var(--muted);
}
.work-info-bottom {
  display: flex; justify-content: space-between; align-items: baseline;
  margin-top: 24px; font-size: 12px; text-transform: uppercase;
}
.work-arrow { font-size: 1.25rem; transition: transform .35s cubic-bezier(.2,.7,.2,1); }
.work-client { color: var(--muted); }

.work-media {
  grid-column: 6 / span 7;
  position: relative; display: block;
  aspect-ratio: 16 / 10;
  transform-origin: 50% 50%;
  will-change: transform;
}
.work-media img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; border-radius: 4px;
  background: var(--surface);
  transition: filter .4s ease;
}
/* scroll-velocity shader canvas (js/home.js setupScrollShader): sits
   exactly over the <img>, only visible while scrolling down */
.work-gl {
  position: absolute; inset: 0; width: 100%; height: 100%;
  display: block; border-radius: 4px;
  opacity: 0; pointer-events: none;
}
.work-row:hover .work-arrow,
.work-row:focus-visible .work-arrow { transform: translateX(10px); }
.work-row:hover .work-media img { filter: brightness(1.05) contrast(1.04); }

@media (max-width: 760px) {
  .work-row { grid-template-columns: 1fr; row-gap: 14px; }
  .work-media { grid-column: 1; grid-row: 1; }
  .work-info { grid-column: 1; grid-row: 2; }
  .work-info-bottom { margin-top: 14px; }
  .work-item + .work-item { margin-top: 48px; }
}

/* ---------- top info blurb (home only, echoes About) ---------- */
.top-info { grid-column: 9 / span 4; grid-row: 2; margin-top: 10px; max-width: 34ch; }
.top-info p { color: var(--muted); }
.top-info-mail {
  display: inline-block; margin-top: 6px;
  text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px;
}
@media (max-width: 900px) { .top-info { display: none; } }

/* ---------- cursor-follow dot (page-wide) ---------- */
.cursor-dot {
  position: fixed; left: 0; top: 0; z-index: 70;
  width: 16px; height: 16px; border-radius: 50%;
  border: 1.5px solid var(--text); background: transparent;
  mix-blend-mode: difference;
  pointer-events: none;
  transition: background-color .2s ease;
}
.cursor-dot.is-active { background: var(--text); }
@media (max-width: 900px), (pointer: coarse) { .cursor-dot { display: none; } }

/* ---------- global post-processing: bloom + grain ----------
   two fixed, full-viewport, pointer-events:none layers stacked above all
   real content (inspired by codepen.io/filipz/pen/VYwEggV's WebGL
   bloom+grain composite, adapted to pure CSS since that pen's scene is a
   dark WebGL nebula and this page is a light, text-heavy design).

   bloom -- two things were tried and rejected before this version, both
   caught by actually screenshotting the result rather than assuming:
     1. backdrop-filter blur + brightness boost + screen blend (the pen's
        literal technique): screen(white, bright-anything) = white, so a
        brightness boost would wash this site's near-white (--bg:#fbfbfa)
        background to flat white everywhere.
     2. dropping just the brightness term (blur-only backdrop-filter +
        screen): fixes the whiteout, but backdrop-filter blurs the real
        DOM content sitting behind the overlay -- confirmed by screenshot,
        this blurred every line of real text on the page to illegibility,
        since a full-viewport backdrop-filter affects everything under it
        with no way to exempt text from it.
   this version never samples real content at all: it's a few large, soft,
   blurred radial-gradient "light blobs" (filter:blur() on the gradient
   the element itself paints, not backdrop-filter on the page), screen-
   blended on top. screen(white, white) = white, so it still stays
   invisible over plain background exactly as before, while bleeding a
   genuine soft glow over darker content (text, the hero-box) -- without
   ever touching those elements' own sharpness, because the blur never
   samples them in the first place. the gradients drift slowly for a
   living, atmospheric feel rather than a static wash.

   grain: tiled SVG feTurbulence(fractalNoise) noise, desaturated via
   feColorMatrix, blended with mix-blend-mode:overlay at low opacity, with
   a tiny animated background-position shift for a flickering film-grain
   feel. layered above bloom, matching the reference pen's pass order
   (bloom pass before grain pass). */
.page-bloom {
  position: fixed; inset: -15%; z-index: 65;
  pointer-events: none;
  mix-blend-mode: screen;
  filter: blur(70px);
  background:
    radial-gradient(38vw 38vw at 15% 22%, rgba(255,255,255,0.55), transparent 70%),
    radial-gradient(42vw 42vw at 85% 78%, rgba(255,255,255,0.5), transparent 70%),
    radial-gradient(30vw 30vw at 72% 12%, rgba(255,255,255,0.4), transparent 70%);
  animation: bloom-drift 24s ease-in-out infinite alternate;
}
@keyframes bloom-drift {
  0%   { transform: translate(0, 0) scale(1); }
  100% { transform: translate(-3%, 3%) scale(1.05); }
}
@media (prefers-reduced-motion: reduce) {
  .page-bloom { animation: none; }
}
.page-grain {
  position: fixed; inset: 0; z-index: 66;
  pointer-events: none;
  opacity: 0.06;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 180px 180px;
  animation: grain-shift 1.1s steps(4) infinite;
}
@keyframes grain-shift {
  0%   { background-position: 0 0; }
  25%  { background-position: -35px 15px; }
  50%  { background-position: 20px -40px; }
  75%  { background-position: -15px -20px; }
  100% { background-position: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
  .page-grain { animation: none; }
}

/* ---------- about ---------- */
.about { position: relative; min-height: 100dvh; padding: 96px var(--gutter) var(--gutter); }
.about-grid {
  min-height: calc(100dvh - 96px - var(--gutter));
  display: grid; grid-template-columns: repeat(12, 1fr);
  grid-template-rows: auto 1fr auto;
  column-gap: 0; row-gap: 40px;
}
.about-label { grid-column: 1 / span 2; font-size: var(--fs); font-weight: 400; }
.about-head {
  grid-column: 1 / span 8; grid-row: 2;
  align-self: center;
  font-size: clamp(2rem, 6.4vw, 7.5rem); font-weight: 500;
  line-height: 1; letter-spacing: -0.045em; text-wrap: balance;
}
.about-body { grid-column: 10 / span 3; grid-row: 1; display: grid; gap: 14px; max-width: 34ch; }
.about-portrait { grid-column: 10 / span 3; grid-row: 2; align-self: end; justify-self: start; }
.about-portrait img { display: block; width: 100%; max-width: 170px; height: auto; }
.about-contact { grid-column: 10 / span 3; grid-row: 3; display: grid; gap: 2px; align-content: start; }
.about-mail { justify-self: start; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.about-links { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
.about-link-pending { color: var(--muted); }
.about-links a { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.about-copy { grid-column: 1 / span 4; grid-row: 3; align-self: end; color: var(--muted); }

/* ---------- small screens ---------- */
@media (max-width: 760px) {
  .top-info { display: none; }
  .about { padding-top: 72px; }
  .about-grid { grid-template-rows: auto auto auto auto; }
  .about-label { grid-column: 1 / -1; }
  .about-head { grid-column: 1 / -1; grid-row: 2; font-size: 2.4rem; }
  .about-body { grid-column: 1 / -1; grid-row: 3; }
  .about-portrait { grid-column: 1 / -1; grid-row: 4; justify-self: start; }
  .about-portrait img { max-width: 140px; }
  .about-contact { grid-column: 1 / -1; grid-row: 5; }
  .about-copy { grid-column: 1 / -1; grid-row: 6; margin-top: 24px; }
}

/* ---------- reduced motion: static layout, no pin ---------- */
.static .intro { position: relative; height: 100%; }
.static .row { display: none; }
.static .page-grain, .static .page-bloom { display: none; }
.static .menu-island, .static .menu-backdrop { display: none; }
.static .cursor-dot { display: none; }
