/* Portland Property Marketing
   Dark instrument. Near-black ground, precise grid, monospace numerals, photography
   carrying all the colour. Images run edge to edge with zero gutters.
   Palette from the logo files: navy #0e1937, cyan #1eb3db.

   Committed to a single dark look rather than shipping a light variant. Dark is the
   design here, not a toggle. */

/* Nebulica is COMMERCIAL (Azzam Ridhamalik / Zarma Type Foundry, 2024). Not on Google
   Fonts, needs a WEBFONT licence, which a desktop licence does not include.
   Drop the licensed file at assets/fonts/Nebulica-Black.woff2. Until then headings use
   Space Grotesk and nothing breaks. */
@font-face {
  font-family: "Nebulica";
  src: url("/assets/fonts/Nebulica-Black.woff2") format("woff2");
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

/* Registering --ppm-angle as a real <angle> is what makes it animatable. Unregistered
   custom properties cannot be interpolated. Firefox before 128 renders a static
   gradient border instead, which degrades cleanly. */
@property --ppm-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

:root {
  --bg: #070b16;
  --bg-1: #0d1426;
  --bg-2: #131c33;
  --rule: #1f2a45;
  --rule-2: #313f63;

  --ink: #f2f5fb;
  --ink-2: #9dabc6;
  /* Lifted from #6b7794, which measured 4.39:1 against the background. That fails
     WCAG AA for normal text, and this token carries small mono labels: section markers,
     captions, source lines, footer headings. #7885a3 measures 5.32:1. */
  --ink-3: #7885a3;

  --cyan: #35c5ec;
  --cyan-2: #1eb3db;

  /* Accent trio, sampled directly out of PPM_logo1.png rather than picked by eye.
     The logo's own values are cyan #18a8d8, pink #d85484, orange #d89000, violet
     #9054f0. Those were drawn for white, so each is lifted here to hold contrast
     against a near-black ground. */
  --acc-cyan: #35c5ec;
  --acc-pink: #f0709e;
  --acc-orange: #f5a623;
  --acc-violet: #a87cff;

  --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
  --font-display: "Nebulica", "Space Grotesk", var(--font-body);
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* Glow accent, brand-tuned. For the original magenta/cyan pair:
     --glow-1: #ff00ff; --glow-2: #00ffff; --glow-3: #ff00ff; */
  --glow-1: #1eb3db;
  --glow-2: #7c5cff;
  --glow-3: #ff4ecd;
  --glow-speed: 4s;

  --max: 1320px;
  --gap: clamp(1.15rem, 3.5vw, 2.5rem);

  /* Corner radius. Was 3px, now noticeably softer. "Additional 5%" reads as a
     proportional bump rather than a literal one, since 5% of 3px is invisible, so
     these are absolute values tuned to look right. Dial --r and --r-btn together. */
  --r: 8px;
  --r-btn: 10px;
  --r-card: 12px;
}

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: calc(17px * var(--copy-scale));
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img, video { max-width: 100%; height: auto; display: block; }
a { color: var(--cyan); text-underline-offset: 3px; }

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 900;
  line-height: 1.02;
  letter-spacing: -0.035em;
  margin: 0 0 .45em;
  text-wrap: balance;
}
h1 { font-size: clamp(2.6rem, 8vw, 5.6rem); }

/* Widow control, sitewide. `balance` evens heading line lengths; `pretty` lets the
   browser avoid a lone last word in body copy. Both are progressive enhancements:
   browsers without support render exactly as before. Added 2026-08-29 after J
   flagged a widow in body copy. */
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }
h2 { font-size: clamp(1.8rem, 4.4vw, 3rem); }
h3 { font-size: clamp(1rem, 1.7vw, 1.2rem); letter-spacing: -.02em; }
p { margin: 0 0 1em; }

.wrap { max-width: var(--max); margin-inline: auto; padding-inline: var(--gap); }
.wrap--full { max-width: none; padding-inline: 0; }
.section { padding-block: clamp(3.5rem, 9vw, 6.5rem); }
.section--tight { padding-block: clamp(2.5rem, 6vw, 4rem); }
.lede { font-size: calc(clamp(1.05rem, 1.7vw, 1.3rem) * var(--copy-scale)); color: var(--ink-2); max-width: 58ch; }

/* Secondary to the lede on purpose. The friction argument is what closes an agent who
   has already decided they want the photos, so it sits below the pitch and above the
   button, sized down and ruled off so it reads as a practical note rather than a second
   headline. */
.hero-note {
  position: relative;
  font-size: calc(clamp(.92rem, 1.15vw, 1rem) * var(--copy-scale)); color: var(--ink-3);
  max-width: 52ch; margin: 1.1rem 0 0;
  padding-left: .95rem;
}

/* The rule is a pseudo-element, not a border, because a border cannot carry a moving
   background. Deliberately NOT the conic @property technique used on the featured card:
   that one animates a registered custom property, which Firefox before 128 cannot do and
   degrades to a static gradient. A scrolling background-position works everywhere with no
   fallback branch, which is what a 2px line deserves.
   The tile is a fixed 200px rather than a percentage so the loop is seamless at any
   element height. The gradient opens and closes on the same cyan, and the keyframe travels
   exactly one tile, so the seam lands on identical colour. */
.hero-note::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 2px; border-radius: 1px;
  background-image: linear-gradient(180deg,
    var(--acc-cyan), var(--acc-violet), var(--acc-pink),
    var(--acc-orange), var(--acc-cyan));
  background-size: 100% 200px;
  background-repeat: repeat-y;
  animation: ppm-note-flow 7s linear infinite;
}
@keyframes ppm-note-flow {
  from { background-position: 0 0; }
  to   { background-position: 0 200px; }
}
/* Hold a static slice of the same palette rather than collapsing to one flat colour. */
@media (prefers-reduced-motion: reduce) {
  .hero-note::before { animation: none; background-position: 0 0; }
}

.marker {
  display: flex; align-items: baseline; gap: .9rem;
  font-family: var(--font-mono); font-size: .72rem; letter-spacing: .16em;
  text-transform: uppercase; color: var(--ink-3);
  margin: 0 0 1.6rem; padding-bottom: .7rem; border-bottom: 1px solid var(--rule);
}
.marker b { color: var(--cyan); font-weight: 500; }
.marker span { margin-left: auto; }

.skip { position: absolute; left: -9999px; background: var(--cyan); color: #04121b; padding: .8rem 1rem; z-index: 200; }
.skip:focus { left: 0; }
:where(a, button, summary, input, [tabindex]):focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }

/* ---------- header ---------- */
.site-header {
  position: sticky; top: 0; z-index: 100;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: saturate(160%) blur(12px);
  border-bottom: 1px solid var(--rule);
}
.site-header .wrap { display: flex; align-items: center; gap: 1rem; min-height: 64px; }
/* Lockup rule: the SM mark never appears alone. It is always set with the full name in
   the display face, all caps, pure white. The mark is a knockout, white PPM on a
   gradient block with a transparent surround, so it only reads on a dark ground. */
.brand { display: flex; align-items: center; gap: .6rem; text-decoration: none; }
.brand img { height: 30px; width: auto; }
.brand span {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: .82rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: #fff;
  line-height: 1;
}
/* The lockup rule is absolute, so the name is never dropped on small screens. It wraps
   and shrinks instead. Three short lines beside a 26px mark still fits a 375px header
   alongside the booking button. */
@media (max-width: 560px) {
  .brand img { height: 26px; }
  .brand span { font-size: .6rem; letter-spacing: .04em; line-height: 1.25; max-width: 8.5em; }
}
.nav { margin-left: auto; display: flex; align-items: center; gap: clamp(.5rem, 1.8vw, 1.7rem); }
/* :not(.btn) matters. Without it this rule (specificity 0,1,1) beats .btn--primary
   (0,1,0) and repaints the booking button's label #9dabc6 on a #35c5ec background,
   which is a 1.13:1 contrast ratio. Effectively invisible, and it shipped that way. */
.nav a:not(.btn) {
  color: var(--ink-2); text-decoration: none; white-space: nowrap;
  font-family: var(--font-mono); font-size: .74rem; letter-spacing: .1em; text-transform: uppercase;
}
.nav a:not(.btn):hover, .nav a:not(.btn)[aria-current="page"] { color: var(--ink); }
/* ---------- mobile nav ----------
   Until 2026-08-27 the links were simply `display: none` below the breakpoint and there
   was no hamburger, so five of the six destinations on the site were unreachable from a
   phone. Nothing linked them; you had to already know the URL. That is the bug this
   replaces, and it had been shipping since launch.

   The booking button now sits OUTSIDE <nav>, so collapsing the link list never hides the
   primary CTA. The toggle is a real <button> with aria-expanded, not a checkbox hack, so
   screen readers announce state. */
