/* ============================================================================
   FORMULAIC
   Shared by index.html and work.html. One sheet, no framework, no build step.

   The rules this page is built on:

   1. No accent colour. Nothing on this site is green, blue, or neon. Emphasis
      is made with size, weight and space — which is the only kind of emphasis
      that still works when everything else on the internet is glowing.
   2. Warm neutrals, not blue-blacks. The off-white is #EDEBE6, not #FFFFFF.
      That single decision is most of the difference between "industrial brand"
      and "SaaS landing page".
   3. Type does the work. Two sizes of idea per screen: something very large and
      something very small. Nothing in between competing.
   4. Space is the layout. Sections are enormous. If a block feels too empty,
      it is probably correct.
   5. Almost no borders and no cards. A hairline is allowed to separate two
      ideas. A box around an idea is not.
   ========================================================================== */

:root,
:root[data-theme="dark"],
:root[data-theme="light"] {
  /* The ground carries a trace of blue — long-exposure sky is never neutral
     black. Warm off-white type on a faintly cool ground is the same contrast
     astrophotography gets for free: warm stars, cold space. */
  --ink:     #08090C;
  --ink-2:   #101218;
  --paper:   #EDEBE6;
  --muted:   #8B8983;
  --muted-2: #5B5A56;
  --line:    rgba(237, 235, 230, .15);
  --line-2:  rgba(237, 235, 230, .07);

  /* Deep-sky palette, sampled off long-exposure nebula photography and held as
     raw RGB triplets so every layer can tune its own alpha. These four are the
     colours a camera actually records out there: scattered starlight on dust
     (violet), ionised oxygen (teal), hydrogen-alpha (rose), and warm dust lanes
     (amber). They are used ONLY as ambient background light — never on type,
     borders, buttons or any other interface element. The palette of the site
     is still monochrome; the room it sits in has colour. */
  --neb-violet: 96, 68, 208;
  --neb-teal:   28, 152, 172;
  --neb-rose:   198, 64, 132;
  --neb-amber:  190, 116, 56;

  --display: "Inter Tight", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --sans:    "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;

  --pad:   clamp(22px, 5vw, 72px);
  --nav-h: 72px;
  color-scheme: dark;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; scroll-padding-top: calc(var(--nav-h) + 24px); }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--sans);
  font-size: 17px;
  line-height: 1.55;
  letter-spacing: -.006em;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img { display: block; max-width: 100%; height: auto; }
a   { color: inherit; }
:focus-visible { outline: 1px solid var(--paper); outline-offset: 4px; }
::selection { background: var(--paper); color: var(--ink); }

.wrap {
  width: 100%;
  max-width: 1360px;
  margin-inline: auto;
  padding-inline: var(--pad);
}

/* ---------- type ---------- */

/* Headlines are set very large, very tight, and never in more than two weights.
   The negative tracking is what keeps a 100px line from looking like a default
   browser <h1>; without it the whole thing falls apart. */
h1, h2, h3 {
  margin: 0;
  font-family: var(--display);
  font-weight: 600;
  letter-spacing: -.038em;
  line-height: 1.02;
  text-wrap: balance;
}

/* The only small-type treatment on the site. Uppercase sans at 11px with modest
   tracking — deliberately not wide-tracked monospace, which reads as a terminal
   and drags everything toward developer-tool marketing. */
/* The doubled class is not a typo. It lifts specificity to (0,2,0) so that a
   label sitting inside a block that styles its own paragraphs — .case p,
   .panel p — is never captured by that rule. Without it, every label
   nested in a text block silently reverts to body copy. */
.label,
.label.label {
  margin: 0;
  font-family: var(--sans);
  font-size: .6875rem;
  font-weight: 500;
  letter-spacing: .11em;
  text-transform: uppercase;
  color: var(--muted-2);
}

.lede {
  margin: 0;
  font-size: clamp(1.0625rem, 1.5vw, 1.3125rem);
  line-height: 1.5;
  letter-spacing: -.012em;
  color: var(--muted);
}
.lede b { color: var(--paper); font-weight: 400; }

