/* ==========================================================================
   Miloop AI: style.css
   Design tokens follow the "closed loop" concept: build, verify, deploy,
   iterate. Colors and the ring motif exist to carry that idea, not to
   decorate the page.
   ========================================================================== */

/* MiSans, self-hosted and subset to exactly the CJK characters (+ ASCII)
   that appear anywhere on this site (site copy, portfolio-page dictionaries,
   markup) -- built with fontTools/pyftsubset from Xiaomi's official
   free-for-commercial-use release, same hand-optimise-then-commit pattern
   used for images and video. Free commercial use requires crediting MiSans
   somewhere on the site; see footer.rights/privacy page. Replaces Noto Sans
   SC / Noto Serif SC as the primary CJK face; Noto Sans SC stays loaded from
   Google Fonts as a fallback for any glyph outside this subset (chiefly the
   FactLoop demo's arbitrary runtime-generated Chinese, which this subset was
   never meant to cover). */
@font-face {
  font-family: 'MiSans';
  src: url('../assets/fonts/MiSans-Light.woff2') format('woff2');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'MiSans';
  src: url('../assets/fonts/MiSans-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'MiSans';
  src: url('../assets/fonts/MiSans-Medium.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'MiSans';
  src: url('../assets/fonts/MiSans-Semibold.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

:root {
  /* --- Color tokens --- */
  --bg-void: #0A0B14;
  --bg-panel: #141625;
  --bg-panel-raised: #1B1E33;
  --ink: #E9E7F5;
  --ink-dim: #8D8BA8;
  --ink-faint: #57597A;
  --loop-indigo: #5B6EF5;
  --loop-violet: #A66CF0;
  --signal-mint: #5EE6C8;
  /* The palette had no colour for "held back". Mint means verified and clean, and
     the loop needed a second state for the moment something is stopped and sent
     to be redone. Warm rather than red on purpose: in this story being caught is
     the system working, not an error. */
  --signal-amber: #E0B978;
  --border-soft: rgba(233, 231, 245, 0.09);
  --border-strong: rgba(233, 231, 245, 0.18);

  /* --- Type --- */
  /* MiSans (self-hosted, subset -- see the @font-face block above) is the
     primary CJK face across display, body and mono/label tiers alike: it
     replaced the old Fraunces/Noto-Serif-SC split because a free, equally
     distinctive CJK serif to pair with Fraunces does not really exist (the
     free options are all Source-Han-Serif derivatives, visually the same
     font as Noto Serif SC under a different name). Noto Sans SC stays as
     the last fallback in every stack, full (not subset), from Google Fonts. */
  --font-display: 'Fraunces', 'MiSans', 'Noto Sans SC', serif;
  --font-body: 'IBM Plex Sans', 'MiSans', 'Noto Sans SC', -apple-system, sans-serif;
  /* Every Latin face in this stack lacks CJK, and so does the generic
     `monospace` it ends in, so Chinese label text used to fall through to
     whatever the OS happened to own the glyph: SimSun on Windows, thin and
     spindly and visibly weaker than the rest of the page at the same size.
     MiSans (then Noto Sans SC as a full-coverage fallback) keeps the label
     tier on one deliberate face in both languages. */
  --font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', 'MiSans', 'Noto Sans SC', monospace;

  /* A CJK glyph fills its em box. Latin lowercase only reaches the x-height,
     a little over half of it. Set at the same font-size, a Chinese heading
     therefore reads noticeably larger than the English one it was drawn to
     match — the bigger the type, the more the difference shows. This is the
     correction, applied only to display sizes; body and label text is set
     from the reader's side and stays where the legibility pass put it. */
  --display-scale: 1;

  /* --- Spacing / layout --- */
  --max-width: 1180px;
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;
  --space-6: 6.5rem;
  --radius-sm: 6px;
  --radius-md: 14px;
  --radius-lg: 28px;
}

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

/* 17px rather than the browser default 16. Every size and every space token
   on this site is in rem, so the whole design scales from here in proportion
   instead of each tier being nudged on its own and the hierarchy collapsing.
   A percentage rather than a px value so it still respects a reader who has
   changed their browser's default text size. */
html { font-size: 106.25%; scroll-behavior: smooth; }

/* Both Chinese versions only. The English pages are the reference the scale
   was drawn against and must not move. `lang` is set on <html> by every one
   of the three language paths, so this needs no help from JS. */
html[lang^="zh"] {
  --display-scale: 0.92;
}
/* 1.12 and 1.15 are Latin values: they work because lowercase leaves air
   above and below. Two lines of Chinese at that leading nearly touch. */
html[lang^="zh"] .hero h1,
html[lang^="zh"] .about-h1,
html[lang^="zh"] .deskloop-h1,
html[lang^="zh"] .driftboard-h1,
html[lang^="zh"] .factloop-h1 {
  line-height: 1.28;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}

body {
  margin: 0;
  background: var(--bg-void);
  background-image:
    radial-gradient(ellipse 900px 500px at 15% -10%, rgba(91, 110, 245, 0.16), transparent 60%),
    radial-gradient(ellipse 700px 500px at 100% 10%, rgba(166, 108, 240, 0.10), transparent 60%);
  background-repeat: no-repeat;
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
ul { margin: 0; padding: 0; list-style: none; }
h1, h2, h3, p { margin: 0; }

:focus-visible {
  outline: 2px solid var(--loop-indigo);
  outline-offset: 3px;
  border-radius: 2px;
}

.container {
  max-width: var(--max-width);
  margin: 0 auto;
  padding: 0 var(--space-3);
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.95rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--loop-violet);
}

h1, h2, h3 { font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em; }

h2.section-title {
  font-size: calc(clamp(1.6rem, 3vw, 2.3rem) * var(--display-scale));
  font-weight: 300;
  margin-top: var(--space-1);
  max-width: 40ch;
}

.section-lede {
  color: var(--ink-dim);
  max-width: 56ch;
  margin-top: var(--space-2);
  font-size: 1.02rem;
}

section { padding: var(--space-6) 0; }
section + section { border-top: 1px solid var(--border-soft); }

/* --- Buttons --- */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1.4rem;
  border-radius: 999px;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 0.95rem;
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.btn-primary {
  background: linear-gradient(120deg, var(--loop-indigo), var(--loop-violet));
  color: #07070C;
  font-weight: 600;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-ghost {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--ink);
}
.btn-ghost:hover { border-color: var(--loop-indigo); }

/* ==========================================================================
   Nav
   ========================================================================== */
.site-nav {
  position: sticky;
  top: 0;
  z-index: 50;
  backdrop-filter: blur(14px);
  background: rgba(10, 11, 20, 0.72);
  border-bottom: 1px solid var(--border-soft);
}
.site-nav .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 68px;
}
.brand-group { display: flex; align-items: center; gap: 0.85rem; }
.wordmark {
  display: inline-flex;
  align-items: center;
}
.wordmark-full {
  height: 34px;
  width: auto;
  display: block;
}
.brand-divider {
  width: 1px;
  height: 22px;
  background: var(--border-strong);
  display: inline-block;
}
/* The mark is 24px because that is the size it should be read at. The link around
   it is 44px because that is the smallest thing a finger reliably hits, and the
   negative margin keeps the header laid out as though it were still icon-sized. */
.brand-youtube-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: -14px -10px;
  transition: opacity 0.15s ease;
}
.brand-youtube-link:hover { opacity: 0.8; }
.brand-youtube-link svg { width: 24px; height: 17px; display: block; }

.lang-switch { position: relative; }
.lang-switch-btn {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  color: var(--ink-dim);
  background: transparent;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  padding: 0.35rem 0.7rem;
  cursor: pointer;
}
.lang-switch-btn:hover { color: var(--ink); border-color: var(--loop-indigo); }
.lang-caret { transition: transform 0.15s ease; }
.lang-switch.open .lang-caret { transform: rotate(180deg); }

.lang-switch-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  min-width: 150px;
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-md);
  padding: 0.4rem;
  display: none;
  flex-direction: column;
  gap: 0.15rem;
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
  z-index: 60;
}
.lang-switch.open .lang-switch-menu { display: flex; }
.lang-option {
  text-align: left;
  background: transparent;
  border: none;
  color: var(--ink-dim);
  padding: 0.55rem 0.65rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 0.95rem;
}
.lang-option:hover { background: var(--bg-panel-raised); color: var(--ink); }
.lang-option.active { color: var(--loop-violet); font-weight: 600; }

