/* ---------------------------------------------------------------
   Ganmarie Duque · personal site
   Film grain, monospace instrumentation, masked reveals.
   No build step, no dependencies.
----------------------------------------------------------------*/

/* Dark is the default. It is the base palette rather than a
   prefers-color-scheme branch, so it holds even with JS disabled and
   regardless of what the operating system is set to. Light is opt-in,
   through the toggle, and persists in localStorage from there. */
:root {
  color-scheme: dark;

  --bg:        #14161a;
  --panel:     #1b1e23;
  --text:      #dcdde1;
  --muted:     #979aa3;
  --faint:     #6d7078;
  --line:      #2a2e35;
  --line-soft: #21252b;
  /* Matcha, whisked. A yellow-green rather than a mint, the same hue as
     the cup next to the keyboard. */
  --accent:    #a8cc78;
  --dot:       #8fbb5e;

  --grain-opacity: 0.5;
  --spot-strength: 9%;

  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --measure: 47rem;
  --gap: 5rem;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

:root[data-theme="light"] {
  color-scheme: light;

  --bg:        #f4f4f1;
  --panel:     #fafaf8;
  --text:      #101012;
  --muted:     #5f5e66;
  --faint:     #98969f;
  --line:      #dedcd6;
  --line-soft: #e8e6e1;
  /* Ceremonial grade: the same matcha, deep enough to hold on near-white. */
  --accent:    #4d7526;
  --dot:       #669c38;

  --grain-opacity: 0.34;
  /* Light needs far more than dark: a dark green at low alpha over a
     near-white ground barely moves, and the overlay grain washes out what
     little is left. */
  --spot-strength: 16%;
}

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  /* Rail links and terminal jumps glide instead of teleporting. The reveal
     safety net in script.js sweeps anything a jump would have skipped. */
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 15.5px;
  line-height: 1.65;
  letter-spacing: -0.011em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

/* Selected text sits in a wash of matcha. */
::selection {
  background: color-mix(in srgb, var(--accent) 32%, transparent);
}

main {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 8rem 1.75rem 7rem;
  position: relative;
  z-index: 2;
}

/* --- film grain + cursor light ---------------------------------- */

/* Sits above the ambient light but below `main`, so the grain textures
   the background without ever softening the text. */
.grain {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* A diffuse wash of the accent color that lags behind the cursor. */
.spot {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
  /* Defaults to a resting position near the top of the page, so there is
     ambient light before the pointer has moved at all. */
  background: radial-gradient(
    circle 340px at var(--mx, 50%) var(--my, 14%),
    color-mix(in srgb, var(--accent) var(--spot-strength), transparent),
    transparent 68%
  );
}

.spot.on { opacity: 1; }

/* --- registration marks ---------------------------------------- */

.marks { position: fixed; inset: 0; pointer-events: none; z-index: 3; }

.mark {
  position: absolute;
  width: 9px;
  height: 9px;
  opacity: 0;
  animation: markIn 0.7s var(--ease) 0.5s forwards;
}

.mark::before,
.mark::after {
  content: "";
  position: absolute;
  background: var(--faint);
}

.mark::before { left: 50%; top: 0; width: 1px; height: 100%; }
.mark::after  { top: 50%; left: 0; height: 1px; width: 100%; }

.mark.tl { top: 1.6rem; left: 1.6rem; }
.mark.tr { top: 1.6rem; right: 1.6rem; }
.mark.bl { bottom: 1.6rem; left: 1.6rem; }
.mark.br { bottom: 1.6rem; right: 1.6rem; }

@keyframes markIn { to { opacity: 0.55; } }

/* --- typography ------------------------------------------------ */

h1 {
  margin: 0 0 1.1rem;
  font-family: var(--mono);
  font-size: clamp(1.15rem, 4.4vw, 1.7rem);
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1.25;
  /* Reserved so the scramble can start from an empty string without
     collapsing the line and shifting everything below it. */
  min-height: 1.25em;
  cursor: default;
}

/* Unresolved characters mid-scramble. */
h1 .gl {
  color: var(--accent);
  opacity: 0.65;
}

/* Rail and terminal jumps land with breathing room above the heading. */
h2, #top { scroll-margin-top: 3.2rem; }

h2 {
  display: flex;
  align-items: baseline;
  gap: 0.85rem;
  margin: 0 0 1.9rem;
  font-family: var(--mono);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Hairline rule trailing each section heading. */
h2::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--line);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 1s var(--ease) 0.15s;
}

h2.in::after { transform: scaleX(1); }

.idx {
  font-family: var(--mono);
  font-size: 0.68rem;
  color: var(--accent);
  letter-spacing: 0.08em;
}

h3 {
  margin: 0;
  font-size: 0.98rem;
  font-weight: 500;
  letter-spacing: -0.015em;
  transition: color 0.2s ease;
}

p { margin: 0; }

section, footer { margin-top: var(--gap); }

/* --- hero ------------------------------------------------------ */

.tag {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 1.7rem;
  padding: 0.28rem 0.7rem 0.28rem 0.55rem;
  border: 1px solid var(--line);
  border-radius: 100px;
  background: var(--panel);
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.dot {
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--dot);
  animation: pulse 2.6s ease-out infinite;
}

@keyframes pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--dot) 55%, transparent); }
  70%  { box-shadow: 0 0 0 6px color-mix(in srgb, var(--dot) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--dot) 0%, transparent); }
}