/* Sections are far larger than they need to be. That is the point — the amount
   of air between two ideas is what tells you they are both worth reading. */
section { padding-block: clamp(56px, 7.5vw, 116px); position: relative; }

 /* The measure has to live on the heading itself, not on .head — `ch` resolves
    against the element's own font-size, so a limit set on the wrapper would be
    computed in 17px body text and crush a 60px headline into three words. */
.head h2 { font-size: clamp(2rem, 5vw, 3.9rem); max-width: 15ch; }
.head .label { margin-bottom: clamp(20px, 2.5vw, 32px); }
.head .lede { margin-top: 26px; max-width: 46ch; }

/* ---------- links & buttons ---------- */
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; }

.btn {
  display: inline-flex;
  align-items: center;
  padding: 15px 30px;
  font-size: .9375rem;
  font-weight: 500;
  letter-spacing: -.008em;
  text-decoration: none;
  white-space: nowrap;
  border: 1px solid var(--paper);
  background: var(--paper);
  color: var(--ink);
  border-radius: 999px;
  cursor: pointer;
  transition: background .2s, color .2s, border-color .2s;
}
.btn:hover { background: transparent; color: var(--paper); }

.btn-line { background: transparent; color: var(--paper); border-color: var(--line); }
.btn-line:hover { background: transparent; border-color: var(--paper); }

/* An arrow link — the quiet CTA. The arrow moves, the text does not. */
.alink {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: .9375rem;
  font-weight: 500;
  text-decoration: none;
  color: var(--paper);
  padding-bottom: 4px;
  border-bottom: 1px solid var(--line);
  transition: border-color .2s;
}
.alink:hover { border-bottom-color: var(--paper); }
.alink span { transition: transform .25s cubic-bezier(.2,.7,.25,1); }
.alink:hover span { transform: translateX(5px); }

/* ---------- top bar ---------- */
.top {
  position: sticky;
  top: 0;
  z-index: 40;
  border-bottom: 1px solid transparent;
  transition: background .3s, border-color .3s, backdrop-filter .3s;
}
.top.stuck {
  background: rgba(10, 10, 11, .72);
  backdrop-filter: blur(18px) saturate(1.4);
  border-bottom-color: var(--line-2);
}
.top-in {
  height: var(--nav-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
}
/* The wordmark is the logo. Tracked out just enough to read as a mark rather
   than as a word, and left otherwise alone — no dot, no glyph, no gradient. */
.mark {
  font-family: var(--display);
  font-weight: 600;
  font-size: .9375rem;
  letter-spacing: .26em;
  text-decoration: none;
  color: var(--paper);
}
.top nav { display: flex; align-items: center; gap: clamp(20px, 3vw, 40px); }
.top nav a {
  font-size: .875rem;
  font-weight: 400;
  letter-spacing: -.006em;
  text-decoration: none;
  color: var(--muted);
  transition: color .2s;
}
.top nav a:hover,
.top nav a[aria-current="page"] { color: var(--paper); }
.top nav .btn { padding: 10px 20px; font-size: .8125rem; color: var(--ink); }
.top nav .btn:hover { color: var(--paper); }
@media (max-width: 620px) { .top nav .hide-sm { display: none; } }

/* ---------- hero ---------- */
/* Nothing here but words. No panel, no device mockup, no animated widget — the
   confidence of an empty screen is the entire effect, and any object dropped
   into it immediately makes the page look like it is selling a tool. */
.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-height: min(76svh, 760px);
  display: flex;
  align-items: flex-end;
  /* Pulled up under the sticky header and padded back down by the same amount.
     The header is transparent at rest, so without this the hero's overflow
     clips its colour field at the top of the section and leaves a flat black
     band across the nav — a hard horizontal seam right at the top of the page.
     The header still paints above the hero: it carries z-index 40, and .hero's
     isolation keeps its own contents at level 0. */
  margin-top: calc(var(--nav-h) * -1);
  padding-block: calc(clamp(56px, 8vw, 104px) + var(--nav-h)) clamp(40px, 5vw, 68px);
}
.hero::before, .hero::after { content: ""; }
.hero-in { width: 100%; }
.hero h1 {
  font-size: clamp(2.9rem, 8.4vw, 7.4rem);
  line-height: .96;
  /* Loosened from -.045em. Inter Tight will take very negative tracking at this
     size, but the gradient below needs air between the strokes — packed tight,
     adjacent hues bleed into each other and the whole line reads as one muddy
     wash instead of as separate bands of colour. */
  letter-spacing: -.028em;
  max-width: 15ch;
  /* Descender room. background-clip: text paints only inside the element box,
     and line-height .96 makes that box shorter than the glyphs — so the tail of
     the "g" on the last line falls outside it and renders as nothing. Solid
     text overflows the box happily, which is why this only appeared once the
     headline became a gradient. Set in em so it tracks the font-size clamp;
     .hero .lede carries a smaller top margin to absorb it. */
  padding-bottom: .18em;
}
.hero .lede {
  margin-top: clamp(18px, 2.2vw, 28px);
  /* No measure here, unlike every other .lede — this one runs the full width
     of the column so it sets on a single line on a desktop, which is what pulls
     the capability list and the button up the page. It still wraps on its own
     once the viewport is too narrow to hold it. */
  max-width: none;
  text-wrap: pretty;
  /* Full white, also unlike every other .lede on the site. The headline is no
     longer solid paper, so the lede is what anchors the top of the page. */
  color: var(--paper);
}
.hero .actions { margin-top: clamp(32px, 4vw, 52px); }