.nav-actions { display: flex; align-items: center; gap: var(--space-2); }
/* Two labels, one dictionary entry each, so the narrow bar gets a shorter one
   without the button text falling outside the translation path. */
.btn-narrow { display: none; }
@media (max-width: 820px) {
  .btn-wide { display: none; }
  .btn-narrow { display: inline; }
}

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

@media (max-width: 480px) {
  .wordmark-full { height: 26px; }
  .brand-group { gap: 0.55rem; }
  /* The bar carries the wordmark, the channel link, the language switch and the
     call to action, and the last one is the widest. It used to be dropped here and
     picked up beside the hero, but the hero no longer has anywhere to put it. It
     stays, smaller, under a shorter label. */
  .brand-youtube-link svg { width: 22px; height: 15px; }
  .nav-actions { gap: 0.5rem; }
  .nav-actions .btn { padding: 0.5rem 0.9rem; font-size: 0.88rem; }
}

/* ==========================================================================
   Hero
   ========================================================================== */
.hero {
  padding-top: var(--space-6);
  padding-bottom: var(--space-6);
  display: grid;
  /* Set from the text side, not the loop. Growing the loop had taken the column
     below .hero-sub's own 46ch measure, so the paragraph was wrapping tighter than
     it was drawn for and sat hard against the left margin. At this width it has
     slack, which is what stops a column of text reading as crammed, and the English
     headline settles onto two lines like the Chinese one. The headline cap came down
     from 3.6rem to 3.2rem to buy the loop some of that width back: measured, both
     headlines still hold two lines at 584px and the paragraph keeps 77px of slack. */
  grid-template-columns: 1.1fr 0.9fr;
  gap: var(--space-5);
  align-items: center;
}
.hero h1 {
  font-size: calc(clamp(2.3rem, 4.4vw, 3.2rem) * var(--display-scale));
  font-weight: 300;
  line-height: 1.12;
  margin-top: var(--space-2);
}
.hero h1 em {
  font-style: normal;
  background: linear-gradient(120deg, var(--loop-indigo), var(--loop-violet));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.hero-sub {
  margin-top: var(--space-3);
  color: var(--ink-dim);
  font-size: 1.08rem;
  max-width: 46ch;
}

/* The three concrete things we make, named in the hero because until you scroll
   the page never says one. The h1 and the loop are both abstractions: "busywork"
   and a process diagram. This list is the first noun a visitor can picture. It
   holds .hero-sub's own 46ch measure so the whole copy column shares one edge,
   and it deliberately stops at three when the services section below has five:
   it is a sample, not a contents page, which is what "including:" above says. */
.hero-offers {
  margin-top: var(--space-3);
  /* 46ch resolved against .hero-sub's own 1.08rem, so the list ends on the same
     right edge as the paragraph above it instead of 65px short of it. The
     children set their own sizes back. */
  font-size: 1.08rem;
  max-width: 46ch;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 0.8rem;
}
.hero-offers li {
  position: relative;
  padding-left: 1.2rem;
}
.hero-offers li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.52em;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--signal-mint);
}
.hero-offers strong {
  display: block;
  font-weight: 500;
  font-size: 1rem;
  line-height: 1.4;
  color: var(--ink);
}
.hero-offers span {
  display: block;
  font-size: 0.97rem;
  line-height: 1.5;
  color: var(--ink-dim);
}