.hero .role {
  color: var(--muted);
  font-size: 0.95rem;
  margin-bottom: 1.4rem;
}

.lede {
  color: var(--muted);
  max-width: 36rem;
  text-wrap: pretty;
}


/* --- hero icon links -------------------------------------------- */

.iconlinks {
  display: flex;
  gap: 0.55rem;
  margin-top: 2.3rem;
  /* No reserved room for the hover labels. They are absolutely positioned,
     so they cost no layout, and the section gap below already leaves far
     more clearance than a 12px label needs. Reserving space here just put
     permanent dead air under the row. */
}

.iconlinks a {
  position: relative;
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 11px;
  background: var(--panel);
  color: var(--muted);
  transform: translate3d(var(--tx, 0px), calc(var(--ty, 0px) + var(--lift, 0px)), 0);
  transition:
    color 0.2s ease,
    border-color 0.2s ease,
    transform 0.35s var(--ease);
}

.iconlinks a:hover,
.iconlinks a:focus-visible {
  color: var(--accent);
  border-color: var(--accent);
  --lift: -3px;
}

/* While the pointer is being tracked the offset updates every frame, so the
   easing transition would only ever lag behind it. */
.iconlinks a.pull { transition: color 0.2s ease, border-color 0.2s ease; }

.iconlinks svg { width: 16px; height: 16px; display: block; }

.iconlinks .stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.iconlinks .fill { fill: currentColor; }

/* Label revealed under the button on hover. */
.iconlinks a::after {
  content: attr(data-label);
  position: absolute;
  top: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(-4px);
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--faint);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease, transform 0.3s var(--ease);
}

/* The +3px cancels the parent button's -3px hover lift. Without it the
   label is dragged upward with the button and the gap between them reads
   as inconsistent from icon to icon. */
.iconlinks a:hover::after,
.iconlinks a:focus-visible::after {
  opacity: 1;
  transform: translateX(-50%) translateY(3px);
}

/* --- links ----------------------------------------------------- */

.links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.25rem;
  margin-top: 2rem;
}

.links a {
  position: relative;
  color: var(--text);
  text-decoration: none;
  font-size: 0.9rem;
  padding-bottom: 2px;
  transition: color 0.2s ease;
}

/* Underline that traces in from the left on hover. */
.links a::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.35s var(--ease);
}

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

.links a:hover::after,
.links a:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

/* --- items ----------------------------------------------------- */

.list { margin: 0; padding: 0; list-style: none; }

.item {
  position: relative;
  padding: 1.15rem 0;
  border-top: 1px solid var(--line-soft);
}

/* :first-of-type, not :first-child. In Work and Projects the items are <li>
   inside an <ol>, so the first one is also the first child, but in Education
   the item is a <div> that follows the <h2>, so :first-child never matched it
   and it kept a stray rule above the first row. */
.item:first-of-type { border-top: none; padding-top: 0; }
.item:last-of-type  { padding-bottom: 0; }

/* Diagonal corner brackets that snap in on hover. */
.item::before,
.item::after {
  content: "";
  position: absolute;
  width: 7px;
  height: 7px;
  opacity: 0;
  transition: opacity 0.3s ease, transform 0.35s var(--ease);
  pointer-events: none;
}

.item::before {
  top: 0.6rem;
  left: -0.85rem;
  border-left: 1px solid var(--accent);
  border-top: 1px solid var(--accent);
  transform: translate(5px, 5px);
}

.item::after {
  bottom: 0.6rem;
  right: -0.85rem;
  border-right: 1px solid var(--accent);
  border-bottom: 1px solid var(--accent);
  transform: translate(-5px, -5px);
}

.item:hover::before,
.item:hover::after,
.item:focus-within::before,
.item:focus-within::after {
  opacity: 1;
  transform: none;
}

.item-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.5rem;
  text-decoration: none;
  color: inherit;
}

