/* JL Digital — night-crossing redesign. No inline styles anywhere (CSP: style-src 'self').
   Motion is progressive enhancement: js/main.js adds .js and, when the visitor
   allows motion, .motion to <html>. Without JS everything is visible and static. */

*, *::before, *::after { box-sizing: border-box; }
/* F18: header is position:fixed (~80px tall with the enlarged .btn below), so a
   plain anchor jump lands the target flush under it. scroll-padding-top tells
   smooth-scroll (and browser-native "jump to fragment") to stop short by this
   much, so section headings clear the header on arrival. One rule, both pages,
   every #anchor. */
/* color-scheme tells the browser this page IS dark — native scrollbars,
   autofill fills, and form widgets stop rendering light-mode white boxes
   into the night scene (guidelines sweep, 2026-08-15). */
html { scroll-behavior: smooth; scroll-padding-top: 84px; color-scheme: dark; }
/* touch-action: manipulation removes the legacy double-tap-zoom wait on
   taps — on the budget Androids this site is built for, that is a real
   ~300ms between tap and response on every button. */
a, button { touch-action: manipulation; }
body {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.55;
  text-wrap: pretty;
}
/* Nocturne v3: headings speak in the brand serif (Fraunces 600) while body
   and UI chrome stay Public Sans — the scale contrast between the two
   families is what makes the hierarchy read without adding a single byte of
   colour or motion. letter-spacing 0, not the sans' old -0.015em: Fraunces
   is already tightly fitted, and negative tracking on a serif collapses the
   counters at text sizes. T2: headings also sit on the brightest tier
   (neutral-100) — the one-notch lift above body text is what "peaks" are
   built from; secondary text keeps its dimmer mixes. */
h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 600; line-height: 1.12; letter-spacing: 0; color: var(--color-neutral-100); margin: 0; }
p { margin: 0; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
a { color: var(--color-accent); text-underline-offset: 3px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

.skip-link {
  position: absolute; left: -9999px; top: 12px; z-index: 100;
  padding: 8px 14px; background: var(--color-surface); color: var(--color-text);
  border-radius: var(--radius-md); box-shadow: var(--shadow-md); font-size: 13.5px;
}
.skip-link:focus { left: 12px; }

/* Visually hidden but still in the accessibility tree — used to give a section
   a real heading (for correct h1→h2→h3 order) without showing duplicate text
   next to a kicker that already says the same thing on screen. */
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Keeps a short phrase ("2–3 weeks.") from breaking at its dash — display
   type breaks at en dashes and the stranded half reads like a typo. */
.nowrap { white-space: nowrap; }

.wrap { max-width: 1240px; margin: 0 auto; padding-inline: var(--pad-x); }
.wrap-wide { max-width: 1500px; margin: 0 auto; padding-inline: clamp(0px, 2vw, 40px); }

/* ── Buttons & tags ─────────────────────────────────────────────── */
.btn {
  /* 13px/18px padding at 14px text = 44.8px tall — clears the project's own
     44px tap-target bar (WCAG 2.5.8's floor is only 24px). */
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 13px 18px; border-radius: var(--radius-md); border: 1px solid transparent;
  font-family: var(--font-heading); font-size: 14px; font-weight: 500; line-height: 1.2;
  text-decoration: none; cursor: pointer; white-space: nowrap;
  transition: background-color 0.2s, border-color 0.2s, color 0.2s, transform 0.12s ease-out;
}
/* F49: press feedback — the one place transform is animated on interaction.
   Compositor-only, 120ms, and it doubles as the :active state so the filled
   primary never needs a darker (contrast-losing) active colour. */
.btn:active { transform: scale(0.97); }
/* Android's grey tap flash replaced by nothing — the scale press above IS
   the tap feedback, and the default flash paints outside the radius. */
.btn { -webkit-tap-highlight-color: transparent; }
/* F04: the primary CTA was an outline like everything else — on a cheap
   low-brightness Android a 1px purple line on near-black barely reads as a
   button. Solid accent-500 fill on dark text: 5.87:1 against #161826-as-text
   (computed, ≥4.5 AA). Hover lightens to accent-400 (higher ratio, still AA);
   :active keeps the same fill and signals with the .btn scale press instead,
   because accent-600 on dark text would drop to 3.95:1. */
.btn-primary { border-color: var(--color-accent-500); color: var(--color-bg); background: var(--color-accent-500); }
/* T6: secondary border lifted 38% → 55% — at 38% the outline button next to
   a filled primary read as disabled, not as a second choice. */
.btn-secondary { border-color: color-mix(in srgb, var(--color-text) 55%, transparent); color: var(--color-text); background: transparent; }
.btn-ghost { color: var(--color-accent-300); background: transparent; }
/* (T7 retired .on-photo: no button sits over a screenshot anymore — the
   work-stage caption, buttons included, lives below the figure now.) */
/* T6: the decision buttons (hero + contact) get one size up — 16px text and
   44→~51px height — so the two moments the page asks for action are also
   the two biggest targets on it. The primary's static accent halo marks it
   as the page's brightest interactive object; it never animates (F50: no
   box-shadow transitions), so it costs one paint, once. */
.btn-lg { padding: 16px 28px; font-size: 16px; }
.btn-lg.btn-primary { box-shadow: 0 0 28px color-mix(in srgb, var(--color-accent) 30%, transparent); }

.tag {
  display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px;
  font-size: 12px; font-weight: 500; letter-spacing: 0.02em;
}
.tag-accent { background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent-200); }
.tag-neutral { background: color-mix(in srgb, var(--color-text) 12%, transparent); color: color-mix(in srgb, var(--color-text) 80%, transparent); }

/* ── Header ─────────────────────────────────────────────────────── */
.site-head {
  position: fixed; top: 0; left: 0; right: 0; z-index: 40;
  display: flex; align-items: center; gap: 22px;
  padding-block: 14px;
  padding-inline: max(var(--pad-x), calc((100% - 1240px) / 2 + var(--pad-x)));
  transition: background-color 0.4s, box-shadow 0.4s;
}
.site-head.scrolled {
  background: color-mix(in srgb, var(--color-bg) 82%, transparent);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: 0 1px 0 var(--color-divider);
}
/* F20: padding-block + matching negative margin-block grows the click/tap
   target to ~44px tall without pushing the header taller — the negative
   margin cancels the padding's layout footprint, so the extra hit area
   floats outside the flow but is still fully painted and clickable. */
.brand {
  margin-right: auto; padding-block: 11px; margin-block: -11px;
  display: inline-flex; align-items: center;
  font-family: var(--font-heading); font-weight: 500; font-size: 15px; color: var(--color-text); text-decoration: none;
}
.site-head .nav-links { display: flex; align-items: center; gap: 22px; }
/* F28: same padding/negative-margin trick as .brand — grows the tap target
   without growing the header. */
.site-head .nav-links a { padding-block: 8px; margin-block: -8px; font-size: 13.5px; color: color-mix(in srgb, var(--color-text) 70%, transparent); text-decoration: none; }

/* ── Hero ───────────────────────────────────────────────────────── */
/* The oversized track exists only to feed the JS scroll-fade: the hero pins
   (sticky) while the visitor scrolls the extra height and the copy fades out.
   F21: without that fade — no JS, or prefers-reduced-motion (html.motion is
   only ever added when motion is allowed) — the extra height is just dead
   dark scroll, so the track collapses to one viewport. svh (where supported)
   tracks the *small* mobile viewport so Android's collapsing URL bar doesn't
   push the scroll hint off-screen; the vh line above it is the fallback. */
/* T18: 168vh → 135vh. The scroll-fade completes at 0.62 viewports of
   scroll (js/main.js), so everything past ~1.35 track heights was dead
   dark travel — sky with no story left in it. The short variant shrinks
   in proportion. */
.hero-track { height: 135vh; position: relative; }
.hero-track.short { height: 128vh; }
html:not(.motion) .hero-track, html:not(.motion) .hero-track.short { height: 100vh; height: 100svh; }
.hero {
  position: sticky; top: 0; height: 100vh; height: 100svh; overflow: clip;
  display: grid; align-items: center;
  background: linear-gradient(178deg, var(--color-night-deep) 0%, var(--color-bg) 46%, var(--color-night-mid) 72%, var(--color-night-low) 100%);
}
.horizon {
  position: absolute; left: 0; right: 0; bottom: 26%; height: 1px;
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--color-accent) 55%, transparent) 20%, color-mix(in srgb, var(--color-accent) 55%, transparent) 80%, transparent);
  /* T13: glow spread doubled (22px → 44px) — the line is the night scene's
     spine and it was vanishing on dim phone panels. rgba fallback first
     (F27): a WebView without color-mix keeps the first declaration. */
  box-shadow: 0 0 44px 2px rgba(145, 132, 217, 0.28);
  box-shadow: 0 0 44px 2px color-mix(in srgb, var(--color-accent) 28%, transparent);
}
.sea { position: absolute; left: 0; right: 0; bottom: 0; height: 26%; background: linear-gradient(to bottom, color-mix(in srgb, var(--color-accent-900) 60%, transparent), transparent 70%); }
/* T13: glow stepped up two shades (accent-900 → accent-700 mix) — the old
   wash sat below what a cheap panel can show. This is moonlight behind thin
   cloud, not a spotlight: still no hard edge. */