.nav-toggle { display: none; }
/* NOT margin-left:auto on desktop. `.nav` already carries the auto margin, and a second
   auto margin in the same flex row splits the free space between the two instead of
   pushing both right, which strands the nav in the middle of the header. */
.nav-book { margin-left: 0; white-space: nowrap; }

/* Header CTA on a phone. Two problems, one cause: the label is too wide for the space
   left over after the brand lockup and the hamburger, so it wrapped to two lines and the
   button became a double-height slab dominating the header. Below 560px the trailing
   words are dropped to leave "Book", the padding tightens, and nowrap above guarantees a
   single line. The <a> keeps aria-label="Book Your Shoot", so the accessible name is
   unchanged. 44px min-height is retained: it is the touch target floor, not decoration. */
@media (max-width: 560px) {
  .nav-book__rest { display: none; }
  .nav-book {
    padding: .5rem 1.05rem; font-size: .72rem; letter-spacing: .09em;
  }
  .site-header .wrap { gap: .55rem; }
}

@media (max-width: 980px) {
  /* Here the auto margin is required, because .nav is absolutely positioned and out of
     flow, so nothing else is left to push the button to the right edge. */
  .nav-book { margin-left: auto; }
  .nav-toggle {
    display: inline-flex; flex-direction: column; justify-content: center; gap: 5px;
    width: 44px; height: 44px; padding: 0 10px;   /* 44px is the Apple HIG touch minimum */
    background: none; border: 0; cursor: pointer;
  }
  .nav-toggle span {
    display: block; height: 2px; width: 100%; background: var(--ink);
    border-radius: 2px; transition: transform .22s ease, opacity .18s ease;
  }
  /* Bars fold into an X so the control reads as "close" once the panel is open. */
  .nav-toggle.is-open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
  .nav-toggle.is-open span:nth-child(2) { opacity: 0; }
  .nav-toggle.is-open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

  .nav {
    display: none;
    position: absolute; top: 100%; left: 0; right: 0;
    flex-direction: column; align-items: stretch; gap: 0;
    padding: .4rem var(--gap) 1rem;
    background: var(--bg);
    border-bottom: 1px solid var(--rule);
  }
  .nav.is-open { display: flex; }
  .nav a:not(.btn) {
    padding: .95rem 0; font-size: .82rem;
    border-bottom: 1px solid var(--rule);
  }
  .nav a:not(.btn):last-child { border-bottom: 0; }
}
/* No `position: relative` needed on .site-header for the panel above: it is already
   `position: sticky`, which makes it a positioned ancestor, and .wrap between them is
   static. Do not "fix" .wrap by giving it position, or the panel will anchor to the
   padded inner column instead of the full-bleed header. */

/* ---------- buttons ---------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
  padding: .72rem 1.35rem; border-radius: var(--r-btn); border: 1px solid transparent;
  font-family: var(--font-mono); font-size: .76rem; font-weight: 500;
  letter-spacing: .11em; text-transform: uppercase; text-decoration: none;
  cursor: pointer; min-height: 44px; transition: background-color .14s, color .14s, border-color .14s;
}
.btn--primary { background: var(--cyan); color: #04121b; }
.btn--primary:hover { background: #fff; }
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--rule-2); }
.btn--ghost:hover { border-color: var(--cyan); color: var(--cyan); }
.btn--lg { padding: 1rem 1.9rem; font-size: .8rem; }
.btn-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 2rem; }

/* ---------- hero ----------
   Type never sits on top of the video. The footage is bright, mostly white ceilings and
   blown-out windows, so white headline text over it is unreadable and a dark scrim would
   muddy footage that was paid for. The video is a framed element; the words live beside
   it. That also means the poster frame carries no text and cannot fail. */
.hero { padding-block: clamp(2.5rem, 6vw, 4.5rem) clamp(1.5rem, 4vw, 3rem); }
.hero h1 { max-width: 14ch; margin-bottom: .3em; }
.hero-meta {
  font-family: var(--font-mono); font-size: .72rem; letter-spacing: .16em;
  text-transform: uppercase; color: var(--acc-cyan); margin: 0 0 1.4rem;
}
.hero-video {
  position: relative; margin-top: clamp(2rem, 5vw, 3.5rem);
  border: 1px solid var(--rule); border-radius: var(--r); overflow: hidden;
  background: var(--bg-1);
}

/* Phone: the reel has to be visible without scrolling.
   Budget on an iPhone 11, 414x896 CSS px with roughly 660px usable once Safari's chrome
   is deducted, and on the tighter 375px-wide 11 Pro:
     header 64 + hero padding 20 + eyebrow 32 + h1 three lines 103 + video 220 = 439px.
   The lede and the buttons move below the video, which costs nothing because anyone
   still reading has already scrolled. */
@media (max-width: 720px) {
  .hero { padding-block: 1.25rem 1.5rem; }
  .hero .wrap { display: flex; flex-direction: column; }
  /* `display: contents` dissolves the desktop grid column so its children order
     themselves against .hero__art directly. Without it the whole copy block would sort
     as one unit and the panel would land below the buttons, off the phone's first
     screen, which is the exact placement this ordering exists to prevent. */
  .hero--split .hero__copy { display: contents; }
  .hero__art { order: 3; margin-top: .9rem; }
  .steps     { order: 4; margin-top: 1.3rem; }
  /* One line, always. The three regions read as a single stripe or not at all, so the
     size tracks the viewport and the string is barred from wrapping. At 320px the
     36-character run lands near 195px against roughly 280px of usable width. */
  .hero-meta   { order: 1; margin-bottom: .85rem;
                 font-size: clamp(.52rem, 2.35vw, .66rem);
                 letter-spacing: .12em; white-space: nowrap; }
  .hero h1     { order: 2; max-width: none; font-size: clamp(1.95rem, 8.4vw, 2.6rem); margin-bottom: .55rem; }
  .hero-video  { order: 3; margin-top: .35rem; }
  .hero-cap    { order: 4; }
  .hero .lede  { order: 5; margin-top: 1.4rem; font-size: calc(1rem * var(--copy-scale)); }
  /* Without an explicit order this would default to 0 and jump above the eyebrow. */
  .hero-note   { order: 6; margin-top: 1rem; font-size: calc(.92rem * var(--copy-scale)); }
  .hero .btn-row { order: 7; margin-top: 1.1rem; }
  .hero .btn { flex: 1 1 100%; }
}
.hero-video video { width: 100%; display: block; }

/* ---------- split hero ----------
   The differentiator owns the fold. Until 2026-08-29 the hero's only visual was the
   sample reel, which sells "we shoot nice video" -- the one claim every competitor on
   the comp sheet can also make. The Launch Kit is what none of them can, so the panel
   sits beside the headline and the reel moved to its own band directly below, where it
   works as proof of craft rather than as the pitch. */
.hero__copy { min-width: 0; }
.hero__art  { min-width: 0; }
.hero-panel {
  border: 1px solid var(--rule); border-radius: var(--r);
  background: var(--bg-1); padding: clamp(1rem, 2.2vw, 1.7rem);
}
.hero-panel svg { display: block; width: 100%; height: auto; }
/* Panel footer, not a caption hanging off the left edge. The two labels sit at opposite
   ends of a ruled line so the block reads as designed at full width, and centre-stack
   once they no longer fit side by side. */
.hero-panel .hero-cap {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: .35rem 1.2rem; margin-top: 1.1rem;
  padding-top: .9rem; border-top: 1px solid var(--rule);
}
.hero-panel .hero-cap span:first-child { color: var(--ink-2); }
@media (max-width: 620px) {
  .hero-panel .hero-cap {
    flex-direction: column; align-items: center; text-align: center; gap: .3rem;
  }
}

@media (min-width: 901px) {
  .hero--split .wrap {
    display: grid; grid-template-columns: 1.04fr .96fr;
    gap: clamp(2rem, 4vw, 4rem); align-items: center;
  }
  /* 14ch is right for a headline spanning the full column. In half the width it forces
     a ragged four-line stack, so the column governs the measure here instead. */
  .hero--split h1 { max-width: 17ch; }
}