.meta {
  margin-top: 0.2rem;
  color: var(--muted);
  font-size: 0.875rem;
}

.year {
  flex: none;
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--faint);
  letter-spacing: 0;
  white-space: nowrap;
  transition: color 0.2s ease;
}

a.item-head:hover h3,
a.item-head:focus-visible h3,
a.item-head:hover .year,
a.item-head:focus-visible .year { color: var(--accent); }

/* --- project logos ------------------------------------------------
   Each project row leads with the product's own mark, sized like an
   app icon so the two read as a set. `.with-logo` switches the head
   from baseline to center alignment, since an image has no baseline
   worth aligning the domain label to.
----------------------------------------------------------------*/

.item-head.with-logo { align-items: center; }

.head-left {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  min-width: 0;
}

.head-text { display: block; min-width: 0; }
.head-text .meta { display: block; }

.proj-logo {
  flex: none;
  width: 46px;
  height: 46px;
  border-radius: 11px;
  object-fit: cover;
  /* Both marks ship with their own opaque backdrop, so this only shows
     through in the moment before the image paints. */
  background: color-mix(in srgb, var(--line-soft) 50%, transparent);
  border: 1px solid var(--line-soft);
  transition: transform 0.35s var(--ease), border-color 0.3s ease,
    box-shadow 0.35s var(--ease);
}

a.item-head:hover .proj-logo,
a.item-head:focus-visible .proj-logo {
  transform: translateY(-2px);
  border-color: var(--accent);
  box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--accent) 55%, transparent);
}

.arrow {
  display: inline-block;
  font-size: 0.8em;
  color: var(--faint);
  opacity: 0;
  transform: translate(-3px, 2px);
  transition: transform 0.3s var(--ease), opacity 0.3s ease;
}

a.item-head:hover .arrow,
a.item-head:focus-visible .arrow {
  opacity: 1;
  transform: none;
}

.note {
  margin-top: 0.7rem;
  max-width: 34rem;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.6;
  text-wrap: pretty;
}

.tags {
  margin-top: 0.85rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  color: var(--faint);
  letter-spacing: 0;
}

/* --- stack ----------------------------------------------------- */

.stack { margin: 0; }

.stack > div {
  display: grid;
  grid-template-columns: 8.5rem 1fr;
  gap: 1.5rem;
  padding: 0.7rem 0;
  border-top: 1px solid var(--line-soft);
}

.stack > div:first-child { border-top: none; padding-top: 0; }

.stack dt {
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--faint);
  letter-spacing: 0.04em;
  padding-top: 0.15rem;
  text-transform: uppercase;
}

.stack dd {
  margin: 0;
  color: var(--muted);
  font-size: 0.9rem;
}

/* --- footer ---------------------------------------------------- */

footer { padding-top: 0; }

.colophon {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  /* The footer's own margin supplies the separation, so the colophon is the
     only thing left in it. */
  margin-top: 0;
  padding-top: 1.2rem;
  border-top: 1px solid var(--line-soft);
  font-family: var(--mono);
  font-size: 0.66rem;
  color: var(--faint);
  letter-spacing: 0;
}

/* A chawan, still steaming, drawn in the accent like everything else. */
.colo-brew {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
}

.chawan {
  flex: none;
  width: 15px;
  height: 15px;
  color: var(--accent);
  overflow: visible;
}

.chawan path {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.chawan .steam {
  opacity: 0;
  animation: steam 3.2s ease-in-out infinite;
}

.chawan .s2 { animation-delay: 1.6s; }

@keyframes steam {
  0%   { opacity: 0; transform: translateY(2.5px); }
  35%  { opacity: 0.85; }
  100% { opacity: 0; transform: translateY(-2px); }
}

/* --- status bar ------------------------------------------------ */

.statusbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  display: none;
  justify-content: space-between;
  gap: 2rem;
  padding: 0.55rem 3.2rem;
  border-top: 1px solid var(--line);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  font-family: var(--mono);
  font-size: 0.64rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
}

.statusbar .sb-left { color: var(--muted); }

/* Scroll progress, drawn along the status bar's top border. */
.sb-progress {
  position: absolute;
  top: -1px;
  left: 0;
  height: 1px;
  width: 0;
  background: var(--accent);
  transition: width 0.12s linear;
}

@media (min-width: 900px) {
  .statusbar { display: flex; }
  main { padding-bottom: 9rem; }
}

/* --- theme toggle ---------------------------------------------- */

.theme-toggle {
  position: absolute;
  top: 1.4rem;
  right: 3.2rem;
  z-index: 6;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--panel);
  color: var(--muted);
  cursor: pointer;
  transition: color 0.2s ease, border-color 0.2s ease;
}