/* ---------- the headline gradient ----------
   The one place on this site where colour touches type. It is a deliberate
   exception to rule 1 at the top of this file, asked for directly — not an
   oversight, and not licence to colour anything else.

   Thin-film iridescence, the oil-on-water effect. The hue order is the real
   interference sequence — violet, blue, cyan, gold, rose, back to violet —
   rather than a spectrum, which is most of why it reads as a slick and not as
   a rainbow. Every stop is a pale tint rather than a saturated neon: pastels
   are what keep this on the wallpaper side of the line, and they also clear
   9:1 against the ground, so the headline is still the highest-contrast thing
   on the page.

   The gradient is drawn larger than the box and drifts across it, because
   iridescence that never changes angle is just a gradient. 34s is slow enough
   that you notice it only if you stop and look.

   Wrapped in @supports because the fallback for an unsupported
   background-clip is an invisible headline. */
@supports (-webkit-background-clip: text) or (background-clip: text) {
  .hero h1 {
    background-image: linear-gradient(104deg,
      #B9A3FF   0%,
      #8CD8FF  21%,
      #79EFD0  40%,
      #FFE3A8  58%,
      #FFA6CE  78%,
      #B9A3FF 100%);
    background-size: 190% 190%;
    -webkit-background-clip: text;
            background-clip: text;
    color: transparent;
    -webkit-text-fill-color: transparent;
    animation: slick 22s ease-in-out infinite;
  }
  /* Without this the headline highlights to an empty box — a transparent fill
     stays transparent through ::selection. */
  .hero h1::selection { -webkit-text-fill-color: var(--ink); }
}

@keyframes slick {
  0%, 100% { background-position:   0% 50%; }
  50%      { background-position: 100% 50%; }
}

/* The capability list under the lede. Same reasoning as .stack further down:
   a list of things we do is plain text with a mark between the items, never a
   row of badges. It runs as one horizontal band and wraps when it runs out of
   room; `white-space: nowrap` on the item is what makes that work, since it
   forces every break to land between two capabilities rather than inside one.
   The arrow is a ::before rather than markup so the items stay readable text,
   and it is U+2192 rather than a longer arrow glyph because that is the one
   Inter is guaranteed to have — a longer arrow falls back to another font and
   arrives at a different weight. */
.hero-list {
  margin: clamp(36px, 4.5vw, 60px) 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: clamp(11px, 1.4vw, 16px) clamp(20px, 2.1vw, 30px);
}
.hero-list li {
  display: flex;
  align-items: baseline;
  gap: 12px;
  white-space: nowrap;
  font-size: clamp(.9375rem, 1.05vw, 1.0625rem);
  line-height: 1.35;
  letter-spacing: -.012em;
  color: var(--paper);
}
.hero-list li::before {
  content: "→";
  flex: none;
  font-weight: 400;
  color: var(--muted-2);
}

/* On a phone the hero content is already nearly a screen tall, so min-height
   never did anything except add slack — and align-items: flex-end piled every
   pixel of that slack above the headline. Letting the section be as tall as its
   own content removes it. The top padding still has to carry var(--nav-h),
   because the section is pulled up under the transparent header and that is
   what keeps the colour field from being clipped into a black band across the
   nav; only the part above and beyond the header comes off. */
@media (max-width: 760px) {
  .hero {
    min-height: 0;
    padding-block: calc(32px + var(--nav-h)) clamp(40px, 8vw, 64px);
  }
}

/* ---------- work ---------- */
.work { border-top: 1px solid var(--line-2); }

/* ---------- work tabs ----------
   A tab row, not a row of buttons. The site allows a hairline to separate two
   ideas and nothing else, so the whole list carries one rule and the selected
   tab overprints its own segment of it — which is why .tab has a transparent
   bottom border and a -1px margin: the two rules sit on the same pixel, so
   selecting a tab changes the colour of a line that was already there rather
   than adding furniture to the page. No pills, no boxes, no filled states. */
/* The heading has to be pushed off the tab row — .head sets no bottom margin
   of its own, and a rule sitting directly under a 60px headline reads as an
   underline on the headline rather than as the top of a control. */
.work .head { margin-bottom: clamp(22px, 2.8vw, 38px); }

.tablist {
  display: flex;
  flex-wrap: wrap;
  gap: 0 clamp(22px, 3vw, 44px);
  border-bottom: 1px solid var(--line-2);
  margin-bottom: clamp(26px, 3.2vw, 44px);
}
.tab {
  appearance: none;
  background: none;
  border: 0;
  border-bottom: 1px solid transparent;
  margin-bottom: -1px;
  padding: 0 0 14px;
  font: inherit;
  font-size: clamp(.9375rem, 1.05vw, 1.0625rem);
  font-weight: 500;
  letter-spacing: -.012em;
  white-space: nowrap;
  color: var(--muted-2);
  cursor: pointer;
  transition: color .2s, border-color .2s;
}
.tab:hover { color: var(--muted); }
.tab[aria-selected="true"] { color: var(--paper); border-bottom-color: var(--paper); }

/* Belt and braces — the UA sheet already hides [hidden], but .panel picking up
   a display of its own later would silently override it and show every project
   at once. */
.panel[hidden] { display: none; }
.panel:focus-visible { outline-offset: 12px; }

/* Words left, screenshot right — the inverse of .case further down, which puts
   the image on top. A tab row is already a horizontal gesture, so the panel
   reads better split across the column than stacked. */
.panel-in {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  gap: clamp(28px, 4vw, 56px);
  align-items: start;
}
.panel h3 {
  font-size: clamp(1.5rem, 3vw, 2.35rem);
  max-width: 18ch;
  margin: 16px 0 18px;
}
.panel p { margin: 0; max-width: 48ch; color: var(--muted); font-size: 1.0625rem; }
/* .shot frames rather than crops — a 16/9 box with object-fit: contain — because
   on the work page it has to hold portrait phone captures and landscape ones
   side by side. Every project here is a wide full-page grab at roughly 2:1, so
   that frame banded each one with --ink-2 above and below and ringed it with a
   hairline. Matching the frame to the captures and filling it instead of
   fitting inside it removes both. The few percent trimmed off the sides costs
   nothing, and clicking still opens the whole uncropped image in the lightbox.
   Scoped to .panel: work.html's phone captures still need the framed 16/9 box,
   and cover would destroy them. */
.panel .shot {
  margin: 0;
  aspect-ratio: 2 / 1;
  border: 0;
  background: none;
}
/* Much less knockback than .case uses. That filter was set for bright white app
   UIs that punch a hole in a dark page; three of the four projects here are
   dark interfaces or dark photography, and at .72 they turn to mud. Enough is
   kept that the frame still lifts on hover, which is what signals it opens. */
.panel .shot img { object-fit: cover; filter: brightness(.9) saturate(.94); }

@media (max-width: 860px) {
  .panel-in { grid-template-columns: minmax(0, 1fr); gap: clamp(22px, 4vw, 34px); }
  /* Image first on a phone, matching .case. The DOM keeps the words first so
     the reading order and the tab-to-content order stay correct. */
  .panel .shot { order: -1; }
}


.cases { display: flex; flex-direction: column; gap: clamp(72px, 10vw, 150px); }

/* Image first, at full column width; the words sit under it in small type like
   a plate caption. Inverting the usual card layout (picture beside a block of
   marketing copy) is most of why this reads as a portfolio rather than a
   feature grid. */
.case { display: block; }

.shot {
  margin: 0 0 clamp(26px, 3.5vw, 44px);
  position: relative;
  aspect-ratio: 16 / 9;
  background: var(--ink-2);
  border: 1px solid var(--line-2);
  overflow: hidden;
  cursor: zoom-in;
}
.shot img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  /* Client screenshots are bright white app UIs. Knocked back so they sit in
     the page instead of punching a hole in it; they come up to full strength
     on hover, which also tells you the frame is clickable. */
  filter: brightness(.72) saturate(.82);
  transition: filter .45s ease, transform .7s cubic-bezier(.2,.7,.25,1);
}
.shot:hover img { filter: none; transform: scale(1.015); }