.heroglow { position: absolute; inset: 0; background: radial-gradient(1000px 520px at 74% 30%, color-mix(in srgb, var(--color-accent-700) 42%, transparent), transparent 62%); }
.sky { position: absolute; inset: 0; pointer-events: none; }
.sky-stars { z-index: 0; }
.sky-flies { z-index: 1; }
/* T16: sea shimmer — two thin light streaks breathing on the water, the
   moon's reflection broken by swell. Pure paint: fixed-position hairlines
   whose opacity breathes on a 7–9s period (compositor-friendly, and only
   under html.motion). Static visitors keep faint constant glints; the
   .asleep rule joins the existing off-screen pause so covered pixels never
   animate. Colour is accent-400 as a literal rgba — universal support. */
.sea::before, .sea::after {
  content: ""; position: absolute; height: 1px; opacity: 0.12;
  background: linear-gradient(to right, transparent, rgba(181, 171, 252, 0.55) 35%, rgba(181, 171, 252, 0.55) 65%, transparent);
}
.sea::before { left: 12%; right: 36%; top: 34%; }
.sea::after { left: 32%; right: 10%; top: 60%; height: 2px; opacity: 0.08; }
html.motion .sea::before { animation: shimmer 8.5s ease-in-out 1.6s infinite; }
html.motion .sea::after { animation: shimmer 7.2s ease-in-out 4.1s infinite; }
.asleep .sea::before, .asleep .sea::after { animation-play-state: paused; }

.hero-copy { position: relative; z-index: 2; max-width: 1240px; width: 100%; margin: 0 auto; padding-inline: var(--pad-x); }
.greeting { margin-bottom: 24px; font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-accent); }
/* F05: the old 13ch cap force-wrapped "right here on Siquijor." (23 chars)
   into a second line at every viewport — 3–4 total lines instead of the two
   the markup breaks by hand. 25ch lets each hand-broken span own one line at
   ≥768px; the wrap container is the real constraint at desktop. The clamp
   floor drops 46→36px so "I build websites," survives a 280px-wide 320
   viewport on one line (3 lines total there — the spans' own break + one
   natural wrap — instead of 4). */
/* Serif retune (Nocturne v3): -0.018em was Inter's fit; Fraunces at 92px
   wants barely-negative tracking (-0.01em) or the serifs start touching.
   opsz 144 forces the display cut of the variable optical axis — highest
   stroke contrast, tightest fit — instead of the browser's per-size auto
   interpolation, matching the brand spec ("Fraunces 600, opsz 144").
   line-height eases 1.06 → 1.08: serif ascenders/descenders need the air. */
.hero-title { font-size: clamp(36px, 7vw, 92px); line-height: 1.08; letter-spacing: -0.01em; font-variation-settings: "opsz" 144; margin-left: -0.06em; max-width: 25ch; }
.hero-title span { display: block; }
.hero-title .accent, .hero-title em { color: var(--color-accent); font-style: normal; }
.hero-track.short .hero-title { font-size: clamp(44px, 6.6vw, 92px); max-width: 14ch; }
.sub { font-size: clamp(16px, 1.4vw, 19px); line-height: 1.65; max-width: 46ch; margin-top: 36px; color: color-mix(in srgb, var(--color-text) 80%, transparent); }
.hero-track.short .sub { max-width: 50ch; }
.cta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 32px; }

/* F39: the "SCROLL" label was the most generic piece of dark-template
   furniture on the page — the label span is gone from the HTML (it was
   aria-hidden decoration, not copy) and only the thin animated line remains,
   reading as part of the night scene. F22: js/main.js now fades the whole
   .hint with the same scroll progress that fades the hero copy, so the cue
   disappears the moment the visitor acts on it. */
.hint { position: absolute; bottom: 28px; left: 50%; transform: translateX(-50%); display: grid; justify-items: center; gap: 8px; z-index: 2; }
.hint-line { display: block; width: 1px; height: 34px; background: var(--color-accent); }
html.motion .hint-line { animation: scrollhint 2.2s ease-in-out infinite; }

/* ── Content panel over the hero ────────────────────────────────── */
.panel { position: relative; z-index: 3; background: var(--color-bg); box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.5); }
/* F11: the night scene used to exist only in the hero and the closing
   contact section — ~70% of the scroll was flat #161826. .sky-mid is one
   absolutely-positioned host spanning the whole panel; js/main.js seeds it
   with ~10 faint *static* stars (no animation, no parallax — zero per-frame
   cost). Sections sit at z-index:1 so the stars show only on plain-bg
   stretches and slip behind the opaque pricing/honest bands. */
.sky-mid { z-index: 0; }
.panel > section { position: relative; z-index: 1; }
/* T17: hairline echo of the horizon at the hero→panel seam — the motif
   that opens the page recurs where the story hands over to the content,
   and again above the footer (.horizon-foot). rgba fallback first (F27). */
.panel::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px; z-index: 1;
  background: linear-gradient(to right, transparent, rgba(145, 132, 217, 0.25) 30%, rgba(145, 132, 217, 0.25) 70%, transparent);
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--color-accent) 25%, transparent) 30%, color-mix(in srgb, var(--color-accent) 25%, transparent) 70%, transparent);
}
/* T14: film grain — one 160px SVG turbulence tile (data URI, ~0.4kb),
   painted at 3–5% opacity on the four "surface" sections only, never as a
   full-page fixed layer (that would cost a viewport-sized blend on every
   frame). Two jobs: the imperceptible texture that separates "screen" from
   "print", and dithering the long night gradients so 6-bit Android panels
   stop banding them. Never animated. */
.hero::after, .pricing::after, .honest-band::after, .contact::after, .thanks-hero::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0.04;
  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.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Grain hosts that were position:static — the pseudo needs a containing
   block that is the section itself. */
.pricing, .honest-band { position: relative; }

.kicker { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; font-size: 13px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-accent); }
.kicker .rule { width: 44px; height: 1px; background: var(--color-accent); }
.on-band .kicker { color: var(--color-accent-300); }
.on-band .kicker .rule { background: var(--color-accent-300); }
/* T3: the type ladder rebuilt so the money headlines outrank feature blurbs.
   Section titles jump 48 → 72px max: these are the page's biggest claims
   ("One real site, live. Yours in 2–3 weeks.") and they were rendering
   smaller than the feature h3s below them. Tracking eased for the serif. */
.sec-title { font-size: clamp(38px, 5vw, 72px); letter-spacing: -0.008em; text-wrap: balance; }

/* What I do */
/* T15: mid-page moonlight — the two flattest slabs of the crossing get a
   faint violet radial wash (accent-900 as literal rgba, universal support)
   so the night keeps a light source between the hero and the contact
   scene. Opposite corners on purpose: the moon arcs across the page. */