.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }

.theme-toggle svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}

@media (min-width: 900px) {
  .theme-toggle { position: fixed; top: 2.6rem; }
}

/* The icon shows the theme in force, not the one the click would bring. */
.icon-sun  { display: none; }
.icon-moon { display: block; }

:root[data-theme="light"] .icon-sun  { display: block; }
:root[data-theme="light"] .icon-moon { display: none; }

/* --- reveal ----------------------------------------------------- */

/* Each .r wipes up behind its own mask. */
.r {
  opacity: 0;
  transform: translateY(14px);
  clip-path: inset(0 0 100% 0);
  transition:
    opacity 0.75s var(--ease),
    transform 0.85s var(--ease),
    clip-path 0.85s var(--ease);
  transition-delay: var(--d, 0ms);
}

.r.in {
  opacity: 1;
  transform: none;
  clip-path: inset(0 0 -20% 0);
}

/* Added by script.js once the wipe has finished. The clip-path is only
   needed while the element is animating; leaving it in place afterwards
   keeps clipping absolutely-positioned children that sit outside the box,
   which silently swallowed the icon hover labels. */
.r.done { clip-path: none; }

:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 4px;
}

/* --- responsive ------------------------------------------------ */

@media (max-width: 900px) {
  .mark.tl, .mark.tr { top: 1rem; }
  .mark.bl, .mark.br { bottom: 1rem; }
  .mark.tl, .mark.bl { left: 1rem; }
  .mark.tr, .mark.br { right: 1rem; }
}

@media (max-width: 620px) {
  :root { --gap: 3.5rem; }

  main { padding: 5.5rem 1.35rem 4rem; }

  .theme-toggle { right: 1.35rem; top: 1.2rem; }

  .item::before, .item::after { display: none; }

  .item-head {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.3rem;
  }

  .item-head.with-logo { align-items: flex-start; }

  .proj-logo { width: 40px; height: 40px; border-radius: 10px; }

  /* Bigger touch targets. */
  .iconlinks a { width: 44px; height: 44px; }

  .stack > div { grid-template-columns: 1fr; gap: 0.25rem; }

  .colophon { flex-direction: column; gap: 0.35rem; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .r { opacity: 1; transform: none; clip-path: none; }
  .mark { opacity: 0.55; }
  h2::after { transform: scaleX(1); }
}

/* ===============================================================
   MOTION LAYER
   Lattice backdrop, pointer reticle, word reveals, hover kinetics.
   Every block here has a matching reset in the reduced-motion
   section at the bottom of this file.
================================================================*/

/* --- first-paint sweep ------------------------------------------
   One accent hairline crosses the viewport top to bottom, once.
----------------------------------------------------------------*/

.sweep {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  z-index: 4;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--accent) 70%, transparent) 20%,
    color-mix(in srgb, var(--accent) 70%, transparent) 80%,
    transparent
  );
  transform: translateY(-2px);
  opacity: 0;
  animation: sweepDown 1.5s var(--ease) 0.15s forwards;
}

@keyframes sweepDown {
  0%   { transform: translateY(-2px); opacity: 0; }
  12%  { opacity: 0.9; }
  85%  { opacity: 0.9; }
  100% { transform: translateY(100vh); opacity: 0; }
}

/* --- pointer reticle --------------------------------------------
   Four corner brackets that trail the cursor and snap open around
   whatever it is pointing at. The native cursor stays visible, and this
   frames the target rather than replacing the pointer.
----------------------------------------------------------------*/

.reticle {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 8;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  pointer-events: none;
  opacity: 0;
  will-change: transform, width, height;
  transition:
    opacity 0.35s ease,
    width 0.32s var(--ease),
    height 0.32s var(--ease),
    margin 0.32s var(--ease);
}

.reticle.on { opacity: 0.75; }
.reticle.lock { opacity: 1; }

.reticle i {
  position: absolute;
  width: 6px;
  height: 6px;
  border: 0 solid var(--faint);
  transition: border-color 0.25s ease, width 0.3s var(--ease), height 0.3s var(--ease);
}

.reticle i:nth-child(1) { top: 0; left: 0; border-top-width: 1px; border-left-width: 1px; }
.reticle i:nth-child(2) { top: 0; right: 0; border-top-width: 1px; border-right-width: 1px; }
.reticle i:nth-child(3) { bottom: 0; left: 0; border-bottom-width: 1px; border-left-width: 1px; }
.reticle i:nth-child(4) { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; }

.reticle.lock i { border-color: var(--accent); width: 9px; height: 9px; }