/* Book, shoot, post. The end-to-end promise stated as a sequence rather than buried in
   a paragraph, because "frictionless" is a claim and three numbered steps are evidence. */
.steps {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;
  list-style: none; margin: 1.7rem 0 0; padding: 1.2rem 0 0;
  border-top: 1px solid var(--rule);
}
.steps li { margin: 0; }
.steps b {
  display: block; font-family: var(--font-mono); font-size: clamp(1.05rem, 2.5vw, 1.3rem);
  letter-spacing: .12em; margin-bottom: .45rem; line-height: 1;
}
/* One hue per step, drawn from the logo palette the section markers already use, so the
   sequence reads as three distinct moments rather than one repeated label. */
.steps li:nth-child(1) b { color: var(--acc-cyan); }
.steps li:nth-child(2) b { color: var(--acc-violet); }
.steps li:nth-child(3) b { color: var(--acc-pink); }
.steps span {
  display: block; color: var(--ink); line-height: 1.35;
  font-size: calc(clamp(1rem, 1.35vw, 1.12rem) * var(--copy-scale));
}
@media (max-width: 460px) {
  .steps { grid-template-columns: 1fr; gap: .7rem; padding-top: 1rem; }
  .steps li { display: flex; gap: .8rem; align-items: baseline; }
  .steps b { margin-bottom: 0; }
}

/* ---------- Launch Kit diagram animation ----------
   Rebuilt 2026-09-28 (J), revised the same day for the web-only v1 flow with guided
   "post everywhere" mode. One 12s loop: book, shoot, the Kit lands, pick your
   platforms, then tap, paste, post through each chosen platform in turn. Every
   animated element runs on the same loop length, so they stay in step forever;
   staggered items use animation-delay, which shifts an element's whole timeline once
   and never drifts. A scene group (.ks-*, .kit-fan) owns when a beat is on screen, so
   staggered children never leak into another beat. Pure CSS on inline SVG.
   Percentages are of the 12s loop: 1% = 0.12s. */
.kit-anim { --kit-loop: 12s; }
.kit-anim .ks, .kit-anim .kl, .kit-anim .kit-fan, .kit-anim .kit-shot,
.kit-anim .kit-flash, .kit-anim .kit-type, .kit-anim .kit-ptype, .kit-anim .kit-line,
.kit-anim .kit-pill, .kit-anim .kit-sel, .kit-anim .kit-send, .kit-anim .kit-done,
.kit-anim .kit-cursor, .kit-anim .kit-press, .kit-anim .kit-crop, .kit-anim .kit-bt,
.kit-anim .kit-copy-a, .kit-anim .kit-copy-b {
  animation-duration: var(--kit-loop);
  animation-iteration-count: infinite;
  animation-fill-mode: both;
  animation-timing-function: ease-in-out;
}
.kit-anim .kl-static, .kit-anim .kit-lit { opacity: 0; }

/* Scene windows */
.ks-book { animation-name: kit-sc-book; }
.ks-kit  { animation-name: kit-sc-kit; }
.ks-post { animation-name: kit-sc-post; }
.kit-fan { animation-name: kit-sc-fan; }
@keyframes kit-sc-book { 0% { opacity: 0; } 1.5%, 8.5% { opacity: 1; } 10%, 100% { opacity: 0; } }
@keyframes kit-sc-kit  { 0%, 9% { opacity: 0; } 10%, 47% { opacity: 1; } 49%, 100% { opacity: 0; } }
@keyframes kit-sc-post { 0%, 48% { opacity: 0; } 50%, 91% { opacity: 1; } 94%, 100% { opacity: 0; } }
@keyframes kit-sc-fan  { 0%, 25% { opacity: 0; } 26%, 92% { opacity: 1; } 95%, 100% { opacity: 0; } }

/* The card's top label names each beat */
.kl-1 { animation-name: kit-kl-1; }
.kl-2 { animation-name: kit-kl-2; }
.kl-3 { animation-name: kit-kl-3; }
.kl-4 { animation-name: kit-kl-4; }
.kl-5 { animation-name: kit-kl-5; }
@keyframes kit-kl-1 { 0% { opacity: 0; } 1.5%, 8.5% { opacity: 1; } 10%, 100% { opacity: 0; } }
@keyframes kit-kl-2 { 0%, 9.5% { opacity: 0; } 11%, 23.5% { opacity: 1; } 25%, 100% { opacity: 0; } }
@keyframes kit-kl-3 { 0%, 24.5% { opacity: 0; } 26%, 35.5% { opacity: 1; } 37%, 100% { opacity: 0; } }
@keyframes kit-kl-4 { 0%, 36.5% { opacity: 0; } 38%, 47.5% { opacity: 1; } 49%, 100% { opacity: 0; } }
@keyframes kit-kl-5 { 0%, 48.5% { opacity: 0; } 50%, 91% { opacity: 1; } 94%, 100% { opacity: 0; } }

/* Buttons dip when the cursor taps them. Post is tapped once per chosen platform. */
.kit-press { transform-box: fill-box; transform-origin: center; }
.kit-press--book { animation-name: kit-press-book; }
.kit-press--post { animation-name: kit-press-post; }
@keyframes kit-press-book { 0%, 6.5% { transform: none; } 7.3% { transform: scale(.92); } 8.2%, 100% { transform: none; } }
@keyframes kit-press-post {
  0%, 55.4% { transform: none; } 56% { transform: scale(.95); } 56.8% { transform: none; }
  66.9% { transform: none; } 67.5% { transform: scale(.95); } 68.3% { transform: none; }
  78.4% { transform: none; } 79% { transform: scale(.95); } 79.8%, 100% { transform: none; }
}

/* 02 Shoot: one frame at a time, each with a shutter flash */
.kit-shot  { animation-name: kit-shot;  animation-delay: calc(var(--i) * .3s); }
.kit-flash { animation-name: kit-flash; animation-delay: calc(var(--i) * .3s); opacity: 0; }
@keyframes kit-shot  { 0%, 11% { opacity: 0; } 11.6%, 99.5% { opacity: 1; } 100% { opacity: 0; } }
@keyframes kit-flash { 0%, 11% { opacity: 0; } 11.4% { opacity: .9; } 13.6%, 100% { opacity: 0; } }

/* 03 The Kit lands: captions and link write in, the fan reaches every platform */
.kit-type, .kit-ptype { transform-box: fill-box; transform-origin: 0 50%; }
.kit-type  { animation-name: kit-type;  animation-delay: calc(var(--i) * .12s); }
.kit-ptype { animation-name: kit-ptype; animation-delay: calc(var(--i) * .12s); }
@keyframes kit-type  { 0%, 25.5% { opacity: 0; transform: scaleX(0); } 28.5%, 99.5% { opacity: 1; transform: none; } 100% { opacity: 0; } }
@keyframes kit-ptype { 0%, 50.5% { opacity: 0; transform: scaleX(0); } 53.5%, 99.5% { opacity: 1; transform: none; } 100% { opacity: 0; } }

.kit-line { stroke-dasharray: 100; animation-name: kit-line; animation-delay: calc(var(--i) * .1s); }
@keyframes kit-line { 0%, 26% { stroke-dashoffset: 100; } 30%, 99.5% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: 100; } }
.kit-pill { animation-name: kit-pill; animation-delay: calc(var(--i) * .1s); }
@keyframes kit-pill { 0%, 27% { opacity: 0; transform: translateX(9px); } 30.5%, 99.5% { opacity: 1; transform: none; } 100% { opacity: 0; } }

/* 04 Pick: each platform the cursor ticks lights and stays lit. --s is its order. */
.kit-sel { animation-name: kit-sel; animation-delay: calc(var(--s) * .36s); }
@keyframes kit-sel { 0%, 40.6% { opacity: 0; } 41.2%, 99.5% { opacity: 1; } 100% { opacity: 0; } }

/* 05 Post: after each tap the post travels out and a check lands on that platform.
   One platform every 1.38s (11.5% of the loop). */
.kit-send { stroke-dasharray: 9 91; stroke-width: 2; stroke-linecap: round; opacity: 0;
            animation-name: kit-send; animation-delay: calc(var(--s) * 1.38s); }
@keyframes kit-send { 0%, 57% { opacity: 0; stroke-dashoffset: 0; }
                      57.5% { opacity: 1; stroke-dashoffset: 0; }
                      62% { opacity: 1; stroke-dashoffset: -91; }
                      62.5%, 100% { opacity: 0; stroke-dashoffset: -91; } }