/* The one line in the hero addressed to somebody who does not know AI, and the
   only one that says what is actually being asked of them. Sitting at the tail
   of the paragraph it was the least likely sentence on the page to be read. The
   rule at its left is the device .about-pullquote and .result-metric already use
   for the sentence that carries the point, rather than another box. */
/* The loop and the promise share the right column. The promise is a sibling of
   .hero-visual rather than a child of it, because .hero-visual carries
   aria-hidden and would take the one sentence addressed to a non-technical
   reader out of the accessibility tree with it. Putting it here also fills the
   dead space under a 234px-tall loop in a much taller column, and takes its
   height off the copy column, which is what was making the hero long. */
.hero-side {
  display: grid;
  align-content: center;
  gap: var(--space-4);
}

.hero-promise {
  margin-top: 0;
  padding-left: 1rem;
  border-left: 2px solid var(--loop-violet);
  font-family: var(--font-display);
  font-weight: 300;
  /* Sized up to the largest value both languages survive at this column width,
     measured: English holds one sentence per line to 24px and only breaks into
     three at 26px, but Chinese leaves a single line at 21px, and balancing a
     two-line Chinese sentence gives two 238px lines in a 477px box, filling
     half of it. The root is 17px, not 16px, so the cliff sits between 1.3 and
     1.35rem: at 1.3 Chinese is 20.3px on one line at 452px, 95% of the column,
     and English is 22.1px still one sentence per line. */
  font-size: calc(1.3rem * var(--display-scale));
  line-height: 1.5;
  color: var(--ink);
  /* No measure of its own: in the side column it takes the loop's width, so the
     two line up as one block however long the sentence gets. The 34ch it used to
     carry was drawn for the copy column and now just made it 65px narrower than
     the thing above it. Capped again below 900px, where the column goes full
     width and 46ch is as far as this size should run. */
  max-width: none;
  /* At the loop's full width the default break put "You just" alone at the end
     of line one. Balancing lands the break on the full stop instead, one
     sentence per line, and leaves the box itself the full 477px. Browsers
     without it just wrap normally. */
  text-wrap: balance;
}
@media (max-width: 900px) {
  .hero-promise { max-width: 46ch; }
}