.what { padding-block: clamp(84px, 14vh, 150px); background: radial-gradient(880px 460px at 12% 4%, rgba(43, 39, 65, 0.6), transparent 66%); }
.features { display: grid; gap: clamp(70px, 12vh, 120px); }
/* F10: .feature's old 30ch cap was computed from the 15px body font (~300px)
   while the h3 inside runs up to 58px — big type shattered into 1–2 words
   per line. The block is now sized in px for the headline's sake and the
   h3 gets its own ch cap tuned so each headline breaks at 2 lines, not 3–4.
   The paragraph keeps its own 44ch cap below. */
.feature { max-width: 560px; }
.feature.right { margin-left: auto; }
.feature .num { font-size: 15px; color: var(--color-accent); font-feature-settings: 'tnum' 1; margin-bottom: 18px; }
/* T3: stepped DOWN from 58px max — at that size the three feature blurbs
   outshouted every section headline. 40px keeps them clearly the third tier
   of the ladder (hero 92 > sec-title 72 > these 40) instead of a false peak. */
.feature h3 { font-size: clamp(26px, 3vw, 40px); line-height: 1.15; margin-bottom: 18px; max-width: 18ch; }
.feature p { font-size: 16px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 72%, transparent); max-width: 44ch; }
/* T9: the live proof strip under feature 02. The KB figure is the page
   quoting its own network tab — the one claim no competitor can paste. The
   number itself is a small serif peak; the sentence around it stays quiet. */
.proof-strip { margin-top: 20px; font-size: 14px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 62%, transparent); max-width: 44ch; }
.proof-strip .proof-kb { font-family: var(--font-display); font-weight: 600; font-size: 22px; color: var(--color-neutral-100); white-space: nowrap; }

/* Work */
.work { padding-bottom: clamp(84px, 14vh, 150px); }
.work .sec-title { margin-bottom: 48px; }
.stage { position: relative; }
/* T7: the proof shot is staged, not embedded — rounded, ringed in the deep
   accent, sat on an ambient violet pedestal glow with a real offset drop
   shadow underneath. It must read as the brightest object in its viewport:
   the one piece of client work carries more conversion weight than any
   sentence on the page. Dual box-shadow lines are the F27 pattern — the
   first (no color-mix) is what an old Android WebView keeps when it drops
   the second wholesale. */
.shot {
  position: relative; overflow: clip; border-radius: var(--radius-lg);
  box-shadow: 0 0 0 1px #423a6a, 0 30px 80px -24px rgba(0, 0, 0, 0.7);
  box-shadow:
    0 0 0 1px var(--color-accent-800),
    0 30px 80px -24px rgba(0, 0, 0, 0.7),
    0 0 110px color-mix(in srgb, var(--color-accent-700) 20%, transparent);
}
/* T7: a slim CSS-only browser bar above the homepage capture — three window
   dots painted as radial-gradients, zero images. It frames the screenshot
   as "a live site", which is exactly the claim the section makes. Scoped to
   .work: the case-study page's capture already includes the family's own
   admin bar, and stacking a second chrome on it would read as a mistake. */
.work .shot::before {
  content: ""; display: block; height: 34px;
  border-bottom: 1px solid var(--color-divider);
  background:
    radial-gradient(circle at 22px 17px, #595d6c 4.5px, transparent 5.5px),
    radial-gradient(circle at 42px 17px, #595d6c 4.5px, transparent 5.5px),
    radial-gradient(circle at 62px 17px, #595d6c 4.5px, transparent 5.5px),
    var(--color-surface);
}
/* height:auto is load-bearing, not tidiness. An image element with its
   width/height attributes set gets them as presentational hints on the CSS
   width AND height properties. `width: 100%` here out-specifies the width
   hint, but nothing was overriding the height hint — so the box was locked
   to the file's own pixel height (900px) and `aspect-ratio` was ignored
   entirely, because a ratio only ever computes the missing side. Both
   ratios below were dead code: at 320px the stage rendered a 292×900 tower
   showing maybe a fifth of the screenshot's width. `auto` frees the height
   so the ratios take effect; the attributes still do their real job of
   reserving the right box before the file arrives (no layout shift). */
.shot img { width: 100%; height: auto; aspect-ratio: 21 / 10; object-fit: cover; object-position: top; }
/* T7: the 90% scrim that sat on the homepage capture is GONE — it existed
   to make white caption text readable on top of the artwork, and it cost
   the page its single brightest proof. The caption now sits below the
   figure at every width (the pattern .site-shot already used), so nothing
   floats over the screenshot and nothing needs a text-shadow. */
.shot-link { display: block; }
.stage-info { padding: 24px clamp(0px, 2vw, 40px) 0; }
.stage-info .tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.stage-info h3 { font-size: clamp(26px, 3vw, 40px); margin-bottom: 10px; }
.stage-info p { font-size: 14.5px; line-height: 1.6; color: color-mix(in srgb, var(--color-text) 85%, transparent); margin-bottom: 18px; max-width: 56ch; }
.stage-info .row { display: flex; gap: 12px; flex-wrap: wrap; }
.founding { margin-top: 42px; }
.founding p { font-size: 15px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 72%, transparent); max-width: 56ch; }

/* Pricing */
/* F12: the band used to start and stop on a razor edge. The two outer
   linear-gradients ramp ~140px from the page bg into the band at each edge,
   so "night" flows into "deeper night" instead of hard-cutting. Layer order:
   ramps on top, glow beneath them, flat band colour at the bottom. */
.pricing {
  background:
    linear-gradient(to bottom, var(--color-bg), transparent 140px),
    linear-gradient(to top, var(--color-bg), transparent 140px),
    /* T10: glow grown and brightened (1100×500@60% → 1400×620@80%) — the
       band's light now visibly pools behind the founding card's corner
       instead of re-brightening the whole slab. */
    radial-gradient(1400px 620px at 80% 0%, color-mix(in srgb, var(--color-section-glow) 80%, transparent), transparent 62%),
    var(--color-section);
  padding-block: clamp(84px, 12vh, 130px);
}
.pricing .sec-title { margin-bottom: 12px; }
.slide-note { font-size: 14px; color: color-mix(in srgb, var(--color-text) 60%, transparent); margin-bottom: 42px; }
/* F09: snap was `mandatory`, which fights diagonal thumb-scrolling at 320 —
   proximity still settles cards neatly but never hijacks the gesture. */
.scroller {
  display: flex; gap: 21px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin;
  padding: 6px max(var(--pad-x), calc((100% - 1240px) / 2 + var(--pad-x))) 28px;
}
/* F09: at 1440 the flex scroller showed 2.6 of 4 cards with "Site care"
   fully offscreen behind a hairline scrollbar. Where there's room, pricing
   is a 2×2 grid — every card visible, no interaction to explain. The
   scroller (and its keyboard access via tabindex) still serves <1100px;
   .slide-note is instructions for the slider only, so it hides with it. */
@media (min-width: 1100px) {
  .scroller { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow-x: visible; padding-bottom: 6px; }
  .pkg { flex: none; }
  .slide-note { display: none; }
}
/* Keyboard access: .scroller carries tabindex="0" in the HTML so arrow keys
   can scroll it natively (the 4 cards inside have no focusable children of
   their own). outline-offset is negative — a positive offset would draw the
   ring outside the box, where overflow-x:auto can clip it; pulling it inward
   keeps the ring fully visible. */
.scroller:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -3px; }
.pkg {
  flex: 0 0 min(400px, 86vw); scroll-snap-align: start;
  display: flex; flex-direction: column; gap: 14px; padding: 28px;
  border-radius: var(--radius-lg);
  /* T19: a real light model instead of a flat tint — the surface catches
     light at the top and falls off, a 1px inset top-light marks the lit
     edge, and an offset drop shadow puts the card ABOVE the band. The
     moonlight established in T13/T15 finally lands on something. F27: the
     rgba-only lines come first; a WebView without color-mix keeps those. */
  background: color-mix(in srgb, var(--color-bg) 55%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 52%, transparent), color-mix(in srgb, var(--color-bg) 48%, transparent) 62%);
  box-shadow: inset 0 1px 0 rgba(233, 233, 237, 0.07), 0 0 0 1px rgba(233, 233, 237, 0.18), 0 16px 40px -20px rgba(0, 0, 0, 0.6);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-text) 7%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-text) 18%, transparent), 0 16px 40px -20px rgba(0, 0, 0, 0.6);
}
/* F52: the featured card used to differ by a 1px ring only — at a glance all
   four cards weighed the same. A faint opaque accent tint (accent-900 mixed
   into the page bg — light text on it stays well above AA) makes "start
   here" legible before reading a word. */