.kit-done { transform-box: fill-box; transform-origin: center;
            animation-name: kit-done; animation-delay: calc(var(--s) * 1.38s); }
@keyframes kit-done { 0%, 62% { opacity: 0; transform: scale(.3); }
                      63.2% { opacity: 1; transform: scale(1.15); }
                      64%, 99.5% { opacity: 1; transform: none; } 100% { opacity: 0; } }

/* The frame is re-cut to the platform's size as the post card opens */
.kit-crop { transform-box: fill-box; transform-origin: 50% 0; animation-name: kit-crop; }
@keyframes kit-crop { 0%, 50% { transform: scaleY(.8); } 54%, 100% { transform: none; } }

/* After the first tap the note under the frame says the caption is copied */
.kit-copy-a { animation-name: kit-copy-a; }
.kit-copy-b { animation-name: kit-copy-b; }
@keyframes kit-copy-a { 0%, 56% { opacity: 1; } 56.8%, 100% { opacity: 0; } }
@keyframes kit-copy-b { 0%, 56% { opacity: 0; } 56.8%, 86.5% { opacity: 1; } 87.5%, 100% { opacity: 0; } }

/* Button label and step counter: platform 1, 2, 3, then done */
.kit-bt-1 { animation-name: kit-bt-1; }
.kit-bt-2 { animation-name: kit-bt-2; }
.kit-bt-3 { animation-name: kit-bt-3; }
.kit-bt-4 { animation-name: kit-bt-4; }
@keyframes kit-bt-1 { 0%, 64% { opacity: 1; } 65%, 100% { opacity: 0; } }
@keyframes kit-bt-2 { 0%, 64% { opacity: 0; } 65%, 75.5% { opacity: 1; } 76.5%, 100% { opacity: 0; } }
@keyframes kit-bt-3 { 0%, 75.5% { opacity: 0; } 76.5%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
@keyframes kit-bt-4 { 0%, 87% { opacity: 0; } 88%, 100% { opacity: 1; } }

/* The cursor. --y1..--y3 are the chosen pills' centres, written onto the svg by build.py */
.kit-cursor { animation-name: kit-cursor; }
@keyframes kit-cursor {
  0%        { opacity: 0; transform: translate(236px, 262px); }
  2%        { opacity: 1; transform: translate(236px, 262px); }
  6.2%      { opacity: 1; transform: translate(140px, 153px); }
  7.3%      { transform: translate(140px, 153px) scale(.82); }
  8.2%      { opacity: 1; transform: translate(140px, 153px); }
  10%       { opacity: 0; transform: translate(140px, 153px); }
  36%       { opacity: 0; transform: translate(238px, 210px); }
  38%       { opacity: 1; transform: translate(238px, 210px); }
  40.2%     { transform: translate(362px, calc(var(--y1) * 1px)); }
  40.8%     { transform: translate(362px, calc(var(--y1) * 1px)) scale(.82); }
  41.4%     { transform: translate(362px, calc(var(--y1) * 1px)); }
  43.2%     { transform: translate(362px, calc(var(--y2) * 1px)); }
  43.8%     { transform: translate(362px, calc(var(--y2) * 1px)) scale(.82); }
  44.4%     { transform: translate(362px, calc(var(--y2) * 1px)); }
  46.2%     { transform: translate(362px, calc(var(--y3) * 1px)); }
  46.8%     { transform: translate(362px, calc(var(--y3) * 1px)) scale(.82); }
  47.4%, 50% { opacity: 1; transform: translate(362px, calc(var(--y3) * 1px)); }
  55%       { transform: translate(150px, 250px); }
  56%       { transform: translate(150px, 250px) scale(.82); }
  56.8%     { transform: translate(150px, 250px); }
  67.5%     { transform: translate(150px, 250px) scale(.82); }
  68.3%     { transform: translate(150px, 250px); }
  79%       { transform: translate(150px, 250px) scale(.82); }
  79.8%     { transform: translate(150px, 250px); }
  84%       { opacity: 1; transform: translate(150px, 250px); }
  86%, 100% { opacity: 0; transform: translate(150px, 250px); }
}

/* Reduced motion: no loop at all. Show the landed Kit as a still diagram, the way it
   looked before the animation, with the last destination highlighted. */
@media (prefers-reduced-motion: reduce) {
  .kit-anim * { animation: none !important; }
  .kit-anim .ks-book, .kit-anim .ks-post, .kit-anim .kl, .kit-anim .kit-cursor,
  .kit-anim .kit-flash, .kit-anim .kit-send, .kit-anim .kit-done, .kit-anim .kit-lit { opacity: 0; }
  .kit-anim .kl-static, .kit-anim .ks-kit, .kit-anim .kit-fan, .kit-anim .kit-shot,
  .kit-anim .kit-type, .kit-anim .kit-pill, .kit-anim .kit-lit--last { opacity: 1; transform: none; }
  .kit-anim .kit-line { stroke-dashoffset: 0; }
}
.hero-cap {
  display: flex; flex-wrap: wrap; gap: .5rem 1.2rem;
  font-family: var(--font-mono); font-size: .7rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-3); margin-top: .8rem;
}

/* ---------- zero-gutter mosaic ----------
   No gaps. The wall of work is the design. Uniform cells with object-fit: cover keep the
   grid honest across mixed source ratios, which matters because two of the frames are
   portrait and the rest are landscape. */
.mosaic {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(clamp(150px, 24vw, 320px), 1fr));
  gap: 0;
  background: var(--bg-1);
}
.mosaic figure { margin: 0; position: relative; aspect-ratio: 3 / 2; overflow: hidden; }

/* Wide tiles, set in arrange.py. Two cells across at one cell tall, so 3:1 rather than
   3:2 keeps the row height identical and the zero-gutter grid stays flush. In a uniform
   wall every frame carries equal weight, which flattens the best work; a handful of
   doubles gives the page rhythm and signals which listings matter. */
.mosaic figure.is-wide { grid-column: span 2; aspect-ratio: 3 / 1; }

/* Below two columns a span-2 tile would force an implicit third column and break the
   flush edge, so it reverts to a normal cell. */
@media (max-width: 460px) {
  .mosaic figure.is-wide { grid-column: span 1; aspect-ratio: 3 / 2; }
}
.mosaic img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s cubic-bezier(.2,.6,.2,1); }
.mosaic figure:hover img, .mosaic figure:focus-within img { transform: scale(1.04); }
.mosaic figcaption {
  position: absolute; inset: auto 0 0 0; padding: .75rem .85rem;
  font-family: var(--font-mono); font-size: .64rem; letter-spacing: .14em;
  text-transform: uppercase; color: #fff;
  background: linear-gradient(to top, rgba(4,8,18,.85), rgba(4,8,18,0));
  opacity: 0; transition: opacity .22s;
}
.mosaic figure:hover figcaption, .mosaic figure:focus-within figcaption { opacity: 1; }

/* Touch devices have no hover, so the label would never appear. Show it always. */
@media (hover: none) {
  .mosaic figcaption { opacity: 1; }
  .mosaic figure:hover img { transform: none; }
}

/* ---------- lightbox ----------
   Tapping a frame opens it full-bleed. Built on <dialog> so the browser supplies the
   top layer, the backdrop, Escape-to-close, and focus containment rather than any of it
   being hand-rolled. */
/* Standard pointer, not zoom-in. A magnifying-glass cursor is an instruction, and a
   photograph in a grid does not need one. */
.mosaic button.tile-btn {
  all: unset; display: block; width: 100%; height: 100%; cursor: pointer;
}
.mosaic button.tile-btn:focus-visible { outline: 3px solid var(--acc-cyan); outline-offset: -3px; }