@media (max-width: 900px) {
  .hero { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Services (five bands on a spine)
   ==========================================================================
   Five steps of one path, Diagnose to Support, not five items in a list. The
   spine carries the same accent as the hero and a light walks down it as the
   page is read, opening each band as it arrives.

   The copy says what we do in two lines, because the line above it states the
   problem and the block beside it says what you walk away with. It used to do
   all three, which is why it ran six lines and the section ran three screens.
   ========================================================================== */
.sv-lede {
  margin-top: var(--space-2);
  color: var(--ink-dim);
  font-size: 1.08rem;
  line-height: 1.7;
  max-width: 62ch;
}
.sv-stack {
  position: relative;
  margin-top: var(--space-4);
  padding-left: 3.6rem;
  display: flex;
  flex-direction: column;
  gap: 1.2rem;
}
.sv-spine { position: absolute; left: 0.6rem; top: 2.2rem; bottom: 3rem; width: 1px; }
.sv-spine-track,
.sv-spine-lit { position: absolute; left: 0; width: 1px; }
.sv-spine-track {
  top: 0;
  bottom: 0;
  background: linear-gradient(180deg,
    rgba(91, 110, 245, 0.34), rgba(166, 108, 240, 0.28) 50%, rgba(91, 110, 245, 0.34));
}
.sv-spine-lit {
  top: 0;
  height: 0;
  background: linear-gradient(180deg, var(--loop-indigo), var(--loop-violet));
  box-shadow: 0 0 9px rgba(91, 110, 245, 0.55);
}
.sv-spine-head {
  position: absolute;
  left: 50%;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  background: var(--loop-violet);
  box-shadow: 0 0 10px 2px rgba(166, 108, 240, 0.55);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.sv-stack.is-moving .sv-spine-head { opacity: 1; }

.sv-band {
  position: relative;
  background: #0d0e1a;
  border: 1px solid #1e2036;
  border-radius: 14px;
  padding: 1.7rem 1.9rem;
  display: grid;
  grid-template-columns: 1.15fr 0.85fr;
  gap: 2.4rem;
  align-items: start;
  min-width: 0;
}
.sv-copy, .sv-visual { min-width: 0; }
.sv-band::before {
  content: "";
  position: absolute;
  left: -3rem;
  top: 2.4rem;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  background: var(--bg-void);
  border: 1.5px solid rgba(233, 231, 245, 0.2);
  transition: border-color 0.45s ease, box-shadow 0.45s ease;
}
.sv-band.is-in::before {
  border-color: var(--loop-violet);
  box-shadow: 0 0 0 4px rgba(166, 108, 240, 0.1);
}

.sv-stage {
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.08em;
  color: var(--loop-indigo);
  margin-bottom: 0.7rem;
}
.sv-title {
  font-size: calc(1.6rem * var(--display-scale));
  font-weight: 600;
  line-height: 1.22;
  margin: 0 0 0.7rem;
}
.sv-pain {
  font-family: var(--font-mono);
  font-size: 0.9rem;
  color: var(--loop-violet);
  line-height: 1.7;
  margin-bottom: 0.7rem;
}
.sv-desc { color: var(--ink-dim); font-size: 0.95rem; line-height: 1.75; margin: 0; }
.sv-desc strong { color: var(--ink); font-weight: 500; }

/* No card here. The band is already the box; a second box inside it reads as a
   frame within a frame. One hairline rule does the separating. */
.sv-yield { position: relative; padding-left: 1.7rem; }
.sv-yield::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.3rem;
  bottom: 0.3rem;
  width: 1px;
  background: linear-gradient(180deg, var(--loop-indigo), var(--loop-violet) 55%, rgba(166, 108, 240, 0.12));
}
.sv-yield-lb {
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--signal-mint);
  margin: 0 0 0.9rem;
}
.sv-y { padding: 0.7rem 0; border-bottom: 1px solid var(--border-soft); }
.sv-y:last-of-type { border-bottom: none; }
.sv-y-n { font-size: 1.02rem; color: var(--ink); line-height: 1.35; margin: 0 0 0.25rem; }
.sv-y-d { color: var(--ink-dim); font-size: 0.95rem; line-height: 1.6; margin: 0; }

/* Reveal, only once the script is running: a band waiting its turn must never be
   an empty frame, so the whole band fades in, border and all. */
.sv-stack.js-on .sv-band {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 0.55s ease, transform 0.55s ease;
}
.sv-stack.js-on .sv-band.is-in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .sv-stack.js-on .sv-band { opacity: 1; transform: none; transition: none; }
  .sv-spine-lit { height: 100% !important; }
  .sv-spine-head { display: none; }
}

/* The section rhythm was set for a wide screen. At 6.5rem top and bottom, the gap
   between the end of one section and the start of the next comes to 221px, which on
   a phone is about a third of a screen of nothing. */
@media (max-width: 820px) {
  section { padding: var(--space-5) 0; }
  .hero { padding-top: var(--space-4); padding-bottom: var(--space-4); }
}

@media (max-width: 820px) {
  .sv-stack { padding-left: 0; }
  .sv-spine { display: none; }
  .sv-band { grid-template-columns: 1fr; gap: 1.3rem; padding: 1.5rem 1.2rem; }
  .sv-band::before { position: static; display: inline-block; margin: 0 0.5rem 0.1rem 0; transform: none; }
  .sv-title { font-size: calc(1.4rem * var(--display-scale)); }
}


/* ---- narrow screens ----
   The panels are drawn against a 500px column. Stacked into a phone they get about
   360px, so the pipeline gets a row per stage instead of five boxes side by side,
   the metric pair stacks, and the rail keeps only its numbers: five sentences at
   70px each wrapped to four lines and read as noise. The scenes also run taller
   here, so the fixed body height has to follow or the tallest one is clipped. */
@media (max-width: 820px) {

  .pp { flex-direction: column; gap: 0.35rem; }


  /* The deliverable names carry the point; their explanations are the tallest part
     of a band on a phone and the least likely to be read there. */
  .sv-y-d { display: none; }
  .sv-y { padding: 0.5rem 0; }

  /* Monospace is wide, and a long sentence set in it on a 366px column runs to six
     lines. The violet keeps the line recognisable; the face does not have to. */
  .sv-pain { font-family: var(--font-body); font-size: 0.95rem; line-height: 1.6; }
    .sv-desc { line-height: 1.65; }
}