.pkg.featured {
  /* T10+T19: the founding card is the band's peak. A gradient BORDER via
     the double-background technique (padding-box fill under a border-box
     gradient through a transparent border) — light strikes its top edge
     and dies down the sides, which a flat ring can't do. Plus the violet
     halo (static — F50) and a deeper drop. Fallback chain for WebViews
     without color-mix: solid bg (#1f1f32 = the mix, precomputed) and an
     accent ring in the shadow list. */
  border: 1px solid transparent;
  background: #1f1f32;
  background:
    linear-gradient(color-mix(in srgb, var(--color-accent-900) 45%, var(--color-bg)), color-mix(in srgb, var(--color-accent-900) 45%, var(--color-bg))) padding-box,
    linear-gradient(155deg, var(--color-accent-400), var(--color-accent-600) 34%, var(--color-accent-800) 72%, var(--color-accent-700)) border-box;
  box-shadow: 0 0 0 1px var(--color-accent-500), 0 18px 44px -18px rgba(0, 0, 0, 0.65);
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-accent-300) 22%, transparent), 0 0 44px color-mix(in srgb, var(--color-accent) 16%, transparent), 0 18px 44px -18px rgba(0, 0, 0, 0.65);
}
/* T10: at the 2×2 grid width the founding card also grows ~4% — scale, not
   width, so the grid tracks never move (compositor-only, applied once). */
@media (min-width: 1100px) {
  .pkg.featured { transform: scale(1.04); }
}
.pkg .head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.pkg h3 { font-size: 20px; }
/* T2/T10: the price is a peak. It was 26px — smaller than a feature blurb
   heading — for the single number a prospect scrolls here to find. Serif
   numerals, brightest tier, and a size that outranks everything on the card. */
.price { font-family: var(--font-display); font-weight: 600; font-feature-settings: 'tnum' 1; font-size: clamp(32px, 3vw, 42px); line-height: 1.3; color: var(--color-neutral-100); }
/* T10: accent-200, one step brighter than before — the founding price is
   the number the whole page exists to sell. */
.pkg.featured .price { color: var(--color-accent-200); }
/* F29: this fine print carries real terms — billing frequency, founding-slot
   conditions — not decoration. Raised from 12.5px for an audience of older
   owners on budget phones. */
.price small { display: block; font-family: var(--font-body); font-weight: 400; font-size: 14px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
/* The struck regular price beside the founding price. Set at the same 78%
   mix the card's list uses, not the 55% of .note — that lighter mix already
   measures 4.48:1 on the card background, so reusing it here would add a
   second AA failure. Explicit thickness because the browser default hairline
   is easy to miss on a dark surface at this size. */
.price .was { font-size: 18px; color: color-mix(in srgb, var(--color-text) 78%, transparent); text-decoration-thickness: 1.5px; }
.pkg ul { margin: 0; padding-left: 18px; font-size: 14px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 78%, transparent); flex: 1; }
.pkg .note { font-size: 14px; line-height: 1.6; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.tag-outline { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 25%, transparent); color: color-mix(in srgb, var(--color-text) 75%, transparent); }
/* T11: the commission argument is the sharpest copy on the page — it was
   dressed as three columns of 14px fine print. Editorial treatment instead:
   one reading column, a real reading size, hairline rules between the three
   arguments, each opened by a bright subhead. It should read like the
   op-ed it is, not like terms and conditions. */
.explain { max-width: 62ch; margin-top: 56px; }
.explain p { font-size: 15.5px; line-height: 1.75; color: color-mix(in srgb, var(--color-text) 72%, transparent); }
.explain p + p { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--color-divider); }
.explain .lead { display: block; font-size: 17px; font-weight: 600; color: var(--color-neutral-100); margin-bottom: 8px; }

/* How it works */
/* T15: second moonlight wash — see .what. */
.how { padding-block: clamp(84px, 14vh, 150px); background: radial-gradient(1000px 520px at 88% 8%, rgba(43, 39, 65, 0.55), transparent 66%); }
.how .sec-title { margin-bottom: 21px; }
.steps-grid { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: clamp(24px, 5vw, 96px); }
.stepnum-col { position: relative; }
.stepnum {
  position: sticky; top: 34vh; margin: 0;
  /* Nocturne v3: the giant numeral is display type, not UI — it gets the
     serif at full optical size (opsz 144) so the strokes carry the same
     voice as the headlines it sits beside. */
  font-family: var(--font-display); font-weight: 600; font-variation-settings: "opsz" 144; font-size: clamp(80px, 11vw, 168px); line-height: 1;
  color: var(--color-accent); font-feature-settings: 'tnum' 1; opacity: 0.9;
  /* F33: without an explicit curve the exit half of the crossfade ran on the
     browser's default `ease` while JS gave the entry half --ease-out — the
     outgoing number felt mushier than the incoming one. Same curve both ways. */
  transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
  text-shadow: 0 0 46px color-mix(in srgb, var(--color-accent) 40%, transparent);
}
/* F34: was 52vh — four short sentences stretched across 2+ viewports of dark.
   38vh still gives the numeral crossfade room without the section feeling
   like the page ran out of content. */
/* T18: 38vh → 28vh — four short sentences don't earn two viewports of
   dark between them; the numeral crossfade still has room to breathe. */
.step { min-height: 28vh; display: grid; align-content: center; }
.step h3 { font-size: clamp(22px, 2.4vw, 30px); margin-bottom: 12px; }
/* F38/F42: each step can render its own small inline number from the
   data-step attribute that already drives the sticky numeral. Hidden by
   default; shown (with the sticky column hidden) at ≤700px — where the giant
   sticky "04" ate a third of a 320px screen — and for no-JS visitors, who
   otherwise saw a frozen "01" beside all four steps. */
.step::before {
  content: attr(data-step); display: none;
  font-family: var(--font-heading); font-weight: 500; font-size: 15px; line-height: 1;
  color: var(--color-accent); font-feature-settings: 'tnum' 1; margin-bottom: 14px;
}
html:not(.js) .steps-grid { grid-template-columns: 1fr; }
html:not(.js) .stepnum-col { display: none; }
html:not(.js) .step::before { display: block; }
.step p { font-size: 15.5px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 72%, transparent); max-width: 44ch; }

/* About */
/* F35: every section used the same 84–150px gap, so major beats (Work,
   Pricing) and this minor one carried equal weight. About compresses; the
   rhythm finally signals importance. */
.about { padding-bottom: clamp(56px, 8vh, 96px); }
.about-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 42px clamp(24px, 5vw, 96px); align-items: center; }
.about .sec-title { margin-bottom: 24px; }
.about-grid p + p { margin-top: 18px; }
.about-grid .body-text { font-size: 15.5px; line-height: 1.75; color: color-mix(in srgb, var(--color-text) 78%, transparent); max-width: 52ch; }
/* F14 epilogue: the designed-absence vignette is retired — a real portrait
   exists now (see index.html). Ratio and radius are unchanged so the grid
   never moves; the hero's night gradient stays underneath as the loading
   backdrop for the lazy image; the divider ring is now an outer shadow
   (an inset one would paint under the photo's own pixels). */