/* Two phone captures side by side inside one frame. The explicit 100% row is
   load-bearing: without it the grid row sizes to the images' intrinsic height,
   the tall portrait captures win, and the frame's aspect-ratio is ignored. */
.shot.pair { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 100%; gap: 1px; background: var(--line-2); }
.shot.pair img { min-width: 0; min-height: 0; background: var(--ink-2); }

/* The caption block: index and category on the left, everything else on the
   right, split by the page's only real column rule. */
.case-in {
  display: grid;
  grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
  gap: clamp(20px, 4vw, 56px);
  align-items: start;
}
.case-side { display: flex; flex-direction: column; gap: 8px; }
.case h3 {
  font-size: clamp(1.5rem, 3vw, 2.35rem);
  max-width: 18ch;
  margin-bottom: 18px;
}
.case p { margin: 0; max-width: 56ch; color: var(--muted); font-size: 1.0625rem; }

/* Stack, not chips. A pill row of technology badges is the single most
   recognisable piece of SaaS-template furniture there is. */
.stack {
  margin: 22px 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 0;
  font-size: .8125rem;
  color: var(--muted-2);
  letter-spacing: -.004em;
}
.stack li:not(:last-child)::after { content: "·"; margin: 0 10px; opacity: .55; }

@media (max-width: 760px) {
  .case-in { grid-template-columns: minmax(0, 1fr); gap: 14px; }
  .case-side { flex-direction: row; gap: 18px; }
}