/* ==========================================================================
   Results (merged proof + work showcase)
   ========================================================================== */
.result-grid {
  margin-top: var(--space-5);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-3);
}
.result-card {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: var(--space-3);
  display: flex;
  flex-direction: column;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.result-card:hover { border-color: var(--loop-indigo); transform: translateY(-2px); }

/* A testimonial, so the words somebody said are the card and everything else
   attributes them.

   Both marks come from here. While some of the strings carried their own
   punctuation and the stylesheet added an opening one on top, a quotation could
   end up with two openers and nothing closing it. Set on the paragraph, so the
   closer follows the last word wherever that falls. */
.result-quote { margin: 0; }
.result-quote p::before,
.result-quote p::after {
  font-family: var(--font-display);
  font-size: 1.45em;
  line-height: 0;
  vertical-align: -0.14em;
  color: var(--loop-violet);
  opacity: 0.6;
}
.result-quote p::before { content: "\201C"; margin-right: 0.04em; }
.result-quote p::after { content: "\201D"; margin-left: 0.04em; }
/* Traditional Chinese quotes with the corner brackets, which are full-width and
   sit on the baseline already, so the Latin marks' size and lift come back off.
   Simplified keeps the curly pair, which is what it uses. */
html[lang="zh-Hant"] .result-quote p::before,
html[lang="zh-Hant"] .result-quote p::after {
  font-size: 1em;
  vertical-align: baseline;
  margin: 0;
}
html[lang="zh-Hant"] .result-quote p::before { content: "\300C"; }
html[lang="zh-Hant"] .result-quote p::after { content: "\300D"; }
.result-quote p {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 300;
  font-size: calc(1.18rem * var(--display-scale));
  line-height: 1.55;
  color: var(--ink);
}
/* Directly under the words. It used to be pushed to the foot of the card by a
   quote set to stretch, and an attribution sitting a long way from its quotation
   stops reading as the attribution for it. */
.result-cite {
  display: block;
  margin: 0.6rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  font-style: normal;
  color: var(--ink-dim);
}
.result-cite::before { content: "\2014\00a0"; }
html[lang^="zh"] .result-cite::before { content: "\2014\2014\00a0"; }

.result-credential { margin-top: auto; padding-top: var(--space-3); }
.result-project { margin: 0 0 0.6rem; color: var(--ink-dim); font-size: 0.95rem; line-height: 1.5; }
.result-tag {
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--loop-violet);
  margin-right: 0.55rem;
}
/* The same block the hero gives a fix that landed: mint rule, faint mint ground.
   One plain line, so the figure is read rather than looked at. */
.result-metric {
  margin: 0;
  padding: 0.6rem 0.85rem;
  border-left: 2px solid var(--signal-mint);
  background: rgba(94, 230, 200, 0.05);
  color: var(--ink-dim);
  font-size: 0.95rem;
  line-height: 1.55;
}
.result-metric b { color: var(--signal-mint); font-weight: 600; }

@media (max-width: 820px) {
  .result-grid { grid-template-columns: 1fr; }
}

/* --- Live demos ---
   Same card anatomy and weight as .result-card above: the difference is that
   these lead somewhere you can use, so the quote slot becomes a button. */
.live-demo-grid {
  margin-top: var(--space-5);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-3);
}

.live-demo-card {
  position: relative; /* anchor for the whole-card hit area below */
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.live-demo-card:hover { border-color: var(--loop-indigo); transform: translateY(-2px); }
/* The card lifts and lights up on hover, so the whole thing has to be clickable
   or it promises a target it does not have. Done by stretching the card's one
   real anchor over it rather than with a click handler on the card: there is
   still a single link, it keeps its href, its focus ring and its middle-click
   and open-in-new-tab behaviour, and no JS is involved. Only correct while a
   card holds exactly one link. The cost is that text inside the card cannot be
   selected, which is the accepted trade for this pattern. */
.live-demo-actions a::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2; /* over the badge (1) and the visual's scrim */
}
/* Keyboard users tab to the anchor, not the card, so the card has to answer to
   focus the same way it answers to hover. */
.live-demo-card:focus-within { border-color: var(--loop-indigo); }

.live-demo-visual {
  position: relative;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  background: var(--bg-panel-raised);
}
.live-demo-visual img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* Light on purpose: this only has to keep a bright screenshot from clashing with
   the text below, so the screenshot itself stays readable. (It used to be
   described against the result cards' scrim, which went when those cards lost
   their stock photographs.) */
.live-demo-visual::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(10, 11, 20, 0.10) 0%, rgba(10, 11, 20, 0.14) 60%, rgba(20, 22, 37, 0.42) 100%);
  pointer-events: none;
}

