/* ==========================================================================
   exe.cute v3 — reset, page shell, and the shared vocabulary
   ========================================================================== */

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

html {
  background: var(--ink);
  color: var(--bone);
  -webkit-text-size-adjust: 100%;
  scroll-behavior: auto;
}
/* The document scrolls normally — v3 deliberately does NOT use a fixed scroll
   wrapper (see docs/v3-direction.md, "rejected"): find-in-page, :target anchors,
   back-navigation restore and the iOS URL bar all keep working. */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }

body {
  margin: 0;
  font-family: var(--font-sans);
  font-weight: 300;
  font-size: 16px;
  line-height: 1.5;
  background: var(--ink);
  color: var(--bone);
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* --ink on --accent measured 4.06:1, and swapping the type to --bone does not help — that is
   4.05:1, the accent sits almost exactly between them. The field is darkened instead
   (--accent-deep, 5.24:1 under --bone). The smallest selectable text on the page is 11.84px, far
   under any large-text threshold, so 4.5:1 is what applies. */
::selection { background: var(--accent-deep); color: var(--bone); }

a { color: inherit; text-decoration: none; }
h1, h2, h3, p { margin: 0; }
em { font-family: var(--font-serif); font-style: italic; font-weight: 400; }
.dot { color: var(--accent); }

/* VISUALLY HIDDEN, still announced. Every scrambling element on the page carries one of these:
   the scramble mutates visible text on a 40ms ticker, so a screen reader following the DOM
   announces a run of random glyphs for the duration. The scrambling span is aria-hidden and this
   copy holds the real string beside it. Promoted here from chrome.css, which had it as
   .chrome__sr for the one element that used to be protected — there are now eleven.
   clip-path rather than width/height alone: a zero-size box drops out of some AT trees. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
}
/* At DISPLAY sizes --accent is large text and 4.06:1 clears the 3:1 that applies — the hero's
   period and the eyebrow dot are both well over 24px. At body size the same value fails: the
   accent period the stagger re-applies to the last character of each pillar sentence measures
   21.6px, where 4.5:1 is required. --accent-text is 5.04:1 and the same hue. */
.hsst-char.dot,
.pillar__text .dot { color: var(--accent-text); }
img, svg, canvas { display: block; max-width: 100%; }

main { position: relative; z-index: 10; }

/* --------------------------------------------------------------------------
   The field (z -1) — v2's silk shader, ported to js/gl/field.js
   -------------------------------------------------------------------------- */
/* z 0, NOT -1: body carries an opaque --ink background (the no-WebGL fallback), and the body
   background paints AFTER negative-z-index children of the root stacking context — so at -1 the
   field is invisible. v2 used z-index 0 for exactly this reason. */
.field {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
}
.field-fallback {
  position: fixed;
  inset: -20%;
  z-index: 0;
  pointer-events: none;
  display: none;
  background:
    radial-gradient(40% 50% at 30% 40%, rgba(61, 100, 245, 0.35), transparent 70%),
    radial-gradient(35% 45% at 70% 65%, rgba(236, 235, 230, 0.12), transparent 70%),
    var(--ink);
  filter: blur(40px);
}
html.no-gl .field-fallback { display: block; }
html.no-gl .field { display: none; }
/* The contact section reserves its upper band for the WebGL wordmark; with no renderer that band
   is ~240px of nothing, so the address rises to meet the reader instead. */
html.no-gl .contact { min-height: 0; padding-top: 12vh; }

/* --------------------------------------------------------------------------
   The screen (z 5) — the 4px dot grain that hides the field everywhere except
   the two ends of the page, and the aperture that cuts a hole in it.

   Two stacked dot layers with slightly different radii, so across the aperture
   rim the big dots fade a beat before the small ones. That is a two-step
   approximation of the clone's continuous per-cell radius shrink
   (clones/haoqi/js/webgl/background.js:345-380) done entirely in CSS.
   -------------------------------------------------------------------------- */
.screen {
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  opacity: var(--scr);
  background-image:
    radial-gradient(circle, var(--ink) 45%, transparent 46%),
    radial-gradient(circle, var(--ink) 25%, transparent 26%);
  background-size: var(--cell) var(--cell), var(--cell) var(--cell);
  background-position: 0 0, 2px 2px;
  /* inverse aperture: opaque outside the circle, transparent inside */
  -webkit-mask-image: radial-gradient(circle at var(--ax) var(--ay), transparent var(--ar), #000 calc(var(--ar) + var(--af)));
  mask-image: radial-gradient(circle at var(--ax) var(--ay), transparent var(--ar), #000 calc(var(--ar) + var(--af)));
}

/* The inner layer of the pillars: the direct aperture mask. Everything inside
   the rim is the resolved page; everything outside it is the screened one. */
.aperture-in {
  -webkit-mask-image: radial-gradient(circle at var(--ax) var(--ay), #000 var(--ar), transparent calc(var(--ar) + var(--af)));
  mask-image: radial-gradient(circle at var(--ax) var(--ay), #000 var(--ar), transparent calc(var(--ar) + var(--af)));
}

/* --------------------------------------------------------------------------
   .develop — the page's single reveal gesture.
   --dev-r 0 = absent · --dev-full = solid. Run forward it resolves out of the
   dot matrix; run backward it dissolves into it. Ported in spirit from
   BEHAVIORS §5 (uPolarityPositive 0 → 1), re-targeted from photographs to type.
   -------------------------------------------------------------------------- */
.develop {
  -webkit-mask-image: radial-gradient(circle, #000 var(--dev-r), transparent calc(var(--dev-r) + 0.6px));
  mask-image: radial-gradient(circle, #000 var(--dev-r), transparent calc(var(--dev-r) + 0.6px));
  -webkit-mask-size: var(--cell) var(--cell);
  mask-size: var(--cell) var(--cell);
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
}
/* Where masks are unsupported the element simply stays solid and readable. */
@supports not ((-webkit-mask-image: radial-gradient(#000, #000)) or (mask-image: radial-gradient(#000, #000))) {
  .develop { -webkit-mask-image: none; mask-image: none; opacity: 1 !important; }
  .screen, .aperture-in { -webkit-mask-image: none; mask-image: none; }
  .aperture-in, .screen { display: none; }
  /* Without the aperture the inner layer is gone, so the base layer has to carry the section on
     its own — at --bone-25 it is ~2:1 contrast and unreadable. Same fallback the reduced-motion
     block already gets right, and the counter/track are instruments for a scrub that cannot run. */
  .pillars__layer--base { color: var(--bone); }
  .pillars__counter, .pillars__track { display: none; }
}

/* --------------------------------------------------------------------------
   Shared type bits (carried from v2)
   -------------------------------------------------------------------------- */
.section__meta {
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: 0.74rem;
  letter-spacing: 0.06em;
  text-transform: lowercase;
  /* --bone-70 (8.27:1), not --bone-45 (3.97:1). This is 0.74rem text, so 4.5:1 applies — the 3:1
     large-text threshold starts at 24px. Four declarations across four files carried the same
     value: here, chrome.css (the readout), pillars.css (the counter) and footer.css (the footer
     row). --bone-45 stays where it is NOT text: the pillars fill bar and the mid-scramble tint. */
  color: var(--bone-70);
}
.section__meta--abs {
  position: absolute;
  top: clamp(90px, 12vh, 140px);
  left: var(--gutter);
}

.eyebrow__dot {
  display: inline-block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
  margin-right: 0.9em;
  vertical-align: middle;
  transform: translateY(-1px);
  box-shadow: 0 0 12px rgba(61, 100, 245, 0.8);
}

/* --------------------------------------------------------------------------
   The dotted hover frame — the one hover language on the page.
   Quieter than the clone's: 1px --bone-25, not 2px solid white.
   -------------------------------------------------------------------------- */
.frame { position: relative; }
.frame::before {
  content: "";
  position: absolute;
  inset: -0.5em -0.6em;
  border: 1px dotted transparent;
  pointer-events: none;
  transition: border-color 0.2s var(--ease-std);
}
@media (hover: hover) and (min-width: 1024px) {
  /* --bone-45 (3.97:1), not --bone-25 (1.99:1): a hover affordance nobody can see is not one.
     Deliberately weaker than the focus ring above, which is solid --bone, so the two read as
     different states rather than as the same one. */
  .frame:hover::before { border-color: var(--bone-45); }
}

/* --------------------------------------------------------------------------
   Scramble (js/core/scramble.js) — confined to the DM Mono instrument layer.
   Never on the sans headline or the serif italic. That rule is the single
   strongest identity guard in v3; see docs/v3-direction.md.
   -------------------------------------------------------------------------- */
[data-scramble] { display: inline; }
.scr-hidden { opacity: 0; }
.scr-a { color: var(--accent); }
.scr-b { color: var(--bone-45); }

/* --------------------------------------------------------------------------
   Per-character stagger (js/core/stagger.js) — a fade and a 6px rise, never a
   glyph substitution, so it suits 300-weight proportional type.
   -------------------------------------------------------------------------- */
.hsst-char { display: inline-block; white-space: pre; }
@keyframes hsstIn  { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes hsstOut { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-6px); } }

/* --------------------------------------------------------------------------
   Reserve the sticky wrappers' height in CSS.

   core/stage.js sets these in px from the frame loop, but that is too late for
   scroll restoration: on a reload deep in the page the browser restores an
   offset against a document that is still ~2,500px short, and the reader lands
   past the section they were reading. Reserving the same height up front — the
   stage counts and stageVh from docs/v3-direction.md — makes the document the
   right length before restore, and JS then only refines it.
   -------------------------------------------------------------------------- */
html.js .approach { height: 400svh; }   /* (1 + 4 × 0.75) */
html.js .pillars  { height: 370svh; }   /* (1 + 3 × 0.90) */

/* --------------------------------------------------------------------------
   Forced colours (Windows high contrast, and any UA that substitutes its own
   palette). Everything this page uses to carry meaning — the field, the dot
   screen, the guides, the aperture — is decoration that the substitution
   either drops or renders as a solid block, so it is taken out and the page
   is handed back as type on the system's own ground.

   The aperture layer goes rather than the base layer: the two are the same
   copy stacked, and with the mask no longer dimming one of them they would
   read as doubled text.
   -------------------------------------------------------------------------- */
@media (forced-colors: active) {
  .field,
  .field-fallback,
  .screen,
  .guides,
  .gl-overlay,
  .pillars__layer--in,
  .pillars__glyphs { display: none !important; }

  .pillars__layer--base { color: CanvasText !important; }
  .chrome__bar--top,
  .chrome__bar--bottom {
    background: Canvas !important;
    -webkit-backdrop-filter: none !important;
    backdrop-filter: none !important;
  }
  .frame::before { border-color: CanvasText !important; }
  :focus-visible { outline-color: Highlight !important; }
  ::selection { background: Highlight; color: HighlightText; }
}

/* --------------------------------------------------------------------------
   No JavaScript (or the module CDN blocked): everything scroll-driven is
   scoped to html.js, so the cascade below is what an unscripted visitor gets —
   a plain, readable page in normal flow. Without it the three pillars print on
   top of each other at --bone-25 and the section is an illegible smear.
   -------------------------------------------------------------------------- */
html:not(.js) .loader,
html:not(.js) .gl-overlay,
html:not(.js) .screen,
html:not(.js) .pillars__layer--in,
html:not(.js) .pillars__glyphs,
html:not(.js) .pillars__counter,
html:not(.js) .pillars__track { display: none !important; }

/* THE FROM-STATES, released.
   Dropping html.js is how this page says "nobody is driving": index.html arms it on a deadline and
   main.js drops it again if an init throws. But the layout rules below were the whole of that
   cascade, and by the time a throw can happen ui/hero.js has already applied .is-armed — which
   translates every headline line 115% down and holds four more elements at opacity 0, waiting for
   an .is-in that will now never arrive. Without these resets the fallback produced a readable
   LAYOUT containing no visible headline at all. */
/* The scramble layer is held at opacity 0 by an INLINE style written in each controller's
   constructor, so no ordinary rule can reach it — hence !important. Ten elements across every
   section depend on the frame loop ticking to be revealed; if the loop is what died, they would
   otherwise stay invisible on a page that is otherwise perfectly readable. Measured: with
   core/loop.js throwing, 0 of 10 were lit. */
html:not(.js) [data-scramble] { opacity: 1 !important; }

html:not(.js) .hero.is-armed .line__in,
html:not(.js) .hero.is-armed .hero__sub,
html:not(.js) .hero.is-armed .eyebrow__dot,
html:not(.js) .hero.is-armed .hero__scroll-label,
html:not(.js) .hero.is-armed .hero__scroll-line,
html:not(.js) .hero.is-armed #hero-dot {
  transform: none !important;
  opacity: 1 !important;
  animation: none !important;
  clip-path: none !important;
}

/* The cover is a HIDDEN PAGE now, not an element painted over it, so hiding the loading chrome is
   not enough — the document itself has to be handed back. This is the third independent route to a
   visible page (the other two are js/ui/loader.js's own sequence and the 2.4s inline failsafe), and
   it is the one that catches a module graph that rejects rather than one that never arrives. */
html:not(.js) .page.is-loading {
  visibility: visible !important;
  pointer-events: auto !important;
  user-select: auto !important;
  animation: none !important;
}

html:not(.js) .approach,
html:not(.js) .pillars { height: auto !important; }

html:not(.js) .approach__sticky,
html:not(.js) .pillars__sticky {
  position: static !important;
  min-height: 0 !important;
  padding: var(--gutter) var(--gutter) calc(var(--gutter) * 2) !important;
}
html:not(.js) .pillars__layer--base { position: static !important; color: var(--bone) !important; }
html:not(.js) .pillar,
html:not(.js) .approach__line,
html:not(.js) .closing__w {
  position: static !important;
  opacity: 1 !important;
  transform: none !important;
  margin-bottom: clamp(28px, 5vh, 64px);
}
html:not(.js) .develop,
html:not(.js) .aperture-in {
  -webkit-mask-image: none !important;
  mask-image: none !important;
}
html:not(.js) .section__meta--abs { position: static !important; }
/* Both halves of the closing movement reserve a screen each for scroll-driven work that cannot
   run here; without this an unscripted visitor gets 1,168px of dead air between the words and
   the address. */
html:not(.js) .closing,
html:not(.js) .contact { min-height: 0 !important; }

/* --------------------------------------------------------------------------
   Keyboard focus. The dotted .frame is the page's one affordance, so it should
   answer the keyboard as well as the pointer — and it must work outside the
   (hover: hover) query, where the hover half deliberately lives.
   -------------------------------------------------------------------------- */
/* FOCUS HAS TO BE SEEN, and this block used to guarantee it could not be. The three rules ran in
   the wrong order of specificity: the outline declared on :focus-visible was cancelled outright by
   .frame:focus-visible { outline: none }, so on all four focusable elements the only indicator
   left was the 1px dotted --bone-25 frame — 1.99:1, against the 3:1 WCAG 2.2 SC 1.4.11 asks of
   whatever identifies component state.
   --bone is 16.47:1 on --ink. Hover keeps a weaker ring below so the two states stay
   distinguishable, but it is lifted off --bone-25 too: at 1.99:1 it was not reliably visible
   either. */
.frame:focus-visible::before { border-color: var(--bone); border-style: solid; }
:focus-visible { outline: 2px solid var(--bone); outline-offset: 4px; }
/* The frame IS the indicator on these, so an outline would be a second one drawn on top of it. */
.frame:focus-visible { outline: none; }

/* --------------------------------------------------------------------------
   Reduced motion — five things stop, and both sticky sections fall back to a
   normal-flow layout with every line and every pillar visible.
   -------------------------------------------------------------------------- */
/* ?nomotion asserts html.force-static, and this selector list is why: every rule the reduced-motion
   media query carries applies to it too, so the flag and the preference cannot drift apart. The
   JS side reads prefersReducedMotion() directly, so ?nomotion does NOT change module behaviour —
   it changes what the page LOOKS like, which is what it was always used for. */
html.force-static .field, html.force-static .develop, html.force-static .hsst-char,
html.force-static [data-scramble] { opacity: 1 !important; }
html.force-static .develop { -webkit-mask-image: none !important; mask-image: none !important; }
html.force-static .hsst-char { animation: none !important; transform: none !important; }
html.force-static .screen { opacity: 0 !important; }
html.force-static .aperture-in { display: none !important; }

@media (prefers-reduced-motion: reduce) {
  /* THE STICKY WRAPPERS GIVE BACK THEIR SCROLL. Both sections reserve height for stages that no
     longer run under this preference — .approach already released its (approach.css), .pillars
     never did. Measured at 1440x900: the wrapper stayed 3,330px tall with its sticky child
     collapsed to 936px, leaving 2,394px of empty --ink below the last pillar, 29% of the whole
     document. !important because core/stage.js may have written an inline height before the
     media query is consulted. */
  .pillars { height: auto !important; }

  .field, .develop, .hsst-char, [data-scramble] { opacity: 1 !important; }
  .develop { -webkit-mask-image: none !important; mask-image: none !important; }
  .hsst-char { animation: none !important; transform: none !important; }
  .screen { opacity: 0 !important; }
  .aperture-in { display: none !important; }
  .gate { display: none !important; }
}
