/* Reset + base typography + layout primitives + accessibility utilities. Shared by every page. */

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

* {
  margin: 0;
}

/*
  Custom scrollbar. This is the shared baseline — Studio's own look, and every page/style's fallback
  before any more specific override — themed via the same variables everything else uses (Firefox via
  scrollbar-color/-width, Chrome/Safari/Edge via the ::-webkit-scrollbar family), so it already
  re-colors automatically per home style since --surface-alt/--line-strong/--accent are theme
  variables. Glass Noir/Editorial/Playful/Atelier each go further than just recoloring this same
  shape, though, matching how every other component on this site gets a genuinely distinct per-theme
  treatment rather than a palette-swapped one — see the "Custom scrollbar" section in each of their
  own home-*.css files for the shape/gradient/weight overrides. Studio deliberately keeps just this
  plain version as its own signature, since restrained/minimal IS its identity.
*/
html {
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) var(--surface-alt);
}

::-webkit-scrollbar {
  width: 12px;
  height: 12px;
}

::-webkit-scrollbar-track {
  background: var(--surface-alt);
}

::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border-radius: 999px;
  border: 3px solid var(--surface-alt);
}

::-webkit-scrollbar-thumb:hover {
  background: var(--accent);
}

/*
  Guarantee the native `hidden` attribute always actually hides things. Any class that sets its own
  `display` (e.g. `.chat-window { display: flex }`) ties the UA stylesheet's `[hidden]{display:none}`
  on specificity, and author styles win cascade ties over UA styles — so `hidden` silently stops
  working on any element that also carries such a class. Found exactly this: chat.js correctly set
  `.hidden = true` on the chat window, but it stayed visible on top of the setup form regardless,
  because `.chat-window{display:flex}` outranked the browser's default. This blanket override is the
  standard fix (same rule normalize.css/sanitize.css ship for the same reason).
*/
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  *,
  *::before,
  *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
}