.live-demo-badge {
  position: absolute;
  top: 0.7rem;
  right: 0.7rem;
  z-index: 1; /* above the scrim */
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
  background: rgba(10, 11, 20, 0.72);
  border: 1px solid var(--border-strong);
  backdrop-filter: blur(6px);
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--signal-mint);
}
.live-demo-badge::before {
  content: "";
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--signal-mint);
}
/* This used to live in the hero loop section and was shared with it. The loop
   now runs on delays derived from its own geometry, so the keyframes belong
   to the badge that still uses them. */
@keyframes node-pulse {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: no-preference) {
  .live-demo-badge::before { animation: node-pulse 2.4s ease-in-out infinite; }
}

.live-demo-body { padding: var(--space-3); display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.live-demo-tag {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--loop-violet);
}
.live-demo-name {
  margin: 0;
  font-family: var(--font-body);
  font-size: 1.1rem;
  font-weight: 600;
  line-height: 1.35;
}
/* Absorbs the slack so the button lands at the card's foot, level with the
   quote block on the testimonial cards however long the taglines run. */
.live-demo-tagline {
  flex: 1;
  color: var(--ink-dim);
  font-size: 0.95rem;
  line-height: 1.6;
}
.live-demo-actions {
  margin-top: 0.75rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border-soft);
}

/* A lone card would otherwise leave two thirds of the row empty and read as
   unfinished, so on its own it turns into one wide card with the screenshot
   beside the copy. Adding a second card reverts both rules automatically,
   with no edit here. */
.live-demo-grid:has(> :only-child) { grid-template-columns: 1fr; }
.live-demo-grid:has(> :only-child) .live-demo-card { flex-direction: row; }
.live-demo-grid:has(> :only-child) .live-demo-visual {
  flex: 1 1 50%;
  /* Height comes from the row, not the ratio, so the image fills whichever
     of the two columns turns out taller. */
  aspect-ratio: auto;
  min-height: 280px;
}
.live-demo-grid:has(> :only-child) .live-demo-body {
  flex: 1 1 50%;
  justify-content: center;
  padding: var(--space-4);
}

/* Two cards in a three-column track would leave the last column empty, which
   reads as a card that failed to load. They take half each until a third
   arrives and the default rule takes over again. */
.live-demo-grid:has(> :nth-child(2):last-child) { grid-template-columns: repeat(2, 1fr); }

/* Not everything worth linking to has a screen to photograph. This stands in
   for the screenshot with the figures the project is actually judged on. */
.live-demo-visual--figures {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-4);
  background:
    radial-gradient(ellipse 70% 90% at 10% 0%, rgba(91, 110, 245, 0.20), transparent 65%),
    radial-gradient(ellipse 70% 90% at 100% 100%, rgba(166, 108, 240, 0.16), transparent 65%),
    var(--bg-panel-raised);
}
/* The scrim exists to settle a bright screenshot into the card. There is no
   screenshot here, only text that needs its contrast left alone. */
.live-demo-visual--figures::after { display: none; }

.live-demo-figure + .live-demo-figure {
  padding-top: var(--space-3);
  border-top: 1px solid var(--border-soft);
}
.live-demo-figure-value {
  display: block;
  font-family: var(--font-display);
  font-weight: 300;
  font-size: clamp(1.7rem, 3.6vw, 2.4rem);
  line-height: 1.1;
  color: var(--ink);
}
.live-demo-figure-label {
  display: block;
  margin-top: 0.4rem;
  font-family: var(--font-mono);
  font-size: 0.88rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-dim);
}

/* The pulsing mint dot is the site's "running right now" signal, so a card
   that only leads to a write-up drops it rather than borrowing it. */
.live-demo-badge--study { color: var(--loop-violet); }
.live-demo-badge--study::before { display: none; }

@media (max-width: 820px) {
  .live-demo-grid { grid-template-columns: 1fr; }
  /* Restated because the two-card rule above outranks the line before it on
     specificity, and would otherwise survive into the narrow layout. */
  .live-demo-grid:has(> :nth-child(2):last-child) { grid-template-columns: 1fr; }
  /* Side by side stops working before the cards do, so the wide single-card
     layout folds back to the stacked one here. */
  .live-demo-grid:has(> :only-child) .live-demo-card { flex-direction: column; }
  .live-demo-grid:has(> :only-child) .live-demo-visual { aspect-ratio: 16 / 10; min-height: 0; }
  .live-demo-grid:has(> :only-child) .live-demo-body { padding: var(--space-3); }
}

/* ==========================================================================
   About teaser
   ========================================================================== */
.teaser-panel {
  background: var(--bg-panel);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  text-align: center;
  max-width: 900px;
  margin: 0 auto;
}
.teaser-panel .eyebrow { display: block; margin-bottom: var(--space-2); }
/* Two beats, not one paragraph. The first three sentences are the setup and now
   sit back in --ink-dim at a size the longer copy can carry; the closing pair is
   the argument this panel exists to make, so it is the brightest and largest
   thing in the box. Emphasis by size, weight and colour only: the site's usual
   "this line carries the point" device is a violet rule down the left, which has
   nothing to align to in a centred panel. */