/* --- word-by-word reveal ----------------------------------------
   The lede resolves out of a blur, one word at a time, once its
   parent block has finished wiping in.
----------------------------------------------------------------*/

.w {
  display: inline-block;
  opacity: 0;
  filter: blur(7px);
  transform: translateY(0.42em);
  transition:
    opacity 0.6s var(--ease),
    filter 0.6s var(--ease),
    transform 0.7s var(--ease);
  transition-delay: var(--wd, 0ms);
}

.words.lit .w {
  opacity: 1;
  filter: blur(0);
  transform: none;
}

/* --- item kinetics ----------------------------------------------
   A band of accent light passes across the row on hover, left to
   right, like a scanner head.
----------------------------------------------------------------*/

.item {
  background-repeat: no-repeat;
  background-size: 220% 100%;
  background-position: -120% 0;
  background-image: linear-gradient(
    100deg,
    transparent 38%,
    color-mix(in srgb, var(--accent) 9%, transparent) 50%,
    transparent 62%
  );
  transition: background-position 0.9s var(--ease);
}

.item:hover,
.item:focus-within { background-position: 120% 0; }

.item .note,
.item .tags { transition: color 0.3s ease; }

.item:hover .tags { color: var(--muted); }

/* --- stack rows -------------------------------------------------- */

.stack > div { position: relative; }

.stack > div::before {
  content: "";
  position: absolute;
  left: -0.85rem;
  top: 0.55rem;
  bottom: 0.55rem;
  width: 1px;
  background: var(--accent);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform 0.4s var(--ease);
}

.stack > div:hover::before { transform: scaleY(1); }

.stack dt,
.stack dd { transition: color 0.25s ease, transform 0.4s var(--ease); }

.stack > div:hover dt { color: var(--accent); }
.stack > div:hover dd { color: var(--text); transform: translateX(3px); }

/* --- section index ----------------------------------------------- */

.idx { transition: color 0.3s ease, text-shadow 0.5s ease; }

h2.in .idx { text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 45%, transparent); }

/* --- scroll velocity meter ---------------------------------------- */

.sb-right {
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
}

.sb-vel {
  display: inline-flex;
  align-items: flex-end;
  gap: 2px;
  height: 8px;
}

.sb-vel i {
  width: 2px;
  background: var(--line);
  transition: background-color 0.18s ease, height 0.18s var(--ease);
}

.sb-vel i:nth-child(1) { height: 2px; }
.sb-vel i:nth-child(2) { height: 3px; }
.sb-vel i:nth-child(3) { height: 5px; }
.sb-vel i:nth-child(4) { height: 6px; }
.sb-vel i:nth-child(5) { height: 8px; }

.sb-vel i.lit { background: var(--accent); }

/* --- theme cross-fade ---------------------------------------------
   Where the View Transition API is available the swap is a circular
   wipe out of the toggle button; script.js drives the clip-path and
   these rules just stop the default cross-fade from fighting it.
----------------------------------------------------------------*/

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

::view-transition-old(root) { z-index: 1; }
::view-transition-new(root) { z-index: 2; }

.theme-toggle svg { transition: transform 0.5s var(--ease); }
.theme-toggle:hover svg { transform: rotate(35deg); }


/* The reticle already frames a hovered row. See script.js. */
.reticled .item::before,
.reticled .item::after { display: none; }

/* --- reduced motion ------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  .sweep { display: none; }
  .reticle { display: none; }

  .w { opacity: 1; filter: none; transform: none; }

  .item { background-image: none; }
  .stack > div::before { display: none; }
  .theme-toggle:hover svg { transform: none; }
}

@media (max-width: 900px) {
  .reticle { display: none; }
}

/* ===============================================================
   DEPTH LAYER
   Terminal, section rail, highlights, sub-rows,
   craft rows, contact CTA. Same tokens, same restraint. Every
   moving piece has a reset in the reduced-motion block at the end.
================================================================*/

/* --- terminal ---------------------------------------------------
   A working shell in the hero. The bar and body are all tokens,
   no chrome cosplay, no traffic lights.
----------------------------------------------------------------*/

.term {
  margin-top: 2.6rem;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel);
  font-family: var(--mono);
  font-size: 0.78rem;
  line-height: 1.55;
  overflow: hidden;
  transition: border-color 0.3s ease;
}

.term:focus-within { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }

.term-bar {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
  padding: 0.5rem 0.95rem;
  border-bottom: 1px solid var(--line-soft);
  font-size: 0.62rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--faint);
}

.term-hint code {
  font-family: inherit;
  color: var(--muted);
  text-transform: none;
}