.portrait-slot {
  aspect-ratio: 4 / 5; width: 100%; height: auto; object-fit: cover;
  border-radius: var(--radius-md);
  background: linear-gradient(180deg, var(--color-night-deep), var(--color-bg) 58%, var(--color-night-mid) 100%);
  /* T8: the portrait gets a backlight — solid ring (up from the 16% hairline
     that vanished on dim panels), a wide violet halo behind the frame, and a
     real offset drop shadow so it sits ON the page, not IN it. The photo
     itself still needs a lifted-exposure re-export from Jun; CSS stages the
     light, it cannot invent luminance. F27 dual-line: first shadow list has
     no color-mix and survives old WebViews. */
  box-shadow: 0 0 0 1px #595d6c, 0 22px 56px -12px rgba(0, 0, 0, 0.65);
  box-shadow:
    0 0 0 1px var(--color-neutral-700),
    0 22px 56px -12px rgba(0, 0, 0, 0.65),
    0 0 110px color-mix(in srgb, var(--color-accent-600) 22%, transparent);
}
.frame { overflow: clip; border-radius: var(--radius-md); box-shadow: var(--shadow-sm); }
/* F08: the 4:5 crop cut the bottom ~42% of the 390×844 phone screenshot —
   the date pickers and install prompt its alt text promises. Natural ratio
   (from the width/height attributes) shows the whole frame; the 5fr grid
   column already limits its width.
   Bugfix (task 8 follow-up): `height: auto` was missing, so with no CSS
   height rule the browser fell back to the raw `height="844"` HTML
   attribute as a fixed pixel height at every viewport, while `width: 100%`
   scaled freely with the grid column — stretching the screenshot (and
   tripping Lighthouse's image-aspect-ratio audit, which caught the
   mismatch at its default 412px mobile viewport). `height: auto` lets
   the natural ratio do its job at every width instead of just one.
   2026-08-14: the explicit `aspect-ratio: auto` that used to sit here is
   GONE, on purpose. An author-level `auto` overrides the browser's own
   mapping of the width/height attributes (`aspect-ratio: auto 780/1569`),
   so before the file arrived the image computed to height 0 — and Chrome
   never lazy-loads a zero-area image. The photo was invisible on the live
   site. With no declaration at all, the attribute mapping stands: the box
   is reserved at the right ratio, the lazy load fires, and there's no
   layout shift when the pixels land. */
.frame img { width: 100%; height: auto; }

/* Contact */
.contact { position: relative; overflow: clip; padding: clamp(84px, 12vh, 130px) 0 70px; background: radial-gradient(900px 480px at 50% 118%, color-mix(in srgb, var(--color-accent-900) 85%, transparent), transparent 66%); }
.contact .wrap { position: relative; z-index: 1; }
/* text-wrap: balance (here and .sec-title) stops display headings stranding
   a single word on their last line; progressive — older browsers ignore it. */
.contact-title { font-size: clamp(34px, 4.6vw, 64px); letter-spacing: -0.01em; margin-bottom: 14px; max-width: 16ch; text-wrap: balance; }
.contact-sub { font-size: 16px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 75%, transparent); margin-bottom: 48px; }
.contact-grid { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: 42px clamp(24px, 5vw, 96px); align-items: start; }
form { display: grid; gap: 21px; margin: 0; }
.fields-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 21px; }
/* Two peer actions on one row — flex, not grid, so they keep their natural
   widths side by side and wrap to two rows on their own at ~320px rather than
   being forced into equal columns. */
.form-actions { display: flex; flex-wrap: wrap; gap: 12px; }
/* F19: was 13px, below body text — raised for an audience of older users on
   budget phones. Kept a step under the 16px inputs so label vs. value
   hierarchy still reads. */
form label { display: grid; gap: 8px; font-size: 14px; color: color-mix(in srgb, var(--color-text) 70%, transparent); }
input, textarea {
  /* 16px, not 14 — anything smaller triggers iOS Safari's auto-zoom on focus. */
  width: 100%; padding: 10px 12px; border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--color-surface) 70%, transparent);
  /* F27: rgba fallback first — without it, WebViews lacking color-mix
     would drop the border entirely and the fields lose their edges. */
  border: 1px solid rgba(233, 233, 237, 0.38);
  border: 1px solid color-mix(in srgb, var(--color-text) 38%, transparent);
  color: var(--color-text); font: inherit; font-size: 16px;
}
input:focus-visible, textarea:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 0; border-color: transparent; }
textarea { resize: vertical; }
/* AA fix: the site never set a placeholder colour, so hint text (e.g. the
   "beach resort in Solangon" example) fell back to the browser's default
   grey (#757575) — 3.46:1 on the input's dark fill, below the 4.5:1 floor
   placeholders need since they convey real information here. */
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.hp { position: absolute; left: -9999px; }
.channels { display: grid; gap: 14px; align-content: start; }
/* F40: the static "Find me on the island" card used to wear the exact same
   treatment as the two link cards — only an invisible-on-touch hover shadow
   separated interactive from not. Role now reads at a glance: links carry an
   accent left edge, a raised surface, and a hover; the plain card sits
   flatter with no edge and no shadow. (F50: the hover cue is a
   background-color change, not an animated box-shadow — no paint-heavy
   shadow transition.) */
.channel {
  display: block; padding: 21px; border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--color-surface) 45%, transparent);
  text-decoration: none; color: var(--color-text);
}
a.channel {
  background: color-mix(in srgb, var(--color-surface) 85%, transparent);
  box-shadow: var(--shadow-sm), inset 2px 0 0 var(--color-accent);
  transition: background-color 0.2s;
}
.channel strong { display: block; font-family: var(--font-heading); font-weight: 500; font-size: 15px; margin-bottom: 4px; }
.channel span { display: block; font-size: 13.5px; color: color-mix(in srgb, var(--color-text) 70%, transparent); }
.channel .link { color: var(--color-accent); }

/* Case study — ledger rows and stats */
.started { padding: clamp(84px, 14vh, 150px) 0 clamp(70px, 10vh, 110px); }
.split-5-7 { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 42px clamp(24px, 5vw, 96px); align-items: start; }
.split-5-7 .sec-title { max-width: 16ch; }
/* F15: "A guesthouse that only existed on other people's pages" is 54
   characters — the shared 16ch cap wrapped it to 4 lines at every viewport.
   Widening alone can't fix it: the 5fr column is only ~427px at 1440, where
   54 chars of 48px type still need 4 lines no matter the ch cap. So this one
   heading also steps down from the display size — it's a long editorial
   sentence, not a two-word title — landing at 2–3 lines everywhere.
   (The shared 16ch/48px combo still suits the short titles elsewhere:
   "kwartosguesthouse.com", "What's still missing".) */
.started .sec-title { max-width: 30ch; font-size: clamp(28px, 3vw, 40px); }
.prose { display: grid; gap: 18px; align-content: start; padding-top: 6px; }
.prose p { font-size: 15.5px; line-height: 1.75; color: color-mix(in srgb, var(--color-text) 78%, transparent); max-width: 58ch; }
.site-shot { padding-bottom: clamp(70px, 10vh, 110px); }
/* F36: this stage's only caption is the two tag pills — a tighter top pad
   than the homepage's full caption block. (T7 made below-figure captions
   the base .stage-info behaviour, so only the padding differs here.) */
.site-shot .stage-info { padding: 14px clamp(0px, 2vw, 40px) 0; }
/* This stage shows the capture whole; the homepage's 21:10 band is a teaser
   and can crop. 8:5 is the screenshot's own ratio, so at desktop the
   "Book Your Stay" button and the 8.2/47-reviews badge survive — the two
   things the paragraph below this section actually points at, and the two
   the alt text names. A 21:10 crop from the top loses both.
   (The scrim this comment once excused is gone site-wide — T7.) */
.site-shot .shot img { aspect-ratio: 8 / 5; }
.built { padding-bottom: clamp(84px, 14vh, 150px); }
.built-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 42px clamp(24px, 5vw, 96px); align-items: center; }
/* Bugfix (task 8 follow-up): the heading is a literal domain name,
   "kwartosguesthouse.com" — one unbroken string with no space for the
   browser to wrap at. At the ≤700px single-column width (below) the text
   column narrows to ~280px, too tight for the string at this heading's
   30px floor size, so it overflowed the column without wrapping and pushed
   document.scrollWidth past the viewport. break-word lets it wrap between
   letters only when it truly doesn't fit; it's a no-op at every wider
   width where the string already fits on one line. */