dialog.lb {
  padding: 0; border: 0; background: transparent; max-width: 100vw; max-height: 100dvh;
  width: 100vw; height: 100dvh; overflow: hidden;
}
dialog.lb::backdrop { background: rgba(3, 6, 14, .94); }
.lb-img {
  width: 100vw; height: 100dvh; object-fit: contain; display: block;
  background: transparent;
}
.lb-close, .lb-nav {
  position: fixed; z-index: 2; display: grid; place-content: center;
  width: 48px; height: 48px; border-radius: 999px; cursor: pointer;
  background: rgba(8, 13, 28, .72); color: #fff; border: 1px solid rgba(255,255,255,.22);
  font-family: var(--font-mono); font-size: 1.05rem; line-height: 1;
  -webkit-tap-highlight-color: transparent;
}
.lb-close { top: max(14px, env(safe-area-inset-top)); right: 14px; }
.lb-nav.prev { left: 14px; top: 50%; transform: translateY(-50%); }
.lb-nav.next { right: 14px; top: 50%; transform: translateY(-50%); }
.lb-close:hover, .lb-nav:hover { background: var(--acc-cyan); color: #04121b; border-color: var(--acc-cyan); }
.lb-meta {
  position: fixed; left: 0; right: 0; bottom: max(14px, env(safe-area-inset-bottom));
  text-align: center; pointer-events: none;
  font-family: var(--font-mono); font-size: .66rem; letter-spacing: .14em;
  text-transform: uppercase; color: #cfd8ea;
}
/* Touch targets need room on a phone, and the arrows crowd a narrow screen, so they
   move to the bottom corners where a thumb actually reaches. */
@media (max-width: 560px) {
  .lb-nav.prev { left: 12px; top: auto; bottom: 58px; transform: none; }
  .lb-nav.next { right: 12px; top: auto; bottom: 58px; transform: none; }
}
body.lb-open { overflow: hidden; }

.filters { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1.6rem; }
.filters a {
  font-family: var(--font-mono); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase;
  padding: .5rem 1rem; border: 1px solid var(--rule-2); border-radius: 99px;
  color: var(--ink-2); text-decoration: none; min-height: 38px; display: inline-flex; align-items: center;
}
.filters a:hover { border-color: var(--cyan); color: var(--cyan); }
.filters a[aria-current="page"] { background: var(--cyan); border-color: var(--cyan); color: #04121b; }

/* ---------- figures ---------- */
.figures { display: grid; grid-template-columns: 1fr; border-top: 1px solid var(--rule); }
@media (min-width: 700px) { .figures { grid-template-columns: repeat(3, 1fr); } }
.figure { padding: 1.7rem 0; border-bottom: 1px solid var(--rule); }
@media (min-width: 700px) {
  .figure { padding: 1.9rem clamp(1rem, 2vw, 1.9rem); border-right: 1px solid var(--rule); border-bottom: 0; }
  .figure:first-child { padding-left: 0; }
  .figure:last-child { border-right: 0; padding-right: 0; }
}
.figure b {
  display: block; font-family: var(--font-mono); font-weight: 500;
  font-size: clamp(2.2rem, 4.6vw, 3.1rem); line-height: 1; letter-spacing: -.045em;
  color: var(--cyan); margin-bottom: .5rem;
}
.figure span { font-size: calc(.93rem * var(--copy-scale)); color: var(--ink-2); }

/* Phone: three across, not stacked.
   Stacked, the three figures ran about 330px tall and pushed the Zillow product
   screenshots well below the fold on a 375x812 handset. Side by side the row costs only
   the height of the tallest label, near 150px, and the numerals get BIGGER rather than
   smaller, because the constraint was never horizontal.
   Width check at the tightest case, 320px: gutters take 37px, so each column is 94px. The
   longest value is three characters at clamp floor 2.1rem, which in IBM Plex Mono at
   0.6em advance measures near 60px. Comfortable.
   Separators rotate from horizontal to vertical, so .figures carries the closing rule
   that the last .figure used to provide. */
@media (max-width: 699px) {
  .figures { grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--rule); }
  .figure {
    padding: 1.25rem .55rem 1.35rem 0;
    border-bottom: 0; border-right: 1px solid var(--rule);
  }
  .figure:last-child { border-right: 0; padding-right: 0; }
  .figure b { font-size: clamp(2.1rem, 10.5vw, 3rem); margin-bottom: .3rem; }
  .figure span {
    display: block; font-size: calc(.82rem * var(--copy-scale));
    line-height: 1.3; text-wrap: balance;
  }
}
.source { font-family: var(--font-mono); font-size: .69rem; line-height: 1.7; color: var(--ink-3); max-width: 80ch; margin-top: 1.5rem; }

/* A long multi-sentence attribution at 80ch of .69rem mono stacks into a tall narrow
   column hugging the left edge, which reads as broken rather than as fine print. Where
   the source sits under a full-width figures row it should span the same measure. */
.source--full { max-width: none; }

/* ---------- product screenshots ----------
   Zillow's product visuals are light-ground marketing assets and this site is a dark
   instrument. Dropping them in raw reads as two blown-out rectangles, so each sits on
   its own light panel with real padding. That frames them as screenshots of somewhere
   else, which is what they are, instead of failed page furniture. */
.shots { display: grid; gap: 1.25rem; grid-template-columns: 1fr; margin-top: 1.5rem; }
@media (min-width: 800px) { .shots { grid-template-columns: 1fr 1fr; gap: 1.5rem; } }
.shot { margin: 0; }
.shot img {
  display: block; width: 100%; height: auto;
  background: #fff;
  border: 1px solid var(--rule-2);
  border-radius: 10px;
  padding: clamp(.6rem, 1.4vw, 1.1rem);
}
.shot figcaption {
  margin-top: .85rem; font-size: calc(.93rem * var(--copy-scale)); line-height: 1.6; color: var(--ink-2);
  max-width: 46ch;
}

/* ---------- cards ---------- */
.cards { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 760px) { .cards { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1060px) { .cards--3 { grid-template-columns: repeat(3, 1fr); } }

/* 2px, not 1px, because the glow border below is 2px and a width change on hover would
   shrink the padding box and jog every line of text inside the card by a pixel. */
.card { background: var(--bg-1); border: 2px solid var(--rule); border-radius: var(--r-card); padding: 1.6rem; display: flex; flex-direction: column; transition: box-shadow .25s ease, border-color .25s ease, transform .25s ease; }
.card .badge {
  align-self: flex-start; background: var(--acc-cyan); color: #04121b;
  font-family: var(--font-mono); font-size: .62rem; font-weight: 500;
  letter-spacing: .14em; text-transform: uppercase; padding: .3rem .55rem;
  border-radius: var(--r); margin-bottom: .9rem;
}
.card h3 { margin-bottom: .1em; }
.card .tagline { font-family: var(--font-mono); font-size: .68rem; letter-spacing: .11em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 1.1rem; }
.card .price { font-family: var(--font-mono); font-weight: 500; font-size: 2.1rem; letter-spacing: -.035em; line-height: 1; margin: 0 0 .3rem; color: var(--ink); }
.card .price small { font-size: .64rem; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); }
.card .price-note { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 1.2rem; padding-bottom: 1.2rem; border-bottom: 1px solid var(--rule); }
.card ul { list-style: none; padding: 0; margin: 0 0 1.3rem; }
.card ul li { position: relative; padding-left: 1.3rem; margin-bottom: .5rem; font-size: calc(.91rem * var(--copy-scale)); color: var(--ink-2); }
.card ul li::before { content: ""; position: absolute; left: 0; top: .62em; width: 6px; height: 6px; background: var(--cyan); }
.card p.body { font-size: calc(.91rem * var(--copy-scale)); color: var(--ink-2); }
.card .btn { margin-top: auto; }

/* Featured card: animated conic-gradient border. Two clipped background layers rather
   than a z-index:-1 pseudo-element, which breaks inside any section that paints its own
   background because a negative z-index descendant paints beneath in-flow blocks.

   The highlight follows the pointer (J, 2026-09-08). Agent Favorite carries it by
   default; hovering any card in the grid moves it to that card instead. :focus-within
   is included so keyboard tabbing through the Book buttons gets the same behaviour. */
.card--featured,
.cards .card:hover,
.cards .card:focus-within {
  border-color: transparent;
  border-radius: var(--r-card);
  background:
    linear-gradient(var(--bg-1), var(--bg-1)) padding-box,
    conic-gradient(from var(--ppm-angle), var(--glow-1), var(--glow-2), var(--glow-3), var(--glow-1)) border-box;
  box-shadow: 0 12px 48px -16px rgba(30, 179, 219, .55);
  animation: ppm-glow-spin var(--glow-speed) linear infinite;
}

/* Hand the highlight over. Specificity here (0,5,0) deliberately beats both the
   .card--featured default (0,1,0) and the :hover rule above (0,3,0), and the :not()
   keeps the card that is actually hovered lit. Browsers without :has() support just
   leave the featured card glowing alongside the hovered one, which is the old
   behaviour plus one, not a broken state. */
.cards:has(.card:hover) .card--featured:not(:hover),
.cards:has(.card:focus-within) .card--featured:not(:focus-within) {
  border-color: var(--rule);
  background: var(--bg-1);
  box-shadow: none;
  animation: none;
}

@keyframes ppm-glow-spin { to { --ppm-angle: 360deg; } }
@media (prefers-reduced-motion: reduce) {
  .card--featured,
  .cards .card:hover,
  .cards .card:focus-within { animation: none; --ppm-angle: 135deg; }
}

/* ---------- UVP strip ----------
   The homepage ranked list, added 2026-09-15 from the UVP brief. Rank is the message,
   so the numeral is real content rather than decoration and the list is an <ol>.

   Rows are a two-column grid ONLY once a row has an image. Most rows have no asset yet,
   and a text-only row forced into a half-width column next to an empty cell reads as a
   broken layout, so the copy simply spans the full width until a picture arrives. That
   is why the media column is declared on the modifier class, not on .uvp__row. */
.uvp { list-style: none; padding: 0; margin: 2.5rem 0 0; counter-reset: none; }

.uvp__row {
  border-top: 1px solid var(--rule);
  padding-block: clamp(1.4rem, 2.6vw, 2.1rem);
  display: grid;
  gap: clamp(1rem, 2.5vw, 2rem);
  align-items: center;
}
.uvp__row:last-child { border-bottom: 1px solid var(--rule); }

/* The numeral leads the row on desktop and sits above the title on phones, where a
   narrow first column would squeeze the heading into a stack of single words. */
.uvp__copy { display: grid; gap: .45rem; align-items: baseline; }
.uvp__n {
  font-family: var(--font-mono); font-size: .72rem; font-weight: 500;
  letter-spacing: .14em; color: var(--acc-cyan);
}
.uvp__row h3 { margin: 0; font-size: clamp(1.1rem, 2vw, 1.4rem); }
.uvp__row p {
  margin: 0; color: var(--ink-2); max-width: 62ch;
  font-size: calc(.95rem * var(--copy-scale));
}
/* Wave sequence. Chips with a connecting rule, wrapping freely, so four stages read as
   a progression on a wide screen and stack honestly on a phone. No day numbers appear
   here by rule; the labels are the content. */
.uvp__waves {
  list-style: none; padding: 0; margin: .85rem 0 .1rem;
  display: flex; flex-wrap: wrap; gap: .4rem .75rem; align-items: center;
}
.uvp__waves li {
  position: relative;
  font-family: var(--font-mono); font-size: .64rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-3);
  border: 1px solid var(--rule); border-radius: var(--r);
  padding: .3rem .55rem; white-space: nowrap;
}
.uvp__waves li:first-child { color: var(--acc-cyan); border-color: var(--rule-2); }
/* The connector is drawn in the gap to the LEFT of each chip after the first, so a row
   that wraps never leaves a rule dangling off the end of a line. */