body {
  background: var(--surface);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  transition: background-color var(--dur-normal) var(--ease), color var(--dur-normal) var(--ease);
  overflow-x: hidden;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

/*
  Sticky footer: on any page short enough that its content doesn't fill the viewport (not_found.html
  is the shortest page on the site and the one this was actually noticed on), .site-footer would
  otherwise end wherever the content happens to stop, leaving a gap of plain page background between
  the footer and the real bottom of the viewport. That gap is filled with body's own background color,
  which is a subtly different shade from the footer's own background — barely noticeable as a design
  choice when the footer sits at the very bottom of a long page (home/about/contact all have enough
  content to push it there naturally), but reads as an obvious seam/glitch once there's a large blank
  strip of the other shade between them. main's flex-grow fills exactly that leftover space so the
  footer always touches the true bottom of the viewport, on every page, regardless of content length.
*/
main {
  flex: 1 0 auto;
}

.site-header,
main,
.site-footer {
  opacity: 1;
  transition: opacity 220ms var(--ease), filter 220ms var(--ease), transform 220ms var(--ease);
}

/*
  Toggled by site.js during a homepage style switch, for a brief cross-fade instead of an instant,
  jarring snap when the stylesheet swaps. Only ever set on index.html. Covers the header too, not
  just main: editorial's masthead reflows the nav to two rows, so leaving the header out of the
  fade meant it visibly snapped/reflowed a beat before the rest of the page caught up whenever you
  switched into or out of editorial — reported as the header "glitching".

  A plain opacity crossfade (the original version of this rule) read as "kinda weird" — a flat fade
  to a blank page and back, with a hard, blank gap in the middle where nothing about the transition
  felt connected to what came before or after. Added a small blur and scale alongside the fade (the
  same "give it physical presence" reasoning as the scroll-reveal fix above) so the outgoing style
  reads as softly dissolving away and the incoming one as settling into focus, rather than an
  instant on/off toggle either side of a blank pause. site.js's own timeouts around this class were
  updated to match this rule's actual 220ms duration (they'd drifted slightly out of sync with the
  old 180ms).

  IMPORTANT — filter/transform live ONLY on this transitioning-scoped rule, never on the resting
  rule above (even at "neutral" values like `scale(1)`/`blur(0)`): any non-`none` transform or filter
  on `main`, even a no-op one, creates a new containing block for `position: fixed` descendants —
  and Glass Noir's background blobs are exactly that, nested inside `.hero` inside `main`. Setting
  `transform: scale(1)` permanently on `main` (an earlier version of this rule did) silently broke
  their fixed-to-the-viewport positioning site-wide, making them scroll with the page instead of
  staying put — reported as Glass Noir "straight up breaking." Keeping transform/filter exclusively
  inside the brief `.home-style-transitioning` window means `main` only has a containing-block-
  creating transform for the ~300ms an actual switch is happening, not permanently.
*/
body.home-style-transitioning .site-header,
body.home-style-transitioning main,
body.home-style-transitioning .site-footer {
  opacity: 0;
  filter: blur(6px);
  transform: scale(0.99);
}

img,
picture,
svg {
  display: block;
  max-width: 100%;
}

img {
  height: auto;
}

a {
  color: inherit;
  text-decoration: none;
}

button,
input,
textarea,
select {
  font: inherit;
  color: inherit;
}

button {
  background: none;
  border: none;
  cursor: pointer;
}

ul,
ol {
  list-style: none;
  padding: 0;
}

/*
  Global safety net for every bare `<svg class="icon">` in the markup (nav/menu/theme toggles,
  work-card and check-list arrows, etc). Low specificity on purpose: any component or home-style
  rule that sets its own fill/stroke (.button .icon, .service-icon, .work-card .icon, ...) wins
  over this without needing !important. Without this, an SVG with no explicit fill renders solid
  black per the SVG default — found exactly that bug on work-card arrows in themes that only ever
  set `stroke` and assumed `fill: none` was already covered elsewhere.
*/
.icon {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

h1 {
  font-size: clamp(2.4rem, 3.2vw + 1.4rem, 4.4rem);
}

h2 {
  font-size: clamp(1.9rem, 1.8vw + 1.3rem, 2.75rem);
}

h3 {
  font-size: clamp(1.15rem, 0.5vw + 1rem, 1.4rem);
}

p {
  text-wrap: pretty;
}

/* Layout primitives */

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

.narrow {
  max-width: 760px;
}

/*
  .cta-band (every home style's final "Start with a message" band) is both `.container` AND the
  element carrying the band's own visible background, in the same shared markup — `<div class=
  "container cta-band">`. That's invisible everywhere else `.container` is used, because a plain
  `.container` has no background of its own; only its CHILDREN (showcase-card, work-card, etc.) have
  visible card backgrounds, and those children are correctly inset by .container's own padding-inline
  since padding only affects the gap between a box's edge and its content, not the box's own outer
  position. But .cta-band's visible background paints at the box's own (edge-touching) position, and
  every theme's own `padding: 40px` (or similar) on this same element overrides .container's
  padding-inline for content-inset purposes — so below the width where .container's max-width starts
  centering it with real margin (~1180px), the band's own colored background runs flush to the
  viewport edges with nothing but its internal padding, reading as "no side padding" even though the
  text inside it is correctly inset. Confirmed via an actual user screenshot on a wide (foldable-
  unfolded-width) phone, and confirmed the same structural gap exists at any width below that, just
  less visually obvious on a narrower phone. Fixed once here rather than per theme, since the cause
  and the fix are both about layout, not any theme's specific colors.
*/
@media (max-width: 1180px) {
  .cta-band {
    width: auto;
    margin-inline: var(--container-pad);
  }
}

.section {
  padding-block: clamp(44px, 5vw, 84px);
}

/*
  One consistent gap ("sweet spot") applied to every adjacent-section boundary EXCEPT two explicitly
  excluded ones: hero → what-we-build (left alone — the hero's own height is automatic/viewport-
  driven, not part of this) and recent-work → questions (already the right amount, called out by name
  to stay untouched). Every other boundary was too large at the plain .section default
  (clamp(44px, 5vw, 84px) each side, 140px total at a typical desktop width) — replaced by
  clamp(30px, 3.4vw, 48px) each side (96px total at that same width) throughout.

  The gap a visitor sees is the SUM of both sides meeting at a boundary (section A's bottom padding +
  section B's top padding), so both sides of every affected boundary use the same value below —
  .work's padding-bottom and .faq's padding-top are the one deliberate exception, left at the plain
  .section default so the recent-work → questions gap is exactly as it was.

  Applies to all 5 home styles since these section-level classes aren't themed/overridden anywhere
  else — .final-cta is the one exception (each home-*.css already gives it its own padding-block), so
  its padding-top is adjusted per theme file instead, not here.
*/
.showcase {
  padding-bottom: clamp(30px, 3.4vw, 48px);
}

.process {
  padding-block: clamp(30px, 3.4vw, 48px);
}

.launch {
  padding-block: clamp(30px, 3.4vw, 48px);
}

.pricing {
  padding-block: clamp(30px, 3.4vw, 48px);
}

.about-brief {
  padding-block: clamp(30px, 3.4vw, 48px);
}

.work {
  padding-top: clamp(30px, 3.4vw, 48px);
}

.faq {
  padding-bottom: clamp(30px, 3.4vw, 48px);
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}

.eyebrow::before {
  content: "";
  width: 18px;
  height: 1px;
  background: currentColor;
  opacity: 0.7;
}

/* Accessibility */

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

/*
  Scroll-reveal (progressive enhancement), rebuilt on `animation`/`@keyframes` instead of
  `transition`. Content is visible by default, and only once the early head script confirms JS runs
  and adds `.js` to <html> do reveal targets start hidden, pending site.js's IntersectionObserver —
  so a no-JS visitor, or a visitor whose JS hasn't run yet, always sees full content, never a blank
  section.

  Why `animation`, not `transition` (this used to be transition-based, and it's worth recording why
  that never actually worked, confirmed with an isolated minimal reproduction rather than assumed):
  a CSS transition only animates a property change if the element's computed style, in its NEW/target
  state, still lists that property under `transition-property` — and the old implementation declared
  `transition` ONLY on the hidden `:not(.is-visible)` rule, specifically to stop it from permanently
  overriding a card's own hover transition once revealed (`transition` is a single shorthand property;
  two competing rules don't merge, whichever wins the cascade replaces the other wholesale — this
  style has hit that exact "hover transition silently replaced by the reveal's" bug before). But the
  moment `.is-visible` gets added, the element stops matching the ONLY rule that had `transition`
  declared at all — so by the time the opacity/transform values actually change, nothing declares a
  transition for them anymore, and the browser applies the new values instantly. This is not a timing
  or paint-order bug (a fix along those lines was tried and measurably failed) — it reproduces
  identically even with the class added synchronously with zero delay, in total isolation from this
  site's own JS.

  `animation` sidesteps the entire problem: it doesn't compare an old vs. new computed style the way
  `transition` does, so there's no equivalent requirement about which state declares what, and it
  cannot conflict with a component's own separate `transition` property (hover effects) at all, since
  they're independent CSS properties rather than one shorthand two rules have to fight over. No
  `:not(.is-visible)` guard is needed for that reason anymore either.
*/
.js [data-reveal] {
  opacity: 0;
  transform: translateY(16px);
}

/*
  Opacity + translateY only — no scale. A previous version also scaled from 96% to 100% for a bit
  more physical presence, but the user reported the rebuilt reveal reading as "glitchy, like it's
  happening twice" across the whole site, not any one element — every discrete cause was checked and
  ruled out directly (class mutations, animationstart/end counts, theme-switch replay, overshoot in
  the sampled opacity/scale values themselves — none showed an actual double-fire anywhere), which
  pointed at the animation's own visual properties rather than a logic bug. Scaling TEXT specifically
  is a known source of exactly this kind of artifact: browsers re-rasterize glyphs at each fractional
  scale factor during the animation, and that re-rasterization is more prone to visible wobble/
  ghosting on some GPUs/browsers than a plain translate ever is, since translate just repositions
  already-rasterized pixels instead of re-drawing them. Dropping scale removes that whole risk and is,
  if anything, an even more standard/minimal version of this pattern than before.
  Opacity + transform only (no filter/blur) — both are compositor-only properties, so this stays
  GPU-cheap regardless of how many cards enter at once, consistent with this site's no-bloat stance.

  Deliberately NOT using `animation-fill-mode: forwards` here, and deliberately restating the same
  opacity:1/transform:none the animation ends on as a PLAIN property directly on this rule too, not
  just inside the @keyframes — tried the `forwards` version first and it broke every card's hover
  transform site-wide (confirmed directly, not assumed): a finished animation with fill-mode forwards
  keeps applying its final keyframe value as an animation-level override, which in the cascade
  outranks ANY ordinary style rule regardless of specificity — including `.showcase-card:hover`'s own
  `transform`, permanently. Without `forwards`, the animation still visibly plays once, but the
  instant it finishes it stops existing as an active override — the element falls back to this
  plain rule's own opacity/transform, which happens to already match exactly where the animation
  left off, so there's no visible seam at the handoff, and every component's own hover transform
  works normally again immediately afterward.

  No `.js` prefix here, unlike the hidden-state rule above — deliberate, not an inconsistency. Adding
  it would raise this rule's specificity to 1 class + 1 attribute + 1 class, which actually OUTRANKS
  a plain `.showcase-card:hover` (1 class + 1 pseudo-class) outright, permanently overriding its
  `transform` regardless of source order. Confirmed this exact regression directly while rebuilding.
  Keeping this rule at [data-reveal].is-visible (1 attribute + 1 class) ties hover's specificity
  instead of beating it, so the normal source-order tiebreak decides it — and since every home-*.css
  file loads after this one, each component's own :hover rule correctly wins that tie.

  `animation-fill-mode: backwards` is the actual fix for the "glitchy, like it's happening twice"
  report, found by measuring (not assuming) any staggered element's opacity through its own delay
  window: any element with a nonzero --reveal-delay (i.e. anything but the first in a staggered
  group) was visibly flashing to fully opaque the INSTANT .is-visible got added, holding for the
  length of its own delay, THEN dropping back down and fading in a second time once the animation's
  delay actually elapsed and it started for real — a genuine, confirmed double-appearance, not a
  false alarm. Cause: animation-fill-mode defaults to `none`, which has no effect on the element
  during an animation-delay — so for that whole delay window, the element was governed by the PLAIN
  opacity:1/transform:none on this same rule instead, before the animation itself ever took over.
  `backwards` makes the animation apply its `from` keyframe's values throughout the delay instead,
  so the element correctly stays hidden until its own delay elapses and the fade genuinely begins —
  and specifically `backwards`, not `both`, since `both` would reintroduce the earlier hover-blocking
  bug this rule's plain opacity/transform values exist to avoid (see above): `backwards` only affects
  the DELAY period, not what happens after the animation ends.
*/
[data-reveal].is-visible {
  opacity: 1;
  transform: translateY(0);
  animation: reveal-in var(--dur-slow) var(--ease) var(--reveal-delay, 0ms) backwards;
}

@keyframes reveal-in {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Utility */

.stack-sm > * + * {
  margin-top: 12px;
}

.stack-md > * + * {
  margin-top: 20px;
}

.stack-lg > * + * {
  margin-top: 32px;
}

.visually-hide {
  display: none !important;
}

@media (max-width: 720px) {
  .hide-mobile {
    display: none !important;
  }
}