/* ---------- lightbox ---------- */
.lb {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: grid;
  place-items: center;
  padding: clamp(22px, 5vw, 72px);
  background: rgba(6, 6, 7, .95);
  backdrop-filter: blur(8px);
  opacity: 0;
  visibility: hidden;
  transition: opacity .25s, visibility .25s;
}
.lb.is-on { opacity: 1; visibility: visible; }
.lb img { max-width: 100%; max-height: 86vh; }
.lb-close {
  position: absolute;
  top: 22px; right: 24px;
  appearance: none;
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--paper);
  font-family: var(--sans);
  font-size: .8125rem;
  padding: 9px 18px;
  cursor: pointer;
}
.lb-close:hover { border-color: var(--paper); }

/* ---------- contact ---------- */
.contact {
  border-top: 1px solid var(--line-2);
  isolation: isolate;
  overflow: hidden;
}
.contact::before, .contact::after { content: ""; }
/* One column: the section is the form and nothing else now, so there is no
   second column for it to sit beside. Held to a measure rather than run to the
   full 1360 — a name field a thousand pixels wide reads as a database screen,
   not as stationery. */
/* The measure goes on the form, not on .contact-in — .contact-in *is* the
   .wrap, and .wrap carries margin-inline: auto, so capping its width centres
   the form in the viewport instead of aligning it to the same left edge as the
   headline and the work section. Everything on this site starts at that edge. */