.uvp__waves li + li::before {
  content: ""; position: absolute; left: -.75rem; top: 50%;
  width: .75rem; height: 1px; background: var(--rule);
}

.uvp__link {
  justify-self: start; margin-top: .25rem;
  font-family: var(--font-mono); font-size: .68rem; letter-spacing: .11em;
  text-transform: uppercase; color: var(--acc-cyan); text-decoration: none;
  border-bottom: 1px solid transparent; padding-bottom: 2px;
}
.uvp__link:hover, .uvp__link:focus-visible { border-bottom-color: var(--acc-cyan); }

.uvp__media {
  border: 1px solid var(--rule); border-radius: var(--r);
  overflow: hidden; background: var(--bg-1);
}
.uvp__media img { display: block; width: 100%; height: auto; }

@media (min-width: 860px) {
  .uvp__copy {
    grid-template-columns: 3.2rem 1fr;
    column-gap: .4rem;
    row-gap: .5rem;
  }
  .uvp__n { grid-row: 1 / span 3; padding-top: .45em; }
  /* Every copy child after the numeral rides in column 2, so the text block stays
     flush whether or not the row carries a link. */
  .uvp__copy > :not(.uvp__n) { grid-column: 2; }

  .uvp__row--media { grid-template-columns: 1.15fr .85fr; }
  /* Alternate the picture side so ten rows do not read as one rigid ladder. Counted
     among media rows only, using nth-of-type on the modifier, so the rhythm survives
     text-only rows being interleaved between them. */
  .uvp__row--media:nth-of-type(even) .uvp__copy  { order: 2; }
  .uvp__row--media:nth-of-type(even) .uvp__media { order: 1; }
}

/* ---------- tables ---------- */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { border-collapse: collapse; width: 100%; min-width: 440px; font-size: calc(.92rem * var(--copy-scale)); }
th, td { text-align: left; padding: .85rem .9rem .85rem 0; border-bottom: 1px solid var(--rule); }
th { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); font-weight: 500; }
td { color: var(--ink-2); }
td.num { font-family: var(--font-mono); color: var(--ink); white-space: nowrap; }

/* ---------- cta ---------- */
.cta-band { background: var(--bg-1); border-block: 1px solid var(--rule); }

/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--rule); padding-block: 3rem; font-size: calc(.89rem * var(--copy-scale)); color: var(--ink-2); }
.site-footer .cols { display: grid; gap: 1.75rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); }
.site-footer h4 { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 .7rem; font-weight: 500; }
.site-footer ul { list-style: none; padding: 0; margin: 0; }
.site-footer li { margin-bottom: .35rem; }
.site-footer a { color: var(--ink-2); text-decoration: none; }
.site-footer a:hover { color: var(--ink); }
/* The street address is a real <address> element, which browsers italicise by default.
   Reset it so it sits in the same column as the email and phone rather than reading as
   a pull quote. */
.site-footer address { font-style: normal; color: var(--ink-2); margin: 0 0 .35rem; }
.site-footer .legal { margin-top: 2.5rem; padding-top: 1.3rem; border-top: 1px solid var(--rule); font-family: var(--font-mono); font-size: .68rem; letter-spacing: .08em; color: var(--ink-3); }

.prose { max-width: 66ch; }
.prose h2 { margin-top: 2.75rem; }

/* Body copy size, one knob.
   Base body is 17px. J asked for -40% on 2026-08-28, which computes to 10.2px:
     -40% = .6rem = 10.2px   (below the readable floor for body copy, see README)
     -30% = .7rem = 11.9px
     -20% = .85rem = 13.6px  <- set here
   Change the one value below to move it. */
:root {
  /* One dial for every body-copy size on the site. Set to .8 on 2026-08-27 per J:
     "reduce the size of this body copy font by 20% throughout entire site."
     Only running prose, labels, list items, table and footer text are scaled by it.
     Headings, mono eyebrows, buttons, prices and the big stat numerals are NOT, because
     those are structure and would lose their hierarchy if they shrank with the copy.
     To dial the whole site back, change this one number. */
  --copy-scale: .8;
  --prose-size: calc(.85rem * var(--copy-scale));
}
.prose p, .module p { font-size: var(--prose-size); line-height: 1.75; }

/* ---------- prose modules ----------
   The long-form pages (/about/, /zillow-showcase/) used to run as one unbroken column,
   which is why they read as a different site from /packages/. Same card language now:
   two up on wide screens, stacked on mobile, and the packages glow on hover instead of
   always-on, since a grid of permanently spinning borders would be unusable. */
.modules { display: grid; gap: 1rem; grid-template-columns: 1fr; margin-top: 2.5rem; }
@media (min-width: 900px) { .modules { grid-template-columns: 1fr 1fr; gap: 1.25rem; } }

.module {
  border: 2px solid var(--rule);
  border-radius: var(--r-card);
  background: var(--bg-1);
  padding: clamp(1.4rem, 2.4vw, 2rem);
  transition: box-shadow .25s ease, transform .25s ease;
}
.module h2 {
  margin: 0 0 .75rem;
  font-size: clamp(1.15rem, 1.9vw, 1.45rem);
}
.module p:last-child { margin-bottom: 0; }

/* Border stays 2px in both states so hover cannot reflow the grid. */
.module:hover, .module:focus-within {
  border-color: transparent;
  background:
    linear-gradient(var(--bg-1), var(--bg-1)) padding-box,
    conic-gradient(from var(--ppm-angle), var(--glow-1), var(--glow-2), var(--glow-3), var(--glow-1)) border-box;
  animation: ppm-glow-spin var(--glow-speed) linear infinite;
  box-shadow: 0 12px 44px -18px rgba(30, 179, 219, .45);
  transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce) {
  .module:hover, .module:focus-within { animation: none; --ppm-angle: 135deg; transform: none; }
}