.teaser-statement {
  font-family: var(--font-display);
  font-weight: 300;
  font-size: calc(clamp(1.1rem, 1.9vw, 1.35rem) * var(--display-scale));
  line-height: 1.55;
  color: var(--ink-dim);
  max-width: 62ch;
  margin-inline: auto;
}
.teaser-close {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: calc(clamp(1.25rem, 2.1vw, 1.55rem) * var(--display-scale));
  line-height: 1.45;
  color: var(--ink);
  max-width: 44ch;
  margin: var(--space-3) auto 0;
  text-wrap: balance;
}
.teaser-actions {
  display: flex;
  justify-content: center;
  gap: var(--space-2);
  margin-top: var(--space-4);
  flex-wrap: wrap;
}
@media (max-width: 820px) {
  .teaser-panel { padding: var(--space-4); }
}

/* ==========================================================================
   About page
   ========================================================================== */
.about-hero { padding-top: var(--space-5); padding-bottom: var(--space-4); }
.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: 0.9rem;
  color: var(--ink-dim);
  margin-bottom: var(--space-3);
}
.back-link:hover { color: var(--ink); }
.about-h1 {
  font-family: var(--font-display);
  font-weight: 300;
  font-size: calc(clamp(1.7rem, 3.2vw, 2.5rem) * var(--display-scale));
  line-height: 1.3;
  max-width: 26ch;
  margin-top: var(--space-2);
}
.about-body { padding-top: var(--space-2); }
.about-content { max-width: 700px; }
.about-content p {
  color: var(--ink-dim);
  font-size: 1.05rem;
  line-height: 1.75;
  margin-bottom: var(--space-3);
}
.about-pullquote {
  margin: var(--space-4) 0;
  padding-left: var(--space-3);
  border-left: 2px solid var(--loop-indigo);
  font-family: var(--font-display);
  font-weight: 300;
  font-style: normal;
  font-size: calc(clamp(1.4rem, 2.4vw, 1.8rem) * var(--display-scale));
  line-height: 1.4;
  color: var(--ink);
}
.about-divider {
  border: none;
  border-top: 1px solid var(--border-soft);
  margin: var(--space-5) 0;
}
.about-content .eyebrow { margin-bottom: var(--space-2); }
/* Links inside running text. The global reset takes colour and underline off
   every <a>, which is right for nav items and whole-card links, where the thing
   itself is obviously a control. Inside a paragraph it left these with no mark
   at all: not merely a subtle link, but one set in the same colour as the
   sentence around it. A link in a block of text that is distinguished by colour
   alone already fails WCAG 1.4.1, and this one did not even have the colour.
   Underlined and set brighter than the body copy, so it is marked twice. */
.about-content p > a {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--loop-violet);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.22em;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
.about-content p > a:hover {
  color: var(--loop-violet);
  text-decoration-color: var(--loop-violet);
}
.about-aside {
  font-style: italic;
  color: var(--ink-dim);
  font-size: 0.95rem;
  margin-top: var(--space-3);
}

/* --- Scroll reveal --- */
.reveal { opacity: 0; transform: translateY(14px); transition: opacity 0.6s ease, transform 0.6s ease; }
.reveal.in-view { opacity: 1; transform: translateY(0); }
.result-card.reveal:nth-child(1) { transition-delay: 0s; }
.result-card.reveal:nth-child(2) { transition-delay: 0.08s; }
.result-card.reveal:nth-child(3) { transition-delay: 0.16s; }

/* --- Footer --- */
/* Laid out the way .site-nav is at the top of the page: the element itself is
   the full-width band that carries the rule and the watermark, and the
   .container inside it is the row. It used to be both at once, which made the
   .container a flex item, shrink to its contents and ignore its own max-width
   -- the whole footer ended up as a clump in the middle of the page, 278px off
   the column every other section lines up with. */
.site-footer {
  border-top: 1px solid var(--border-soft);
  padding: var(--space-4) 0;
  color: var(--ink-dim);
  font-size: 0.9rem;
  position: relative;
  overflow: hidden;
}
.footer-mark {
  position: absolute;
  right: -60px;
  top: -140px;
  width: 320px;
  height: 320px;
  opacity: 0.05;
  pointer-events: none;
}
/* The bottom-bar arrangement: who owns the page on the left, the utility links
   right beside it, and where we are pushed to the far right. */
.footer-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  position: relative; /* over the watermark */
}
.footer-links { display: flex; align-items: center; gap: 1.5rem; }
.footer-link {
  color: var(--ink-dim);
  transition: color 0.15s ease;
}
/* Was declaring a transition to the colour it already had, so hovering a link
   did nothing at all. */
.footer-link:hover { color: var(--ink); }
.footer-location {
  font-family: var(--font-mono);
  font-size: 0.88rem;
  margin-left: auto;
}
/* Once the row wraps there is no "far right" to push to, so the location
   rejoins the links instead of stranding itself on a line of its own. */
/* Measured at 360px, the narrow end of phones still in use: the bar's two groups
   want 315px and only 308 is on offer, so they would wrap. The gutter comes off
   the container rather than the nav alone, so the bar stays aligned with the
   content under it, and the button gives up a little more of its padding. */