.contact .lead { max-width: 680px; }
.contact .head { margin-bottom: clamp(26px, 3.2vw, 44px); }


/* Underlined fields, no boxes. A form made of hairlines reads as stationery;
   a form made of filled rounded rectangles reads as a signup flow. */
.lead { display: grid; grid-template-columns: 1fr 1fr; gap: 34px 28px; }
.field { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.field.wide { grid-column: 1 / -1; }
/* Contrast, not decoration. --muted-2 is the site's quietest grey — fine on an
   eyebrow you are not required to read, but on a form field label it measures
   2.7:1 against the ground, under the 4.5:1 floor, in the one place on the page
   where failing to read something costs a lead. Full --paper here. */
.lead label { font-size: .8125rem; color: var(--paper); letter-spacing: -.004em; }
.lead input,
.lead textarea {
  width: 100%;
  padding: 8px 0 12px;
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0;
  color: var(--paper);
  font-family: var(--sans);
  font-size: 1.0625rem;
  letter-spacing: -.008em;
  transition: border-color .2s;
}
.lead textarea { min-height: 108px; resize: vertical; line-height: 1.5; }
/* --muted, 5.9:1, clears AA while still sitting visibly below the value you
   type in. A placeholder at full paper reads as a field already filled. */
.lead input::placeholder,
.lead textarea::placeholder { color: var(--muted); }
.lead input:hover, .lead textarea:hover { border-bottom-color: var(--muted); }
.lead input:focus, .lead textarea:focus { outline: none; border-bottom-color: var(--paper); }
/* The honeypot. display:none rather than an off-screen shift on purpose — the
   field must not be reachable or focusable by a person, and there is nothing
   here a real user needs. */
.lead .hp { display: none; }

.lead .send { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 16px 26px; }

@media (max-width: 560px) {
  .lead { grid-template-columns: minmax(0, 1fr); }
}

/* ---------- closing cta (work page) ---------- */
.cta {
  border-top: 1px solid var(--line-2);
  isolation: isolate;
  overflow: hidden;
}
.cta::before { content: ""; }
.cta h2 { font-size: clamp(2.1rem, 5.4vw, 4.2rem); max-width: 13ch; }
.cta .lede { margin-top: 26px; max-width: 40ch; }
.cta .actions { margin-top: clamp(32px, 4vw, 48px); }

/* ---------- footer ---------- */
footer { border-top: 1px solid var(--line-2); }
.foot-in {
  padding-block: 34px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 36px;
  font-size: .8125rem;
  color: var(--muted-2);
}
.foot-in nav { display: flex; flex-wrap: wrap; gap: 26px; }
.foot-in a { text-decoration: none; transition: color .2s; }
.foot-in a:hover { color: var(--paper); }

/* ---------- work page head ---------- */
.page-head {
  isolation: isolate;
  overflow: hidden;
  /* Same reason as .hero: without pulling up under the transparent sticky
     header, the colour field is clipped at the section top and leaves a flat
     band across the nav. */
  margin-top: calc(var(--nav-h) * -1);
  padding-block: calc(clamp(70px, 11vw, 150px) + var(--nav-h)) clamp(44px, 6vw, 76px);
}
.page-head::before, .page-head::after { content: ""; }
.page-head h1 { font-size: clamp(2.6rem, 7vw, 5.6rem); line-height: .98; max-width: 14ch; }
.page-head .label { margin-bottom: clamp(20px, 2.5vw, 32px); }
.page-head .lede { margin-top: 28px; max-width: 46ch; }

/* Index of projects. Rows of plain type on hairlines — the whole row is the
   target, and the only hover state is the type coming up to full white. */
.wlist { margin-top: clamp(44px, 6vw, 78px); border-top: 1px solid var(--line-2); }
.wlist a {
  display: grid;
  grid-template-columns: 3ch minmax(0, 1fr) auto auto;
  align-items: baseline;
  gap: 20px;
  padding: 20px 0;
  border-bottom: 1px solid var(--line-2);
  text-decoration: none;
  color: var(--muted);
  transition: color .2s;
}
.wlist a:hover { color: var(--paper); }
.wlist .w-n { font-size: .8125rem; color: var(--muted-2); }
.wlist .w-name {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1rem, 1.7vw, 1.25rem);
  letter-spacing: -.024em;
}
.wlist .w-kind { font-size: .8125rem; color: var(--muted-2); }
.wlist .w-go { font-size: .875rem; transition: transform .25s cubic-bezier(.2,.7,.25,1); }
.wlist a:hover .w-go { transform: translateX(5px); }
@media (max-width: 620px) {
  .wlist a { grid-template-columns: 3ch minmax(0, 1fr) auto; }
  .wlist .w-kind { display: none; }
}