.term-body {
  padding: 0.8rem 0.95rem 0.75rem;
  min-height: 8.6rem;
  max-height: 15rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  cursor: text;
}

.term-out { display: flex; flex-direction: column; gap: 0.14rem; }

.tl {
  color: var(--muted);
  white-space: pre-wrap;
  word-break: break-word;
}

.tl.tc { color: var(--text); }

.tl .ps1, .term-ps1, .cta-btn .ps1 { color: var(--accent); }

.tl a { color: var(--accent); text-underline-offset: 3px; }

.term-line {
  display: flex;
  align-items: baseline;
  gap: 0.55em;
  margin-top: 0.14rem;
}

.term-in {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: none;
  outline: none;
  background: none;
  color: var(--text);
  font: inherit;
  caret-color: var(--accent);
}

/* --- section rail -----------------------------------------------
   Fixed left, desktop only. The tick stretches and the label slides
   in for the section being read; script.js sets .on.
----------------------------------------------------------------*/

.rail {
  position: fixed;
  left: 2.4rem;
  top: 50%;
  transform: translateY(-50%);
  z-index: 5;
  display: none;
  flex-direction: column;
  gap: 0.6rem;
}

.rail a {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--faint);
  text-decoration: none;
  transition: color 0.2s ease;
}

.rail a::before {
  content: "";
  width: 13px;
  height: 1px;
  background: var(--line);
  transition: background-color 0.25s ease, width 0.35s var(--ease);
}

.rail a i { font-style: normal; }

.rail a b {
  font-weight: 400;
  opacity: 0;
  transform: translateX(-5px);
  transition: opacity 0.25s ease, transform 0.35s var(--ease);
}

.rail a:hover { color: var(--muted); }
.rail a.on { color: var(--accent); }
.rail a.on::before { background: var(--accent); width: 22px; }

.rail a:hover b, .rail a.on b, .rail a:focus-visible b {
  opacity: 1;
  transform: none;
}

/* Only where there is real margin beside the column. */
@media (min-width: 1150px) { .rail { display: flex; } }

/* --- item highlights --------------------------------------------
   Story beats under a role or project. The marker is the same
   accent hairline the rest of the page speaks in.
----------------------------------------------------------------*/

.hl {
  margin: 0.85rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-width: 36rem;
}

.hl li {
  position: relative;
  padding-left: 1.15rem;
  color: var(--muted);
  font-size: 0.875rem;
  line-height: 1.6;
  text-wrap: pretty;
}

.hl li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.68em;
  width: 9px;
  height: 1px;
  background: var(--accent);
  opacity: 0.75;
}

/* --- sub-rows ---------------------------------------------------
   Named deliverables inside one role, held by a single rule.
----------------------------------------------------------------*/

.subs {
  margin: 0.9rem 0 0;
  padding: 0 0 0 1rem;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  border-left: 1px solid var(--line-soft);
}

.subs li {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  font-size: 0.85rem;
}

.sub-n {
  flex: none;
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--text);
  white-space: nowrap;
}

.sub-d { color: var(--faint); }

/* --- craft rows ------------------------------------------------- */

.craft-row {
  display: grid;
  grid-template-columns: 2.4rem 1fr;
  gap: 1rem;
}

.craft-idx {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--accent);
  padding-top: 0.2rem;
}

/* --- contact CTA ------------------------------------------------ */

.cta-line {
  font-size: clamp(1.45rem, 4.6vw, 2.15rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.22;
  text-wrap: balance;
}

.cta-line em { font-style: normal; color: var(--accent); }

.cta-sub {
  margin-top: 0.9rem;
  max-width: 34rem;
  color: var(--muted);
  text-wrap: pretty;
}

.cta-row {
  margin-top: 1.9rem;
  display: flex;
  align-items: center;
  gap: 1.2rem;
  flex-wrap: wrap;
}

.cta-btn {
  display: inline-flex;
  align-items: baseline;
  gap: 0.55em;
  padding: 0.72rem 1.1rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--text);
  text-decoration: none;
  transition: color 0.2s ease, border-color 0.2s ease, transform 0.35s var(--ease);
}

.cta-btn:hover, .cta-btn:focus-visible {
  color: var(--accent);
  border-color: var(--accent);
  transform: translateY(-2px);
}

.cta-alt {
  font-family: var(--mono);
  font-size: 0.66rem;
  color: var(--faint);
}

.cta-alt code {
  font-family: inherit;
  color: var(--muted);
  border: 1px solid var(--line-soft);
  background: var(--panel);
  border-radius: 5px;
  padding: 0.1em 0.4em;
}

/* --- matrix easter egg ------------------------------------------ */