/* The module grid butted straight into the edge-to-edge image wall below it, so the
   cards read as part of the photography rather than as their own band. */
.modules { margin-bottom: clamp(2.75rem, 7vw, 5rem); }

/* ---------- horizontal photo strip ----------
   A band of work to look at, not a gallery to open. No .mosaic class and no buttons
   inside, so the lightbox does not bind to it and nothing here is clickable. */
/* ---------- short term rental hero ----------
   Copy left, vertical reel right. The reel is 9:16, so it is capped by HEIGHT rather than
   given a width: a portrait video allowed to fill a column gets absurdly tall and shoves
   the copy off the screen. At 620px tall it renders 349px wide, which is why the encode is
   720px wide, comfortably 2x for retina without shipping pixels nobody sees.
   The grid is 1fr auto so the copy takes the slack and the reel takes exactly what it
   needs. On phones it stacks, reel second, because the argument is what sells and the
   sample is what confirms it. */
.str-hero { display: grid; grid-template-columns: 1fr; gap: clamp(1.8rem, 4vw, 3.5rem); }
@media (min-width: 900px) {
  /* Both tracks are bounded on purpose. This was `1fr auto`, and an `auto` track sizes to
     its content's max-content width, so anything unbreakable in the right column grows the
     track without limit and starves the `minmax(0, 1fr)` copy column down to almost
     nothing. That is exactly what happened on 2026-08-27: a Python dict leaked into the
     markup as one long string, the reel track ballooned, and the headline shipped wrapping
     one word per line. Bounding the reel column means the worst case is a cramped video,
     never a destroyed page. */
  .str-hero { grid-template-columns: minmax(0, 1fr) minmax(260px, 380px); align-items: center; }
}
.str-hero__copy { min-width: 0; }
/* Was clamp(2.2rem, 5.2vw, 3.6rem), which made this the only page title on the site
   smaller than the global h1 scale. Matched to the global clamp 2026-08-29 per J. */
.str-hero h1 { font-size: clamp(2.6rem, 7vw, 5.6rem); }
.str-reel { min-width: 0; }

/* Listing Launch Kit band on /short-term-rental/. Copy left, diagram right, stacking
   on phones. Same bounded-track discipline as .str-hero above. */
.kit { display: grid; grid-template-columns: 1fr; gap: clamp(1.8rem, 4vw, 3.5rem); }
@media (min-width: 900px) {
  .kit { grid-template-columns: minmax(0, 1fr) minmax(300px, 460px); align-items: center; }
}
.kit__copy { min-width: 0; }
.kit__art { min-width: 0; }
.kit__art svg { display: block; width: 100%; height: auto; }
.str-reel video {
  display: block; width: auto; max-width: 100%;
  max-height: clamp(360px, 62vh, 620px);
  margin-inline: auto;
  border: 1px solid var(--rule); border-radius: var(--r);
  background: var(--bg-1);
}
.str-reel .hero-cap { justify-content: center; margin-top: .7rem; }

.photo-strip {
  display: flex;
  gap: 0;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  background: var(--bg-1);
  scrollbar-width: thin;
  scrollbar-color: var(--rule-2) transparent;
}
.photo-strip figure {
  flex: 0 0 clamp(210px, 26vw, 340px);
  scroll-snap-align: start;
  margin: 0; position: relative;
  aspect-ratio: 3 / 2; overflow: hidden;
}
.photo-strip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-strip figcaption {
  position: absolute; inset: auto 0 0 0; padding: .75rem .85rem;
  font-family: var(--font-mono); font-size: .64rem; letter-spacing: .14em;
  text-transform: uppercase; color: #fff;
  background: linear-gradient(to top, rgba(4,8,18,.85), rgba(4,8,18,0));
  opacity: 0; transition: opacity .22s;
}
.photo-strip figure:hover figcaption { opacity: 1; }
/* No hover on touch, so the label would never appear. */
@media (hover: none) { .photo-strip figcaption { opacity: 1; } }
.photo-strip::-webkit-scrollbar { height: 8px; }
.photo-strip::-webkit-scrollbar-thumb { background: var(--rule-2); border-radius: 4px; }
.photo-strip::-webkit-scrollbar-track { background: transparent; }
/* An overflow container is not keyboard reachable for free; show it when it is. */
.photo-strip:focus-visible { outline: 2px solid var(--acc-cyan); outline-offset: -2px; }

/* ---------- booking chooser ----------
   Three big targets, above the fold, each landing straight on address entry. This
   replaced an embedded Aryeo order screen sitting behind a three-step explainer, which
   cost every booking two extra steps: scroll past the explainer, then pick a property
   type inside the embed. */
.book-choices {
  display: grid; gap: .8rem; grid-template-columns: 1fr; margin-top: 2.5rem;
}
@media (min-width: 820px) { .book-choices { grid-template-columns: repeat(3, 1fr); } }

.book-choice {
  display: flex; flex-direction: column; gap: .3rem;
  padding: 1.5rem 1.4rem 1.3rem;
  background: var(--bg-1); border: 1px solid var(--rule-2); border-radius: var(--r-card);
  text-decoration: none; color: var(--ink); min-height: 128px;
  transition: border-color .14s, transform .14s;
}
.book-choice:hover, .book-choice:focus-visible { border-color: var(--acc-cyan); transform: translateY(-2px); }
.book-choice b { font-family: var(--font-display); font-weight: 900; font-size: 1.3rem; letter-spacing: -.02em; }
.book-choice span { font-size: calc(.9rem * var(--copy-scale)); color: var(--ink-2); }
.book-choice em {
  margin-top: auto; padding-top: .9rem; font-style: normal;
  font-family: var(--font-mono); font-size: .72rem; letter-spacing: .12em;
  text-transform: uppercase; color: var(--acc-cyan);
}
.book-choices a:nth-child(2):hover { border-color: var(--acc-pink); }
.book-choices a:nth-child(2) em { color: var(--acc-pink); }
.book-choices a:nth-child(3):hover { border-color: var(--acc-orange); }
.book-choices a:nth-child(3) em { color: var(--acc-orange); }

/* ---------- booking ---------- */
.steps { display: grid; gap: 0; grid-template-columns: 1fr; border-top: 1px solid var(--rule); margin-top: 2.5rem; }
@media (min-width: 780px) { .steps { grid-template-columns: repeat(3, 1fr); } }
.step { padding: 1.6rem 0; border-bottom: 1px solid var(--rule); }
@media (min-width: 780px) {
  .step { padding: 1.8rem clamp(1rem, 2vw, 1.8rem); border-right: 1px solid var(--rule); border-bottom: 0; }
  .step:first-child { padding-left: 0; }
  .step:last-child { border-right: 0; padding-right: 0; }
}
.step b {
  display: block; font-family: var(--font-mono); font-weight: 500; font-size: .78rem;
  letter-spacing: .18em; color: var(--cyan); margin-bottom: .7rem;
}
.step h3 { margin-bottom: .35em; }
.step p { font-size: calc(.92rem * var(--copy-scale)); color: var(--ink-2); margin: 0; }

/* Aryeo's embed injects its own markup and styling. Give it a light container and let
   it own the inside. Do not try to restyle its internals from here: it is third-party
   markup that can change without notice, and a selector written against it today is a
   silent breakage tomorrow. */
/* Aryeo's embed carries its own background. Set the Aryeo Designer background colour to
   #070b16 to match this container and the seam disappears entirely. Until then the form
   sits as a light card on the dark page, which still reads as intentional. */
.embed-shell {
  margin-top: 1.5rem; background: var(--bg);
  border: 1px solid var(--rule); border-radius: var(--r-card);
  min-height: 620px; overflow: hidden;
}
.embed-shell iframe { width: 100%; border: 0; display: block; }

/* ---------- accent rotation ----------
   The logo is a spectrum, so the site should not be monochrome cyan. Numbers, rules and
   markers cycle cyan, pink, orange, violet. Colour is decorative here and never the only
   carrier of meaning, so nothing is lost to a colour-blind reader. */