/* ---------- reveal ---------- */
/* Deliberately small and slow. A 12px rise is noticeable without announcing
   itself; anything bigger turns scrolling into a slideshow. */
.rv { opacity: 0; transform: translateY(14px); }
.rv.in {
  opacity: 1;
  transform: none;
  transition: opacity .8s ease, transform .8s cubic-bezier(.2, .7, .25, 1);
}
@media (prefers-reduced-motion: reduce) {
  .rv, .rv.in { opacity: 1; transform: none; transition: none; }
}

/* ============================================================================
   DEEP SKY
   Ambient colour, and the only colour on the site. Every rule below paints
   background light behind content — nothing here touches type, borders,
   buttons, or any interface element, so the design system stays monochrome
   while the page stops being flat black.

   How it works, because it is easy to break:

   - Each field is TWO layers (::before and ::after) drifting in opposite
     directions at different periods. One layer alone reads as a blob sliding
     around; two counter-moving layers interfere, and that interference is what
     makes it look like it is swimming rather than panning.
   - GEOMETRY IS LOAD-BEARING. Every layer uses the same `inset: -35% -25%` and
     the same mask, and the two are matched to each other: the box overhangs the
     section by 35% top and bottom, so the section's own top edge falls at 20.6%
     of the box and its bottom edge at 79.4%. The mask reaches full opacity at
     21% and returns to zero by 79.5% — i.e. the colour fades to nothing exactly
     where the section's `overflow: hidden` would otherwise cut it. Change the
     inset without changing the mask and you get a hard horizontal seam across
     the page at the section boundary.
   - Blur is large and alpha is low. To get more colour, raise the alpha a
     little and leave the blur alone; sharpening it is what turns this from
     astrophotography into a 2019 gradient hero.
   - Three fields only — hero, statement, contact. The long black stretches
     between them are the reason the colour reads as something you noticed
     rather than as wallpaper.
   ========================================================================== */