.matrix {
  position: fixed;
  inset: 0;
  z-index: 7;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.6s ease;
}

.matrix.on { opacity: 0.5; }

/* --- entry veil --------------------------------------------------
   A chasen whisking a bowl while the page settles. script.js lifts
   the veil, and only then does the entrance choreography (sweep,
   reveals, scramble, boot) take its cue. An inline failsafe after
   the veil markup lifts it even if script.js never runs, and no-JS
   visitors never see it at all (noscript hides it).
----------------------------------------------------------------*/

.loader {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: grid;
  place-items: center;
  background: var(--bg);
  clip-path: inset(0);
  transition: clip-path 0.7s var(--ease);
}

/* The page's own grain, so the veil is textured like what follows it. */
.loader::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* The veil exits downward, the same direction the first-paint sweep
   will travel a beat later. */
.loader.lift { clip-path: inset(100% 0 0 0); }

.loader-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.35rem;
  transition: opacity 0.4s ease, transform 0.5s var(--ease);
}

/* Crop brackets around the bowl. The same registration language the
   page corners use, so the veil is framed rather than captioned. */
.loader-frame {
  position: relative;
  display: grid;
  place-items: center;
  /* Same width as the bar below it, so the two edges line up. */
  width: 116px;
  height: 116px;
}

.loader-frame .lf {
  position: absolute;
  width: 13px;
  height: 13px;
  border: 0 solid var(--faint);
  opacity: 0;
  animation: markIn 0.8s var(--ease) 0.2s forwards;
}

.loader-frame .tl { top: 0; left: 0; border-top-width: 1px; border-left-width: 1px; }
.loader-frame .tr { top: 0; right: 0; border-top-width: 1px; border-right-width: 1px; }
.loader-frame .bl { bottom: 0; left: 0; border-bottom-width: 1px; border-left-width: 1px; }
.loader-frame .br { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; }

/* A hairline that fills while the page settles. Front-loaded easing,
   so it is nearly full by the time the veil is allowed to lift and
   the cut never reads as an interruption. */
.loader-bar {
  position: relative;
  width: 116px;
  height: 1px;
  overflow: hidden;
  background: var(--line);
}

.loader-bar i {
  position: absolute;
  inset: 0;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  animation: veilFill 2.6s cubic-bezier(0.16, 0.9, 0.3, 1) forwards;
}

@keyframes veilFill { to { transform: scaleX(1); } }

.loader.lift .loader-inner {
  opacity: 0;
  transform: translateY(14px);
}

/* Vessel and tool in line weight; the matcha itself is the accent. */
.loader-bowl {
  width: 84px;
  height: 84px;
  color: var(--muted);
  overflow: visible;
}

.loader-bowl path {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.loader-whisk path { stroke-width: 1.3; }

/* Whisked in a "w", not circles, per the house recipe. Pivot at the
   top of the handle, where the hand would be. */
.loader-whisk {
  transform-box: fill-box;
  transform-origin: 50% 0%;
  animation: whiskW 1.15s ease-in-out infinite;
}

@keyframes whiskW {
  0%   { transform: translate(0, 0) rotate(0deg); }
  18%  { transform: translate(-5px, 0.8px) rotate(-7deg); }
  38%  { transform: translate(2.6px, 0.3px) rotate(4deg); }
  56%  { transform: translate(-2.6px, 0.3px) rotate(-4deg); }
  78%  { transform: translate(5px, 0.8px) rotate(7deg); }
  100% { transform: translate(0, 0) rotate(0deg); }
}

.loader-tea {
  stroke: var(--accent);
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: slosh 1.15s ease-in-out infinite;
}

@keyframes slosh {
  0%, 100% { transform: translateX(0); }
  28%      { transform: translateX(2px); }
  72%      { transform: translateX(-2px); }
}

/* Froth forming. */
.loader-bub {
  fill: var(--accent);
  opacity: 0;
}

.loader-bub.b1 { animation: bub 1.5s ease-in-out infinite; }
.loader-bub.b2 { animation: bub 1.9s ease-in-out 0.5s infinite; }

@keyframes bub {
  0%, 100% { opacity: 0; }
  45%      { opacity: 0.9; }
}

/* While the veil is up, the first-paint animations hold their cue. */
.brewing .sweep,
.brewing .mark { animation-play-state: paused; }

/* --- responsive ------------------------------------------------- */

@media (max-width: 620px) {
  .term { font-size: 0.74rem; }
  .term-hint-key { display: none; }
  .subs li { flex-direction: column; gap: 0.1rem; }
  .cta-row { align-items: flex-start; flex-direction: column; }
}

/* --- reduced motion --------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .rail a b { opacity: 1; transform: none; }
  .matrix { display: none; }
  /* The steam holds still instead of rising. The base state is opacity 0,
     so without this the bowl would read as empty. */
  .chawan .steam { animation: none; opacity: 0.5; transform: none; }
  /* No veil at all: the page renders as plain static type, immediately.
     script.js also lifts straight away, so nothing stays gated. */
  .loader { display: none; }
}