@media (max-width: 400px) {
  .container { padding-left: 1.15rem; padding-right: 1.15rem; }
  .nav-actions .btn { padding: 0.5rem 0.7rem; }
}
/* 320px is the old small iPhone. The bar wants 308 and has 280, so rather than
   let it push a horizontal scrollbar onto the whole page, the wordmark gives up
   the difference and the groups are allowed to compress. */
@media (max-width: 340px) {
  .wordmark-full { height: 20px; }
  .brand-group { gap: 0.4rem; min-width: 0; }
  .nav-actions { gap: 0.35rem; }
}

@media (max-width: 620px) {
  .footer-row { gap: 0.4rem 1.2rem; }
  .footer-location { margin-left: 0; }
}

/* ==========================================================================
   Hero loop
   ==========================================================================
   The path is 786.6 units long and the light takes 9s to travel it, so one
   second is 87.4 units. Everything else here is derived from those two
   numbers rather than picked: the light and the five nodes run on one clock,
   and a node fires because the light reached it, not on a schedule of its
   own. If the geometry is ever redrawn, the --t values in the markup have to
   be recomputed from the new arc lengths or the causality breaks silently.
   ========================================================================== */
.loop-stage-label {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  fill: var(--ink-dim);
}

.loop-track { fill: none; stroke: url(#heroTrack); stroke-width: 2; }

/* Two dashes on one path make a comet: a long dim body and, aligned to its
   leading edge, a short bright head. 240 - 125.9 = 114.1 is how far the body
   trails behind the head, and is why the trail keyframes start there. */
.loop-trail {
  fill: none;
  stroke: url(#heroGrad);
  stroke-width: 3.5;
  stroke-linecap: round;
  stroke-dasharray: 240 546.6;
  opacity: 0.3;
}
.loop-glow {
  fill: none;
  stroke: url(#heroGrad);
  stroke-width: 3.5;
  stroke-linecap: round;
  stroke-dasharray: 125.9 660.7;
  /* Reads as light rather than as a painted line. Cheap on a dark ground. */
  filter: drop-shadow(0 0 5px rgba(91, 110, 245, 0.55));
}

/* The light and the nodes are driven from hero-loop.js, not from keyframes with
   per-node delays. The send-back means the head stops and runs backwards, and a
   schedule cannot follow that: a node has to light because the head reached it.
   Everything below is the resting look and the three states the script sets. */
.loop-node-mark,
.loop-node .node-dot,
.loop-node .loop-node-icon,
.loop-stage-label {
  transition: stroke 0.35s ease, fill 0.35s ease, opacity 0.35s ease;
}

.loop-node .node-dot {
  fill: var(--bg-panel-raised);
  stroke: var(--border-strong);
  stroke-width: 2;
}
.loop-node .loop-node-icon { stroke: var(--ink-dim); }
.loop-stage-label { fill: var(--ink-dim); }

/* Reached by the light. */
.loop-node.is-lit .node-dot { stroke: var(--loop-violet); }
.loop-node.is-lit .loop-node-icon { stroke: var(--ink); }
.loop-node.is-lit .loop-stage-label { fill: var(--ink); }

/* Verify sits exactly on the crossing of the figure eight, and self-verifying
   systems are what the headline above it claims. It is the only node given the
   metric colour at rest, and the only one drawn larger, so the composition has a
   subject instead of five equal dots. */
.loop-node--verify .node-dot { stroke: rgba(94, 230, 200, 0.5); }
.loop-node--verify .loop-node-icon { stroke: var(--signal-mint); }
.loop-node--verify.is-lit .node-dot,
.loop-node--verify.is-pass .node-dot { stroke: var(--signal-mint); }
.loop-node--verify.is-lit .loop-stage-label,
.loop-node--verify.is-pass .loop-stage-label { fill: var(--signal-mint); }

/* Holding something back. */
.loop-node.is-caught .node-dot,
.loop-node.is-caught .loop-node-icon { stroke: var(--signal-amber); }
.loop-node.is-caught .loop-stage-label { fill: var(--signal-amber); }

/* The verdict sits on the crossing, so it always lands on track: the void-coloured
   outline keeps it readable without a plate behind it. Both numbers are in user
   units and the viewBox scales them, so they have to be read against the font size
   rather than as screen pixels. At 0.9 the outline separates the glyphs from the
   track; at 3 it was wider than the strokes it was meant to protect and the label
   rendered as a black smudge. */
.loop-verdict {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0;
  stroke: var(--bg-void);
  stroke-width: 0.9;
  paint-order: stroke;
  stroke-linejoin: round;
  transition: opacity 0.3s ease;
}
.loop-verdict--sent { fill: var(--signal-amber); }
.loop-verdict--ok { fill: var(--signal-mint); }
.hero-visual.show-sent .loop-verdict--sent,
.hero-visual.show-ok .loop-verdict--ok { opacity: 1; }

/* While the head runs backwards the body would appear to lead it, so it is held
   back to almost nothing and only the head reads. */
.hero-visual.is-reversing .loop-trail { opacity: 0.08; }