.hero::before,
.hero::after,
.contact::before,
.contact::after,
.page-head::before,
.page-head::after,
.cta::before {
  position: absolute;
  inset: -35% -25%;
  z-index: -1;
  pointer-events: none;
  /* No `will-change` on purpose. These are large blurred layers — eight of them
     across the site — and will-change pins a GPU texture for each one for the
     life of the page, which is real memory on a phone. A running transform
     animation already gets the element composited, and the browser is then free
     to drop the layer when it scrolls out of view. */
  -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 21%, #000 68%, transparent 79.5%);
          mask-image: linear-gradient(180deg, transparent 0, #000 21%, #000 68%, transparent 79.5%);
}

/* ---- hero: the brightest field. Violet left, oxygen teal across the top,
        hydrogen rose low and right. ---- */
.hero::before {
  background:
    radial-gradient(38% 40% at 16% 40%, rgba(var(--neb-violet), .30), transparent 68%),
    radial-gradient(44% 34% at 50% 26%, rgba(var(--neb-teal),   .17), transparent 70%),
    radial-gradient(28% 30% at 92% 30%, rgba(var(--neb-amber),  .08), transparent 74%);
  filter: blur(72px) saturate(1.15);
  animation: swim-a 68s ease-in-out infinite;
}
.hero::after {
  background:
    radial-gradient(40% 40% at 72% 56%, rgba(var(--neb-rose),   .19), transparent 70%),
    radial-gradient(36% 36% at 30% 64%, rgba(var(--neb-violet), .15), transparent 70%);
  filter: blur(84px) saturate(1.1);
  animation: swim-b 92s ease-in-out infinite;
}

/* ---- contact: the bookend. Rose and amber where the hero led violet, so the
        foot of the page runs warmer than the head instead of repeating it. --- */
.contact::before {
  background:
    radial-gradient(42% 44% at 22% 54%, rgba(var(--neb-rose),  .27), transparent 70%),
    radial-gradient(34% 34% at 56% 64%, rgba(var(--neb-amber), .15), transparent 72%);
  filter: blur(78px) saturate(1.15);
  animation: swim-b 76s ease-in-out infinite;
}
.contact::after {
  background:
    radial-gradient(44% 44% at 82% 48%, rgba(var(--neb-violet), .25), transparent 70%),
    radial-gradient(32% 34% at 96% 40%, rgba(var(--neb-teal),   .14), transparent 72%);
  filter: blur(88px) saturate(1.1);
  animation: swim-a 96s ease-in-out infinite;
}

/* ---- work page ---- Its head leans teal and its foot leans rose, mirroring
        the home page's arc so the two read as one site rather than two. ---- */
.page-head::before {
  background:
    radial-gradient(40% 44% at 20% 44%, rgba(var(--neb-teal),   .22), transparent 70%),
    radial-gradient(36% 38% at 62% 30%, rgba(var(--neb-violet), .19), transparent 72%);
  filter: blur(76px) saturate(1.15);
  animation: swim-a 72s ease-in-out infinite;
}
.page-head::after {
  background:
    radial-gradient(36% 38% at 84% 54%, rgba(var(--neb-rose),  .15), transparent 72%),
    radial-gradient(28% 32% at 96% 34%, rgba(var(--neb-amber), .09), transparent 74%);
  filter: blur(88px) saturate(1.1);
  animation: swim-b 98s ease-in-out infinite;
}
.cta::before {
  background:
    radial-gradient(42% 44% at 26% 54%, rgba(var(--neb-rose),   .22), transparent 70%),
    radial-gradient(38% 40% at 76% 48%, rgba(var(--neb-violet), .18), transparent 72%),
    radial-gradient(28% 30% at 54% 66%, rgba(var(--neb-amber),  .11), transparent 74%);
  filter: blur(82px) saturate(1.15);
  animation: swim-b 80s ease-in-out infinite;
}

/* Two paths, deliberately not mirror images — mirrored paths cancel at the
   midpoint and the whole thing visibly pauses twice a cycle. The scale change
   is what sells it as gas rather than as an object being moved around. */
@keyframes swim-a {
  0%,  100% { transform: translate3d(0, 0, 0)        scale(1); }
  27%       { transform: translate3d(4%, -3%, 0)     scale(1.14); }
  53%       { transform: translate3d(-3%, 4%, 0)     scale(1.05); }
  78%       { transform: translate3d(2.5%, 2.5%, 0)  scale(1.18); }
}
@keyframes swim-b {
  0%,  100% { transform: translate3d(0, 0, 0)        scale(1.08); }
  33%       { transform: translate3d(-4%, 3%, 0)     scale(1); }
  61%       { transform: translate3d(3%, 3.5%, 0)    scale(1.16); }
  84%       { transform: translate3d(-2%, -3%, 0)    scale(1.04); }
}

/* Reduced motion keeps the colour and drops the drift — the fields are just as
   good standing still, and the movement is the only part anyone objects to. */
@media (prefers-reduced-motion: reduce) {
  .hero::before, .hero::after,
  .contact::before, .contact::after,
  .page-head::before, .page-head::after,
  .cta::before,
  .hero h1 { animation: none; }
}

/* ---- grain ----
   Large, soft, low-alpha gradients band badly on 8-bit displays — you get
   visible contour rings through the colour. A little monochrome noise across
   the whole page dithers those away for the cost of one static raster layer.
   It sits under the sticky nav (z-index 40) and the lightbox (90) on purpose. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: .04;
  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='.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}