/* --- contact page ------------------------------------------------ */

/* Sits above the tag pill, reading as a breadcrumb rather than a button.
   The chevron slides back on hover, which is the whole affordance. */
.backlink {
  /* flex + fit-content rather than inline-flex: the tag pill that follows
     is itself inline-flex and would otherwise sit on the same line. */
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 0.45rem;
  margin-bottom: 1.6rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  color: var(--faint);
  text-decoration: none;
  transition: color 0.2s ease;
}

.backlink:hover, .backlink:focus-visible { color: var(--accent); }

.backlink svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.3s var(--ease);
}

.backlink:hover svg, .backlink:focus-visible svg { transform: translateX(-3px); }

.inline-link {
  color: var(--text);
  text-decoration: none;
  border-bottom: 1px solid var(--line);
  transition: color 0.2s ease, border-color 0.2s ease;
}

.inline-link:hover, .inline-link:focus-visible {
  color: var(--accent);
  border-color: var(--accent);
}

.cform {
  margin-top: 2rem;
  display: flex;
  flex-direction: column;
  gap: 1.15rem;
  max-width: 40rem;
}

/* Honeypot. Off-screen rather than display:none, which some bots skip. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.frow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.15rem;
}

.field { display: flex; flex-direction: column; gap: 0.45rem; }

.field label {
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

.field label .opt {
  margin-left: 0.35rem;
  color: var(--faint);
  text-transform: none;
  letter-spacing: 0.04em;
}

.field input,
.field textarea {
  width: 100%;
  padding: 0.72rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  color: var(--text);
  font-family: var(--sans);
  font-size: 0.92rem;
  line-height: 1.55;
  transition: border-color 0.2s ease, box-shadow 0.25s ease,
    background-color 0.2s ease;
}

.field textarea { resize: vertical; min-height: 8rem; }

.field input::placeholder,
.field textarea::placeholder { color: var(--faint); }

.field input:hover,
.field textarea:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); }

/* The accent ring replaces the default outline, which the border already
   traces at the same radius. */
.field input:focus-visible,
.field textarea:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}

/* Only after a failed submit, never while the field is still being typed
   into for the first time. script.js adds the class. */
.field.bad input,
.field.bad textarea { border-color: #c9605c; }

.ferr {
  font-family: var(--mono);
  font-size: 0.66rem;
  color: #c9605c;
}

/* Out of the flow entirely until it has something to say. Reserving the
   line put an extra step under every validated field, so company — which
   has no error to show — sat closer to what follows than its neighbours. */
.ferr:empty { display: none; }

.fsend {
  margin-top: 0.35rem;
  display: flex;
  align-items: center;
  gap: 1.1rem;
  flex-wrap: wrap;
}

.cform .cta-btn {
  cursor: pointer;
  font-family: var(--mono);
}

.cform .cta-btn[disabled] {
  opacity: 0.55;
  cursor: progress;
  transform: none;
}

.fstatus {
  font-family: var(--mono);
  font-size: 0.68rem;
  color: var(--muted);
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.3s ease, transform 0.3s var(--ease);
}

.fstatus.show { opacity: 1; transform: none; }
.fstatus.ok { color: var(--accent); }
.fstatus.err { color: #c9605c; }

/* The whole form is replaced by this once a message is through, so the
   page does not invite a second identical send. */
.fdone {
  margin-top: 2rem;
  padding: 1.6rem 1.5rem;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel);
  max-width: 40rem;
}

.fdone h3 { color: var(--accent); }

.fdone p {
  margin-top: 0.55rem;
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.6;
}

/* The contact hero is three short lines, not a full opening screen. The
   site-wide rhythm — 8rem of lead-in, then 5rem between sections — was tuned
   for the long homepage and leaves a visible void under a hero this short,
   so this page runs tighter. */
.contact-page main { padding-top: 5.5rem; }
.contact-page .hero .role { margin-bottom: 0; }
.contact-page .hero + section { margin-top: 3.25rem; }

@media (max-width: 620px) {
  .frow { grid-template-columns: 1fr; }
  .fsend { align-items: flex-start; flex-direction: column; gap: 0.7rem; }
  .contact-page main { padding-top: 4.5rem; }
}