/* Section markers: the numeral takes the accent, and the rule under it picks up a tint. */
.marker.acc-1 b { color: var(--acc-cyan); }
.marker.acc-2 b { color: var(--acc-pink); }
.marker.acc-3 b { color: var(--acc-orange); }
.marker.acc-4 b { color: var(--acc-violet); }
.marker.acc-1 { border-bottom-color: color-mix(in srgb, var(--acc-cyan) 40%, var(--rule)); }
.marker.acc-2 { border-bottom-color: color-mix(in srgb, var(--acc-pink) 40%, var(--rule)); }
.marker.acc-3 { border-bottom-color: color-mix(in srgb, var(--acc-orange) 40%, var(--rule)); }
.marker.acc-4 { border-bottom-color: color-mix(in srgb, var(--acc-violet) 40%, var(--rule)); }

/* Big figures: each stat gets its own hue instead of three identical cyan numbers. */
.figures .figure:nth-child(1) b { color: var(--acc-cyan); }
.figures .figure:nth-child(2) b { color: var(--acc-pink); }
.figures .figure:nth-child(3) b { color: var(--acc-orange); }

/* Booking steps. */
.steps .step:nth-child(1) b { color: var(--acc-cyan); }
.steps .step:nth-child(2) b { color: var(--acc-pink); }
.steps .step:nth-child(3) b { color: var(--acc-orange); }

/* Package bullets, one hue per card so the three tiers read as distinct objects. */
.cards .card:nth-child(1) ul li::before { background: var(--acc-cyan); }
.cards .card:nth-child(2) ul li::before { background: var(--acc-pink); }
.cards .card:nth-child(3) ul li::before { background: var(--acc-orange); }

/* A spectrum hairline. Used as a top edge on the CTA band so the logo's full range
   appears somewhere on every page. */
.cta-band {
  border-top: 0;
  background-image:
    linear-gradient(90deg, var(--acc-cyan), var(--acc-violet), var(--acc-pink), var(--acc-orange)),
    linear-gradient(var(--bg-1), var(--bg-1));
  background-size: 100% 2px, 100% 100%;
  background-repeat: no-repeat;
  background-position: 0 0, 0 0;
}

/* Filter chips pick up a hue on hover rather than all going cyan. */
.filters a:nth-child(2):hover { border-color: var(--acc-cyan); color: var(--acc-cyan); }
.filters a:nth-child(3):hover { border-color: var(--acc-pink); color: var(--acc-pink); }
.filters a:nth-child(4):hover { border-color: var(--acc-orange); color: var(--acc-orange); }
.filters a:nth-child(5):hover { border-color: var(--acc-violet); color: var(--acc-violet); }

/* Mosaic captions tint by category. */
.mosaic figure:nth-child(4n+1) figcaption { color: var(--acc-cyan); }
.mosaic figure:nth-child(4n+2) figcaption { color: var(--acc-pink); }
.mosaic figure:nth-child(4n+3) figcaption { color: var(--acc-orange); }
.mosaic figure:nth-child(4n+4) figcaption { color: var(--acc-violet); }

/* ---------- mobile title scale ----------
   Set 2026-08-27 per J: "all title font on mobile screens must be increased in size by
   50%." One dial, same pattern as --copy-scale. Deliberately LAST in the file so these
   land after .hero h1, .str-hero h1 and .module h2, each of which is an equal-or-higher
   specificity override that would otherwise win on source order.

   720px is the phone breakpoint already used by the hero, not a new one.

   What this actually produces on a 390px handset: h1 goes 41.6px to 62.4px, the homepage
   hero h1 32.8px to 49.2px, the STR hero h1 35.2px to 52.8px, h2 28.8px to 43.2px. The
   longest heading on the site, "Zillow Showcase helps your listings stand out", goes from
   about four lines to six. That is the intended trade, but it is a large one, so the
   number is here rather than scattered across four rules. */
@media (max-width: 720px) {
  :root { --title-scale: 1.5; }

  h1 { font-size: calc(clamp(2.6rem, 8vw, 5.6rem) * var(--title-scale)); }
  h2 { font-size: calc(clamp(1.8rem, 4.4vw, 3rem) * var(--title-scale)); }
  h3 { font-size: calc(clamp(1rem, 1.7vw, 1.2rem) * var(--title-scale)); }

  .hero h1     { font-size: calc(clamp(1.95rem, 8.4vw, 2.6rem) * var(--title-scale)); }
  .str-hero h1 { font-size: calc(clamp(2.6rem, 7vw, 5.6rem) * var(--title-scale)); }
  .module h2   { font-size: calc(clamp(1.15rem, 1.9vw, 1.45rem) * var(--title-scale)); }

  /* Display type at 62px on a 390px screen will find any word long enough to overflow.
     Breaking beats a horizontal scrollbar on the whole page. */
  h1, h2, h3 { overflow-wrap: break-word; }
}

/* Starting price on /short-term-rental/. Sits between the lede and the friction note, so
   the reader gets what it is, what it costs, and how easy it is to book, in that order.
   Numerals in mono to match every other price on the site. */
.price-from {
  font-size: calc(1rem * var(--copy-scale));
  color: var(--ink-2); margin: 1.2rem 0 0;
}
.price-from b {
  font-family: var(--font-mono); font-weight: 500;
  color: var(--ink); letter-spacing: -.02em;
}

/* ---------- contact form ----------
   Added 2026-09-08 with /contact/. The form posts to a third-party endpoint because this
   site is static files on DreamHost; nothing here assumes a server.

   Inputs get an explicit background and color. Leaving them to the UA default paints a
   white box with dark text on this near-black page, and in dark-mode browsers it paints
   an unpredictable one. Both look broken. */

.module--form { grid-column: 1 / -1; }
@media (min-width: 900px) {
  /* The form is the point of the page, so on a wide screen it takes the larger column
     rather than sitting in an equal half beside the address block. */
  .contact-grid { grid-template-columns: 1.35fr 1fr; }
  .module--form { grid-column: auto; }
}

.form { margin-top: 1.25rem; }
.field { margin-bottom: 1.1rem; }
.field label {
  display: block; margin-bottom: .4rem;
  font-family: var(--font-mono); font-size: .72rem; font-weight: 500;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ink-2);
}
.field .opt { color: var(--ink-3); text-transform: none; letter-spacing: .04em; }

.field input, .field textarea {
  width: 100%; box-sizing: border-box;
  padding: .72rem .85rem;
  background: var(--bg-2); color: var(--ink);
  border: 1px solid var(--rule-2); border-radius: var(--r);
  font-family: var(--font-body); font-size: 1rem; line-height: 1.5;
  /* 16px minimum or iOS Safari zooms the viewport on focus and never zooms back. */
  min-height: 44px;
  transition: border-color .14s, box-shadow .14s;
}
.field textarea { min-height: 8rem; resize: vertical; }
.field input::placeholder, .field textarea::placeholder { color: var(--ink-3); }
.field input:hover, .field textarea:hover { border-color: var(--ink-3); }
.field input:focus-visible, .field textarea:focus-visible {
  outline: 2px solid var(--cyan); outline-offset: 2px; border-color: var(--cyan);
}
/* Only style a field as invalid AFTER the visitor has interacted with it.
   :user-invalid is the correct selector and does exactly that.

   The obvious-looking :not(:placeholder-shown):invalid does NOT work here and shipped
   broken on 2026-09-08: :placeholder-shown only ever matches an input that HAS a
   placeholder attribute, and these deliberately have none (the labels do that job). With
   no placeholder the :not() is always true, so every empty required field rendered with a
   pink ring the moment the page loaded. Caught by reading computed styles off the live
   page, not from the markup.

   Browsers without :user-invalid simply skip this rule and show no custom invalid state.
   Native validation on submit still works, so the fallback is degraded, not broken. */
.field input:user-invalid,
.field textarea:user-invalid { border-color: var(--acc-pink); }

/* Honeypot. Off-screen rather than display:none, because some bots skip hidden inputs
   and a filled one is the signal. aria-hidden and tabindex keep it away from people. */
.hp {
  position: absolute; left: -9999px; width: 1px; height: 1px;
  overflow: hidden;
}

.form button[type="submit"] { margin-top: .35rem; }
.form[aria-busy="true"] { opacity: .7; }
.form button[disabled] { cursor: progress; }

.form-status {
  margin: 1rem 0 0; font-size: .92rem; line-height: 1.6;
  /* No colour until there is something to say, so an empty <p> is invisible but still
     present for aria-live to announce into. */
}
.form-status:empty { margin: 0; }
.form-status:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
.form-status.is-ok { color: var(--acc-cyan); }
.form-status.is-err { color: var(--acc-pink); }
.form-status a { color: inherit; }

.fine {
  margin: .9rem 0 0;
  font-size: .85rem; color: var(--ink-3); line-height: 1.6;
}