.built .sec-title { margin-bottom: 24px; overflow-wrap: break-word; }
.built p { font-size: 15.5px; line-height: 1.75; color: color-mix(in srgb, var(--color-text) 78%, transparent); max-width: 56ch; margin-bottom: 18px; }
.stats { display: flex; gap: clamp(28px, 4vw, 56px); flex-wrap: wrap; font-feature-settings: 'tnum' 1; margin-top: 28px; }
/* T2: the proof numbers join the brightest tier — 8.2 and 47 are the only
   third-party-sourced figures on the whole site, and they were dimmer than
   the paragraph beside them. Serif numerals at 600, brightest neutral. */
.stat b { display: block; font-family: var(--font-display); font-weight: 600; font-size: clamp(28px, 2.6vw, 38px); line-height: 1.1; color: var(--color-neutral-100); }
.stat span { display: block; font-size: 12.5px; letter-spacing: 0.05em; text-transform: uppercase; color: color-mix(in srgb, var(--color-text) 55%, transparent); margin-top: 6px; }
/* F12: same edge-ramp treatment as .pricing — the band flows out of the page
   colour instead of hard-cutting. padding-block trimmed slightly: the ledger
   is max-width 880px inside a full-bleed slab, so extra padding at wide
   viewports read as a giant empty purple field. */
.honest-band {
  background:
    linear-gradient(to bottom, var(--color-bg), transparent 140px),
    linear-gradient(to top, var(--color-bg), transparent 140px),
    radial-gradient(1100px 500px at 20% 0%, color-mix(in srgb, var(--color-section-glow) 60%, transparent), transparent 62%),
    var(--color-section);
  padding-block: clamp(70px, 10vh, 110px);
}
.honest-band .sec-title { margin-bottom: 18px; }
.honest-band .intro { font-size: 15.5px; line-height: 1.75; color: color-mix(in srgb, var(--color-text) 78%, transparent); margin-bottom: 48px; max-width: 58ch; }
.ledger { max-width: 880px; }
.lrow {
  display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 18px; padding: 24px 0;
  background: linear-gradient(to right, transparent, var(--color-divider) 48px calc(100% - 48px), transparent) no-repeat top / 100% 1px;
}
.lrow:last-child {
  padding-bottom: 28px;
  background:
    linear-gradient(to right, transparent, var(--color-divider) 48px calc(100% - 48px), transparent) no-repeat top / 100% 1px,
    linear-gradient(to right, transparent, var(--color-divider) 48px calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
.lrow .num { font-size: 15px; color: var(--color-accent-300); font-feature-settings: 'tnum' 1; }
/* F17: the one paragraph style with no measure cap — the honest-list items
   ran to 95–105 characters per line at 1440. 60ch matches the reading
   measure everything else on the site holds. */
.lrow p { font-size: 15.5px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 82%, transparent); max-width: 60ch; }
/* Nocturne v3: the pull quote is the case study's editorial voice — serif,
   like the headings it punctuates. line-height stays generous (1.5) because
   it runs 3–4 lines at a reading size, not display size. */
.pull { font-family: var(--font-display); font-weight: 600; font-size: clamp(19px, 1.9vw, 25px); line-height: 1.5; color: var(--color-accent-300); margin-top: 48px; max-width: 44ch; }
/* F16: this heading is 54 characters — more than twice the copy the base
   16ch .contact-title cap was sized for ("Tell me about your place"). Its own
   wider cap settles it at 2 lines at desktop instead of 3–4. */
.yours .contact-title { max-width: 28ch; margin-bottom: 24px; }
.yours .contact-sub { max-width: 62ch; margin-bottom: 32px; }

/* Footer */
.site-foot { margin-top: 84px; padding-top: 28px; background: linear-gradient(to right, transparent, var(--color-divider) 48px calc(100% - 48px), transparent) no-repeat top / 100% 1px; display: flex; flex-wrap: wrap; gap: 14px 28px; align-items: center; justify-content: space-between; }
/* T17: the closing motif. The accent horizon that opened the crossing
   returns above the footer — same element, same scaleX entrance (main.js
   now .in-flags every .horizon) — and the page ends where it began. In
   flow (not absolute like the hero's), so it can never strike through
   text; it replaces the footer's grey hairline wholesale. */
.horizon-foot { position: static; margin-top: clamp(72px, 10vh, 110px); }
.horizon-foot + .site-foot { margin-top: 0; padding-top: 48px; background: none; }
/* T17: the brand sets the footer's final note — one step up in size and on
   the highlight tier, cradled by the contact section's dawn radial. */
.site-foot .brand-foot { font-family: var(--font-heading); font-weight: 600; font-size: 20px; color: var(--color-neutral-100); }
.site-foot p { font-size: 13px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.site-foot nav { display: flex; gap: 14px; }
/* F02 CRITICAL: was ~20px tall with no padding, so this had to clear 24px
   at minimum. padding-block + matching negative margin-block gets it to
   ~44px without moving the visible text or eating into the row's
   horizontal gap. (F06 later brought the header nav back below 700px too —
   this footer nav still needs its own ≥44px target on its own merits.) */
.site-foot nav a { padding-block: 12px; margin-block: -12px; font-size: 13px; color: color-mix(in srgb, var(--color-text) 65%, transparent); text-decoration: none; }

/* ── Motion (only when JS adds html.motion) ─────────────────────── */
html.motion .site-head { opacity: 0; transform: translateY(-16px); transition: opacity 1.1s var(--ease-out) 1.05s, transform 1.1s var(--ease-out) 1.05s, background-color 0.4s, box-shadow 0.4s; }
html.motion .site-head.in { opacity: 1; transform: none; }
html.motion .horizon { transform: scaleX(0); transition: transform 2.4s var(--ease-out) 0.4s; }
html.motion .horizon.in { transform: scaleX(1); }
html.motion .enter { opacity: 0; transform: translateY(34px); filter: blur(6px); transition: opacity 1.15s var(--ease-out), transform 1.15s var(--ease-out), filter 1.15s var(--ease-out); }
html.motion .enter.in { opacity: 1; transform: none; filter: none; }
/* T5: hero line-mask reveal — each headline line rises out of its own
   clipped box (a "line wipe") instead of sharing the generic fade+drift.
   The outer .line is the mask: overflow:clip, plus a padding-bottom /
   negative-margin pair that extends the clip box below the baseline so
   descenders (the g in "right", the j in "Siquijor") aren't shaved off
   mid-rise while the two rules cancel in layout. The inner span does the
   travel: 110% keeps it fully hidden until its beat. transition-delay:
   inherit picks up the stagger main.js writes onto the parent .enter, so
   the same data-enter choreography drives both reveal styles. The outer
   span opts out of .enter's fade (opacity/transform/filter neutralised) —
   without JS or with reduced motion none of this applies and the lines
   are simply visible. */
.hero-title .line { display: block; overflow: clip; padding-bottom: 0.15em; margin-bottom: -0.15em; }
.hero-title .line-inner { display: block; }
html.motion .hero-title .line { opacity: 1; transform: none; filter: none; }
html.motion .hero-title .line-inner { transform: translateY(110%); transition: transform 1.15s var(--ease-out); transition-delay: inherit; }
html.motion .hero-title .line.in .line-inner { transform: none; }
/* F23: the reveal used to blur(8px)→0 as well — blur rasterises on the CPU
   on cheap Androids (the exact audience) and added nothing over the
   opacity+translate, so .fx entrances are compositor-only now. The hero's
   one-time .enter blur above stays: five small elements, once, at load. */
html.motion .fx { opacity: 0; transform: translateY(56px); transition: opacity 1.05s var(--ease-out), transform 1.05s var(--ease-out); }
html.motion .fx.in { opacity: 1; transform: none; }
/* Zoom images get a compositor layer only when motion is on, and only via
   this declarative rule — no more permanent JS will-change on page load. */
html.motion img.zoom { will-change: transform; }
html.motion .fx .rule { transform: scaleX(0); transform-origin: left center; transition: transform 1.2s var(--ease-out) 0.35s; }
html.motion .fx.in .rule { transform: scaleX(1); }
html.motion .clipfx { clip-path: inset(12% 8% 12% 8%); transition: clip-path 1.5s var(--ease-out); }
html.motion .clipfx.in { clip-path: inset(0% 0% 0% 0%); }

@keyframes floaty { 0% { transform: translate(0, 0); } 50% { transform: translate(14px, -30px); } 100% { transform: translate(-12px, -10px); } }
@keyframes flicker { 0%, 100% { opacity: 0.15; } 42% { opacity: 0.95; } 58% { opacity: 0.4; } 74% { opacity: 0.85; } }
@keyframes twinkle { 0%, 100% { opacity: 0.1; } 50% { opacity: 0.6; } }
@keyframes meteor { 0% { transform: rotate(-24deg) translateX(0); opacity: 0; } 3% { opacity: 0.9; } 11% { transform: rotate(-24deg) translateX(-58vw); opacity: 0; } 100% { transform: rotate(-24deg) translateX(-58vw); opacity: 0; } }
@keyframes scrollhint { 0% { transform: scaleY(0); transform-origin: top; } 45% { transform: scaleY(1); transform-origin: top; } 55% { transform: scaleY(1); transform-origin: bottom; } 100% { transform: scaleY(0); transform-origin: bottom; } }
/* T16: opacity-only — never a transform, so the streaks cost the
   compositor one blended layer and nothing else. */
@keyframes shimmer { 0%, 100% { opacity: 0.05; } 50% { opacity: 0.38; } }

/* F26: js/main.js marks sky-hosting sections .asleep while they're
   off-screen so the ~100 infinite star/firefly animations stop burning
   battery for covered pixels. !important is required: the animations are
   set via the inline `animation` shorthand (CSSOM), and inline beats any
   stylesheet rule without it. */
.asleep .sky span { animation-play-state: paused !important; }

/* F03 belt 1: print/save-as-PDF never scrolls, so IntersectionObserver never
   reveals below-fold .fx content — force everything visible on paper.
   !important also overrides the JS-set inline opacity on .hero-copy. */
@media print {
  .fx, .clipfx, .enter, .hero-copy {
    opacity: 1 !important; transform: none !important;
    filter: none !important; clip-path: none !important;
  }
}

/* F25: hover styles only where a hover-capable fine pointer exists — on
   touch there is no hover, only the sticky post-tap flash these rules would
   otherwise cause. All are colour-only (no transforms), gathered here so the
   gate is one block. Each selector out-specifies its base rule, so position
   in the file doesn't matter. */
@media (hover: hover) and (pointer: fine) {
  a:hover { color: var(--color-accent-300); }
  .brand:hover { color: var(--color-text); }
  .site-head .nav-links a:hover { color: var(--color-text); }
  .btn-primary:hover { background: var(--color-accent-400); border-color: var(--color-accent-400); color: var(--color-bg); }
  .btn-secondary:hover { border-color: color-mix(in srgb, var(--color-text) 40%, transparent); background: color-mix(in srgb, var(--color-text) 6%, transparent); color: var(--color-text); }
  .btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); color: var(--color-accent-200); }
  a.channel:hover { background: var(--color-surface); color: var(--color-text); }
  .site-foot nav a:hover { color: var(--color-text); }

  /* T20: desktop reward layer. Everything below is EITHER colour-only, or
     transform-only behind html.motion so reduced-motion visitors get the
     colour response without the movement. .pkg cards get no hover at all —
     they contain no links, and a lifting card is a promise the click can't
     keep. */
  /* The biggest click target on the page (the proof shot) finally answers
     the cursor: ring and pedestal brighten; motion visitors get a gentle
     rise too. This is the one deliberate box-shadow transition — an
     interactive surface, 300ms, on demand (F50 bans them on passive
     surfaces, not here). */
  .work .shot { transition: box-shadow 0.3s; }
  .work .shot:hover {
    box-shadow: 0 0 0 1px #5d5294, 0 34px 90px -24px rgba(0, 0, 0, 0.75);
    box-shadow:
      0 0 0 1px var(--color-accent-700),
      0 34px 90px -24px rgba(0, 0, 0, 0.75),
      0 0 130px color-mix(in srgb, var(--color-accent-700) 30%, transparent);
  }
  html.motion .work .shot { transition: box-shadow 0.3s, transform 0.45s var(--ease-out); }
  html.motion .work .shot:hover { transform: scale(1.008); }
  /* Channel cards: 2px rise for motion visitors on top of the existing
     surface-brighten. */
  html.motion a.channel { transition: background-color 0.2s, transform 0.2s var(--ease-out); }
  html.motion a.channel:hover { transform: translateY(-2px); }
  /* Primary-button sheen: one light streak crossing the fill on hover-in.
     No transition at rest, so leaving the button resets the streak
     instantly off-stage and the next hover replays it. Compositor-only
     (transform), clipped by the button's own radius. */
  html.motion .btn-primary { position: relative; overflow: clip; }
  html.motion .btn-primary::after {
    content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 60%;
    background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.32) 50%, transparent);
    transform: translateX(-130%) skewX(-18deg);
  }
  html.motion .btn-primary:hover::after {
    transition: transform 0.55s var(--ease-out);
    transform: translateX(290%) skewX(-18deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ── Small screens ──────────────────────────────────────────────── */
@media (max-width: 860px) {
  .split-5-7, .built-grid, .about-grid, .contact-grid, .fields-2 { grid-template-columns: 1fr; }
  /* Bugfix (task 8 follow-up): a bare `1fr` track has an implicit `auto`
     (not `0`) minimum, so the track won't shrink below its content's
     min-content size. .built-grid's image column has no min-content risk,
     but this rule is shared by all five grids above and .built-grid was
     the one that actually blew out — its figure.frame's min-content width
     was wide enough to push the single column, and the whole document,
     past the viewport at ≤700px. minmax(0, 1fr) removes that floor so the
     column always shrinks to fit; scoped to .built-grid alone since it's
     the only one confirmed to need it. */
  .built-grid { grid-template-columns: minmax(0, 1fr); }
  .steps-grid { grid-template-columns: minmax(72px, auto) minmax(0, 1fr); gap: 20px; }
  .step { min-height: 26vh; }
  /* T7: caption is below-figure at every width now — this override only
     widens its padding to the page gutter, since .wrap-wide's own inset
     shrinks to ~8px at phone widths. */
  .stage-info { padding: 21px var(--pad-x) 0; }
  /* F14: at one column the empty-portrait vignette was the FIRST thing in
     About — the personal copy should lead, the placeholder follows. */
  .about-grid .portrait-slot { order: 1; }
}
@media (max-width: 700px) {
  /* F06: the nav used to be display:none below 700px — the primary viewport
     had no navigation at all except the footer, 9000px down. The header now
     wraps to two rows: brand + CTA on top, the three links full-width below.
     No JS, no hamburger, every word still on screen, every target ≥44px.
     The fixed header grows to ~106px, so scroll-padding grows with it (F18,
     F53). */
  html { scroll-padding-top: 118px; }
  .site-head { flex-wrap: wrap; gap: 6px 14px; padding-block: 10px; }
  .site-head .nav-links { order: 3; width: 100%; gap: 28px; }
  /* F52/F53: the base rule's padding-block:8px only gets these links to
     ~36.9px tall — under the 44px touch-target floor the footer nav (F02,
     above) and .brand already meet. Same trick, bigger padding — but the
     margin-block compensation can't fully cancel the padding like .brand's
     does: this row sits only 6px (the flex `gap` above) below the brand/CTA
     row, and getBoundingClientRect includes padding, so a margin that
     cancels the padding entirely lets the rendered (padded) box spill
     upward past that 6px gap and overlap the row above — measured ~44.9px
     tall links overlapping the brand/CTA row by several px at
     margin-block:-12px (F52's original value, reverted: it passed the
     height floor but failed the "no overlapping hit-boxes" check). -5px
     leaves a real ~0.8px clearance instead of chasing an exact cancel, so
     rounding can't tip it back into overlap; the header grows ~14px more
     than the -6px attempt, which scroll-padding-top above now accounts
     for. */
  .site-head .nav-links a { padding-block: 12px; margin-block: -5px; }
  /* F51: a persistent 10px backdrop blur is a per-scroll-frame tax on budget
     phones — swap it for a near-opaque fill; desktop keeps the glass. */
  .site-head.scrolled {
    background: color-mix(in srgb, var(--color-bg) 96%, transparent);
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
  /* F21 + F06: on phones the hero doesn't pin at all. The sticky crossing
     was mobile dead-scroll even with motion on, and the two-row header needs
     the hero to start below it — so the hero becomes a normal in-flow
     section: tall enough to breathe (min one viewport), padded past the
     ~92px fixed header, every pixel reachable by ordinary scrolling. The JS
     fade still runs — the copy now fades while scrolling away naturally.
     The selector list must repeat the html.motion/html:not(.motion) forms
     to out-specify the base track-height rules above. */
  .hero-track, .hero-track.short,
  html.motion .hero-track, html.motion .hero-track.short,
  html:not(.motion) .hero-track, html:not(.motion) .hero-track.short { height: auto; }
  /* Bugfix (task 8 follow-up): `position: relative`, not `static`. The
     JS-generated meteor (js/main.js) lives in `.sky` (position:absolute;
     inset:0) and needs `.hero` to be its containing block — same as desktop,
     where `.hero`'s `position: sticky` already does this job. `static`
     breaks that chain, so `.sky`'s absolutely-positioned children (meteor,
     fireflies) resolve against the initial containing block instead and the
     meteor's fixed 200px width silently pushes document.scrollWidth past
     the viewport below ~834px, even though nothing looks broken on screen
     (`.hero`'s `overflow: clip` still paints correctly — only the
     scrollable-overflow bookkeeping was wrong). `relative` has no visual
     effect here (no top/left offset is set) but restores the containing
     block, which fixes the 320px horizontal-scroll bug at its root instead
     of papering over it with overflow-x:hidden or clamping the meteor. */
  .hero {
    position: relative; height: auto;
    min-height: 100vh; min-height: 100svh;
    padding-top: 104px; padding-bottom: 72px;
  }
  /* F07: at phone widths the 21:10 letterbox shrank the one piece of proof
     to a 152px-tall sliver and cropped the bottom of the shot. 8:5 is the
     source screenshot's own ratio — nothing cropped by the container, and
     the work shot gains ~a third more height at 320. */
  .shot img { aspect-ratio: 8 / 5; }
  /* F38: hide the sticky numeral column (its 80px clamp floor dominated a
     320px screen) and show each step's own small inline number instead. */
  .steps-grid { grid-template-columns: 1fr; }
  .stepnum-col { display: none; }
  .step::before { display: block; }
}

/* ── Big desktop (≥1680px) ──────────────────────────────────────────
   QHD/ultrawide monitors run the site fullscreen: the 1240px wrap and
   the fixed-pixel hero glow are correct there but read as a small
   island of content in a large dark sea, and the copy sits optically
   low because the sea band owns the bottom quarter of the viewport.
   This block scales the COMPOSITION, not the layout: bigger display
   type, a viewport-proportional glow, and an optical lift for the hero
   copy. Nothing below 1680px changes. */
@media (min-width: 1680px) {
  .hero-title, .hero-track.short .hero-title { font-size: clamp(92px, 4.6vw, 122px); }
  .sub { font-size: 20px; }
  /* Optical centring: true centre + the 26%-tall sea underneath leaves
     a void above the headline; padding (not transform — main.js owns
     this element's transform for the scroll fade) lifts the block. */
  .hero-copy { padding-bottom: 10vh; }
  /* The moonlight pool scales with the viewport instead of staying a
     1000px blob lost in an ultrawide sky. */
  .heroglow { background: radial-gradient(52vw 46vh at 73% 30%, color-mix(in srgb, var(--color-accent-700) 42%, transparent), transparent 64%); }
  .sec-title { font-size: clamp(72px, 4.4vw, 88px); }
  .contact-title { font-size: clamp(64px, 3.8vw, 76px); }
  .feature h3 { font-size: clamp(40px, 2.2vw, 46px); }
}

/* ── Thanks page (thanks.html) ──────────────────────────────────────
   The form's success redirect — one viewport of the same night the
   visitor just left. The scene layers (.horizon/.sea/.heroglow/.sky)
   are the hero's own classes and js/main.js seeds the star/firefly
   hosts by the same selectors it uses on the homepage; without JS the
   hosts stay empty and the gradient + copy carry the page alone.
   Type reuses .hero-title/.sub/.cta wholesale so the page reads as
   the same site, not a utility screen. */
.thanks-hero {
  position: relative; overflow: clip;
  min-height: 100vh; min-height: 100svh;
  display: grid; grid-template-rows: 1fr auto; align-items: center;
  background: linear-gradient(178deg, var(--color-night-deep) 0%, var(--color-bg) 46%, var(--color-night-mid) 72%, var(--color-night-low) 100%);
}
.thanks-copy {
  position: relative; z-index: 2; width: 100%;
  max-width: 1240px; margin: 0 auto;
  /* Top padding clears the fixed header on short/landscape viewports where
     the 1fr row alone can't centre the copy free of it. */
  padding: 124px var(--pad-x) 56px;
}
.thanks-note { margin-top: 44px; font-size: 14.5px; line-height: 1.7; color: color-mix(in srgb, var(--color-text) 62%, transparent); max-width: 46ch; }
.thanks-note a { color: var(--color-accent-300); }
/* Footer sits inside the scene's second grid row, over the sea gradient —
   kill the homepage's 84px top margin, keep its own hairline rule. */
.thanks-foot { position: relative; z-index: 2; width: 100%; }
.thanks-foot .site-foot { margin-top: 0; padding-bottom: 26px; }
/* The horizon is anchored to the footer, not the viewport: a fixed 28px
   above it, so it can never strike through the note text the way the
   hero's bottom:26% line did at some viewport heights. Same element,
   same html.motion scaleX entrance — only the anchor changes. */
.thanks-foot .horizon { bottom: calc(100% + 28px); }
/* Give the risen firefly a stage: the homepage glow (80% mix at 74%/30%)
   is imperceptible over this page's flatter right half, so the thanks
   scene recentres a slightly stronger glow on the sent-fly's resting
   point (71%/33%) to counterweight the left-anchored copy. */
.thanks-hero .heroglow { background: radial-gradient(1000px 520px at 71% 33%, color-mix(in srgb, var(--color-accent-900) 95%, transparent), transparent 66%); }
/* One brighter firefly rising out of the swarm: the message that was just
   sent, joining the night. Compositor-only (transform/opacity), rises once
   and holds; completely still for reduced-motion/no-JS visitors. */
.sent-fly {
  position: absolute; z-index: 1; left: 71%; top: 33%;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--color-accent-300);
  /* Brightest point in the sky on purpose — it is the page's one
     storytelling element and must not blend into the seeded stars. */
  box-shadow: 0 0 30px 8px color-mix(in srgb, var(--color-accent) 70%, transparent);
  opacity: 0.9;
}
/* Rise starts at 2s — after the .enter stagger settles (~1.95s) — so the
   fly reads as its own beat, not part of the text reveal. 4.5s not 7s:
   with this ease ~80% of the travel happens early, and everything past
   ~4.5s was sub-pixel crawl. */
html.motion .sent-fly { animation: sentrise 4.5s var(--ease-out) 2s both; }
@keyframes sentrise {
  from { transform: translateY(16vh); opacity: 0; }
  40%  { opacity: 0.95; }
  to   { transform: translateY(0); opacity: 0.9; }
}
@media (max-width: 700px) {
  /* Same 104px the homepage hero uses below 700px — the desktop 124px was
     spending dead air on the viewport where the fold already crowds the
     note. Note margin tightens 44→32px for the same reason. */
  .thanks-copy { padding-top: 104px; }
  .thanks-note { margin-top: 32px; }
  /* At 320 the fly's halo sat directly behind the reply-time promise in
     .sub — park it in clear sky off the headline's ragged edge instead. */
  .sent-fly { left: 82%; top: 20%; }
}
