/* ==========================================================================
   E-Tender landing page
   --------------------------------------------------------------------------
   Sister to the PropIntel landing page and built the same way: duroob tokens,
   then duroob components, then this file for what is specific to the page.

   One difference from PropIntel worth knowing. PropIntel is an endorsed venture
   carrying its own indigo, so it has to declare a venture accent layer. E-Tender
   wears duroob's stock accent, so it declares none: every accent here resolves
   through --accent-primary and friends straight out of duroob-tokens.css, which
   is purple on the light page and periwinkle on the drenched bands. Nothing in
   this file re-states an accent value.
   ========================================================================== */

.page {
  /* Cool neutral ground, the same near-white the PropIntel page uses, so the two
     sit together and the dark bands read as the same material. */
  --bg:             #F4F4F7;
  --surface:        color-mix(in srgb, var(--periwinkle-100) 30%, #E9E9EE);
  --border:         color-mix(in srgb, var(--neutral-200) 62%, #E9E9EE);
  --border-control: color-mix(in srgb, var(--neutral-500) 88%, #E9E9EE);
  --divider:        color-mix(in srgb, var(--neutral-100) 55%, #F4F4F7);

  /* The dark band base under the photographic bands. */
  --et-ground:      var(--purple-950);

  /* The before/after band's ground. A cool off-white a shade DEEPER than the
     page, not lighter: at 18% of periwinkle-100 into white it came out paler
     than --bg, so the band read as a hole in the page rather than a plane on
     it, and the frosted panes had nothing to sit against. It was indigo-1000 —
     the PropIntel stack band — until the figure was rebuilt from the reference,
     which is a saturated mass on near-white; see the note over .changes. */
  --et-band:        color-mix(in srgb, var(--periwinkle-100) 62%, #FFF);

  /* Softened panel corner: enough curve to stop reading as a wireframe, short of
     a rounded UI. Controls take it too, so nothing on the page is a pill. */
  --et-cut: 8px;

  /* A flat scrim over the finished band, the way an overlay sits over a modal.
     The photograph and its gradient are untouched underneath; this is the dial.
     Raise it for contrast, lower it for more glass. 0.40 is PropIntel's setting
     and the two pages share a texture, so they share the dial: --ink measures
     6.93:1 on the brightest rib and --ink-secondary 5.12:1. */
  --glass-modal: 0.40;

  /* One rhythm for the gap between a section heading and the block it introduces. */
  --et-head-gap: var(--space-8);
  --et-optical: .35rem;

  background: var(--bg);
  color: var(--ink);
  font: var(--text-body);
}

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
/* Jumped-to sections start clear of the top edge instead of flush against it. */
#walkthrough, #lifecycle, #changes, #ai, #contact { scroll-margin-top: var(--space-8); }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body { margin: 0; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
img, svg { display: block; max-width: 100%; }
/* Every glyph on the page is an inlined Lucide outline at 24px on a 24 grid.
   They carry stroke="currentColor", so colour comes from the element around
   them and nothing here restates an ink value. */
.icon { width: 20px; height: 20px; flex: none; }

/* Surfaces the browser draws, themed rather than left at defaults. */
.page ::selection { background: color-mix(in srgb, var(--accent-primary) 24%, transparent); color: var(--ink); }
.page { caret-color: var(--accent-primary); scrollbar-color: var(--border-control) transparent; scrollbar-width: thin; }
.page a { text-decoration: none; text-underline-offset: 0.18em; }
/* Only plain links take the colour of the text around them. A button carries its
   own ink from the component, and `.page a` (0,1,1) outranks `.dr-btn--primary`
   (0,1,0), so an unscoped `color: inherit` here silently repaints every anchor
   button with the band's ink: neutral-50 on periwinkle-400 measures 2.31:1. */
.page a:not(.dr-btn) { color: inherit; }
.page :focus-visible { outline: none;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-primary); border-radius: var(--et-cut); }
.page .dr-btn { border-radius: var(--et-cut); }

/* ---------- Measure ----------
   .shell owns the page gutter so a background can run edge to edge while its
   content stays on the measure. */
.shell { max-width: 1200px; margin: 0 auto; padding-inline: var(--space-6); }
@media (min-width: 900px) { .shell { padding-inline: var(--space-8); } }

section { padding-block: var(--space-8); }
@media (min-width: 900px) { section { padding-block: var(--space-16); } }

/* Sections divide on a full-bleed hairline, so the break reads across the whole
   page. The drenched bands carry their own edge and are left out. */
main > section:not(.drenched) + section:not(.drenched) { border-top: 1px solid var(--divider); }

/* ---------- Section heads ---------- */
.sechead { margin: 0 0 var(--et-head-gap); }
.sechead .sec { margin: 0 0 var(--space-4); }
.sechead .lede { margin: 0; color: var(--ink-secondary); font: var(--text-body-large); }
@media (min-width: 900px) {
  .sechead__in { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
    gap: var(--space-8) var(--space-16); align-items: start; }
  .sechead .sec { margin: 0; max-width: 15ch; }
  .sechead .lede { max-width: 54ch; padding-top: var(--et-optical); }
  /* The walkthrough opens with its title over the standfirst, not beside it. */
  /* With no standfirst beside it a title has no reason to be squeezed into the
     left column of a two-column head. */
  .sechead__in:not(:has(.lede)) { display: block; }
  .sechead__in:not(:has(.lede)) .sec { max-width: 26ch; }
}
/* Kept for a head that genuinely wants centring. Nothing uses it: every section
   on this page opens on the same left edge as the nav and the footer, and one
   centred heading in that column broke the page's only vertical line. */
.sechead--centred { text-align: center; }
.sechead--centred .sec { max-width: 24ch; margin-inline: auto; }

h2.sec { font: var(--text-h1); letter-spacing: -0.025em; margin: 0 0 var(--space-6);
  max-width: 22ch; text-wrap: balance; }
@media (max-width: 700px) { h2.sec { font: var(--text-h2); letter-spacing: -0.015em; } }
p.lede { font: var(--text-body-large); color: var(--ink-secondary); margin: 0; max-width: 68ch; }


/* ---------- The ground, shared by the hero, the flow band and the close ----------
   Three layers, bottom up: the fluted glass photograph on the element itself, a
   grain plate over it in overlay, then one dim so type sits clear of both.

   The texture is duroob's own, the one the brand book names for dark hero
   surfaces, and it is the same file the PropIntel page uses. The E-Tender export
   shipped a different photograph (hero-pleats.jpg) whose median luminance is
   0.006 against this one's 0.142: almost black except for one specular sweep,
   which read as a dark slab rather than as glass. */
.drenched { position: relative; isolation: isolate; overflow: hidden;
  color: var(--ink);
  background-image: url("../assets/duroob-texture-fluted-glass.jpg");
  background-size: cover; background-position: center;
  /* The grain lifts the ground, so the band lifts its secondary text to match.
     The dark theme's own --ink-secondary is periwinkle-400, which measures
     around 3:1 against the brightest rib of this photograph. periwinkle-200
     clears 4.5:1 and keeps the tint. Scoped here, not in the design system. */
  --ink-secondary: var(--periwinkle-200); }

.drenched::before { content: ""; position: absolute; inset: 0; z-index: -2;
  background-image: url("../assets/duroob-texture-grain.jpg");
  background-size: cover; background-position: center;
  mix-blend-mode: overlay; opacity: .62; pointer-events: none; }

.drenched::after { content: ""; position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(0deg,
      color-mix(in srgb, var(--purple-1000) calc(var(--glass-modal) * 100%), transparent),
      color-mix(in srgb, var(--purple-1000) calc(var(--glass-modal) * 100%), transparent)),
    linear-gradient(175deg,
      color-mix(in srgb, var(--et-ground) 44%, transparent) 0%,
      color-mix(in srgb, var(--et-ground) 60%, transparent) 55%,
      color-mix(in srgb, var(--et-ground) 72%, transparent) 100%); }

/* ---------- Navigation, inside the hero band ----------
   It lies over the hero rather than on it: no fill of its own, no shadow, and it
   does not stick on scroll. */
.nav { padding-block: var(--space-4); border-bottom: 1px solid color-mix(in srgb, var(--neutral-50) 14%, transparent); }
.nav__in { display: flex; align-items: center; gap: var(--space-8); }
.brand { display: flex; align-items: baseline; gap: var(--space-1); }
.brand__name { font: var(--text-h3); letter-spacing: -0.02em; color: var(--ink); }
.brand__by { font: var(--text-caption); color: var(--ink-secondary); }
.nav__links { display: none; gap: var(--space-6); margin-inline-start: auto; }
.nav__links a { font: var(--text-body); color: var(--ink-secondary); }
.nav__links a:hover { color: var(--ink); }
@media (min-width: 900px) { .nav__links { display: flex; } }
.nav__cta { display: none; margin-inline-start: auto; }
@media (min-width: 620px) { .nav__cta { display: inline-flex; } }
@media (min-width: 900px) { .nav__cta { margin-inline-start: 0; } }

/* ---------- Hero ---------- */
.hero { padding-block: 0 var(--space-16); }
.hero__in { display: grid; gap: var(--space-16); padding-top: var(--space-16); }
@media (min-width: 1000px) {
  /* The copy leads, so it takes the wider column. A narrower gutter buys it the
     room: at the even split the headline fell to four lines and broke "Award the
     right vendor." after "the", which separates the adjective from its noun. */
  /* 624 + 32 + 480 = 1136, the same measure the nav spans, so the figure's right
     edge lands on the gutter instead of hanging 75px past it. */
  .hero__in { grid-template-columns: minmax(0, 624fr) minmax(0, 480fr);
    gap: var(--space-16) var(--space-8); align-items: center; }
}
/* No balancing here. The headline is two sentences split by a <br>, and balance
   equalises the halves rather than filling each line, which is what produced the
   bad break. Left to wrap naturally each sentence fills its line first. */
.hero h1 { font: var(--text-display); letter-spacing: -0.03em; margin: 0 0 var(--space-6); }
@media (max-width: 900px) { .hero h1 { font: var(--text-h1); letter-spacing: -0.025em; } }
/* A shorter measure than the headline above it. Matching the two gives a flat
   left-aligned slab; stepping the second block in gives the rag something to do
   and keeps the line length readable. */
.hero__lede { font: var(--text-body-large); color: var(--ink-secondary); margin: 0 0 var(--space-8);
  max-width: 470px; }
.hero__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }

/* ---------- The hero figure ----------
   A stat rail against a product frame. The rail carries the machine voice: the
   numerals are mono because they are produced, the labels beside them are not. */
.figure { display: grid; gap: 0; grid-template-columns: minmax(0, 130px) minmax(0, 1fr);
  border-radius: var(--et-cut); overflow: hidden;
  border: 1px solid color-mix(in srgb, var(--neutral-50) 12%, transparent);
  background: color-mix(in srgb, var(--purple-1000) 62%, transparent); }
@media (max-width: 720px) { .figure { grid-template-columns: minmax(0, 1fr); } }

.stats { list-style: none; margin: 0; padding: var(--space-4); display: grid; gap: var(--space-6);
  align-content: start; }
@media (max-width: 720px) { .stats { grid-template-columns: repeat(3, 1fr); gap: var(--space-4); } }
.stat { display: block; }
.stat__n { display: block; font: var(--text-h1); font-family: var(--font-mono); letter-spacing: -0.02em;
  color: var(--ink); }
.stat__label { display: block; font: var(--text-caption); color: var(--ink-secondary); margin-top: var(--space-1); }

/* The product frame. It is a still of the real screen, so it declares itself
   light rather than inheriting the band's dark theme: a dark panel reading a
   dark band would lose its edges. Declaring the theme is also what lets the
   panel use --ink / --ink-secondary instead of hand-picked periwinkle steps,
   which is how the "Evaluation" label ended up at exactly 4.50:1.

   The three neutral overrides are repeated from .page on purpose. The light
   block in duroob-tokens.css re-points --surface at periwinkle-100, so a nested
   light island silently gets the full lilac surface back unless it restates the
   page's neutralised values. */
.app { --surface:  color-mix(in srgb, var(--periwinkle-100) 30%, #E9E9EE);
  --border:  color-mix(in srgb, var(--neutral-200) 62%, #E9E9EE);
  --divider: color-mix(in srgb, var(--neutral-100) 55%, #F4F4F7);
  background: var(--neutral-50); color: var(--ink);
  border-inline-start: 1px solid color-mix(in srgb, var(--neutral-50) 12%, transparent);
  display: flex; flex-direction: column; min-width: 0; }
.app__chrome { display: flex; gap: 6px; padding: var(--space-3) var(--space-4);
  background: var(--divider); border-bottom: 1px solid var(--border); }
.app__dot { width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--neutral-300); }
/* The sidebar is chrome; the panel beside it is the thing being shown, so the
   split favours the panel. At the old 132px the rail squeezed "Committee review"
   onto three lines and left the score no room. */
.app__body { display: grid; grid-template-columns: minmax(0, 132px) minmax(0, 1fr); flex: 1; min-width: 0; }
@media (max-width: 560px) { .app__body { grid-template-columns: minmax(0, 1fr); } }

.app__side { padding: var(--space-3); border-inline-end: 1px solid var(--border); min-width: 0; }
@media (max-width: 560px) { .app__side { display: none; } }
.app__brand { font: var(--text-body-large); font-weight: 600; margin: 0 0 var(--space-6); color: var(--ink);
  padding-inline: var(--space-2); }
/* The rows carry 8px of their own padding so the selected pill reads wider than
   its label. The brand and this label take the same inset, so every line of type
   in the sidebar sits on one edge. */
.app__grouplabel { font: var(--text-caption); text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--ink-secondary); margin: 0 0 var(--space-2); padding-inline: var(--space-2); }
.app__nav { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-1); }
.app__nav li { font: var(--text-caption); color: var(--ink-secondary); padding: 5px var(--space-2);
  border-radius: 4px; border: 1px solid transparent; }
/* A fill alone cannot mark something you operate unless it clears its ground, so
   the current row carries a fill and an edge. */
.app__nav li.is-current { background: var(--accent-tint); border-color: var(--accent-secondary);
  color: var(--ink); font-weight: 600; }

.app__main { padding: var(--space-3) var(--space-4); min-width: 0; }
.app__field { font: var(--text-caption); color: var(--ink-secondary); margin: 0 0 var(--space-6);
  padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: 6px;
  background: var(--neutral-50); }
.app__kicker { font: var(--text-caption); color: var(--ink-secondary); margin: 0 0 var(--space-1); }
.app__award { font: var(--text-h3); margin: 0 0 var(--space-4); color: var(--ink); }
.app__scorerow { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3);
  margin: 0 0 var(--space-4); }
.app__score { margin: 0; display: flex; align-items: baseline; gap: var(--space-1); }
.app__scoren { font: var(--text-h1); font-family: var(--font-mono); letter-spacing: -0.02em; color: var(--ink); }
.app__scoreof { font: var(--text-caption); font-family: var(--font-mono); color: var(--ink-secondary);
  white-space: nowrap; }
.app__rank { margin: 0; display: flex; align-items: center; gap: var(--space-2);
  border: 1px solid var(--border); border-radius: 6px; padding: var(--space-1) var(--space-2); }
.app__rank { white-space: nowrap; }
.app__rank span { font: var(--text-caption); color: var(--ink-secondary); }
.app__rank b { font: var(--text-caption); font-family: var(--font-mono); color: var(--ink); }

/* 28 segments standing for the weighted score: 24 carried, 4 not. 24 of 28 is
   85.7%, which is the 86 printed above it. */
.app__meter { display: flex; gap: 2px; height: 8px; margin: 0 0 var(--space-4); }
.app__meter i { flex: 1; border-radius: 1px; background: var(--periwinkle-400); }
.app__meter i.is-gap { background: color-mix(in srgb, var(--ink) 13%, transparent); }
.app__note { font: var(--text-caption); color: var(--ink-secondary); margin: 0;
  display: flex; align-items: center; gap: var(--space-2); }
.app__tick { flex: none; display: grid; place-items: center; color: var(--accent-secondary); }
.app__tick .icon { width: 13px; height: 13px; }

/* The score meter fills rather than arriving finished. One clip on the whole
   segmented bar instead of 28 staggered segments: same read, one animation, and
   the gaps at the end still land last because they are last in the wipe.
   It runs on load, not on scroll — the hero is above the fold, so there is
   nothing to scroll when it plays. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes meter-fill { from { clip-path: inset(0 100% 0 0); }
                          to   { clip-path: inset(0 0 0 0); } }
  .app__meter { animation: meter-fill 1.1s cubic-bezier(.22, .61, .36, 1) .45s both; }
}

/* ---------- Walkthrough ----------
   The same window PropIntel's walkthrough sits in: a dark application frame
   with a light-on-dark chrome bar, and the film's port inside it. It was a
   white card with a bordered chrome strip and a caption bar underneath — a
   document, not a screen, and it read as a different product from its sister
   site. A screen is dark because a screen that is off is dark.

   One third copy, two thirds port. The heading lives inside the left column
   rather than above the grid, so the port's top edge starts on the heading's
   line instead of below it. */
.watch__grid { display: grid; gap: var(--space-8); }
.watch__copy .sec { margin: 0 0 var(--space-4); max-width: 18ch; }
.watch__copy p { margin: 0; color: var(--ink-secondary); font: var(--text-body-large); }
@media (min-width: 960px) {
  .watch__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
    gap: var(--space-16); align-items: start; }
  /* The heading holds the port's top line and the standfirst drops to the foot
     of the column, so the short copy block spans the port's height instead of
     hanging under its own title with empty column beneath it. */
  .watch__copy { align-self: stretch; display: flex; flex-direction: column;
    justify-content: space-between; }
  .watch__copy p { max-width: 34ch; }
}

.reel { position: relative; margin: 0; border-radius: var(--et-cut);
  overflow: hidden; background: var(--purple-950);
  border: 1px solid color-mix(in srgb, var(--periwinkle-300) 22%, transparent);
  box-shadow: 0 22px 52px rgba(13, 11, 25, 0.38);
  padding: var(--space-2); padding-top: 0; }
.reel__chrome { display: flex; align-items: center; gap: var(--space-3);
  height: 30px; padding: 0 var(--space-2); }
.reel__dots { display: flex; gap: var(--space-1); }
/* Square, not round: window furniture, drawn the way the chrome bar is drawn. */
.reel__dots i { width: 6px; height: 6px; border-radius: 1px;
  background: color-mix(in srgb, var(--periwinkle-300) 46%, transparent); }
/* A file path is a machine-produced string, so it takes the mono voice. */
.reel__path { font-family: var(--font-mono); font-size: .68rem; line-height: 1rem;
  letter-spacing: 0; color: color-mix(in srgb, var(--periwinkle-200) 72%, transparent); }

/* The port. Darker than its frame, with a hairline hatch across it so an empty
   16:9 rectangle reads as a screen awaiting a film rather than as a hole. */
.reel__stage { position: relative; display: grid; place-items: center;
  aspect-ratio: 16 / 9; border-radius: calc(var(--et-cut) - 1px);
  background:
    repeating-linear-gradient(135deg,
      color-mix(in srgb, var(--periwinkle-200) 4%, transparent) 0 10px,
      transparent 10px 20px),
    var(--purple-1000); }
/* Not a control: there is nothing to press yet. It is the one cue that the
   frame stands in for a film. */
.reel__play { display: block; width: 4.5rem; height: 4.5rem;
  color: var(--periwinkle-200); }
.reel__play svg { display: block; width: 100%; height: 100%; }
.reel__label, .reel__time { position: absolute; bottom: var(--space-4); margin: 0;
  font-family: var(--font-mono); font-size: .69rem; line-height: 1rem;
  letter-spacing: 0; color: color-mix(in srgb, var(--periwinkle-200) 62%, transparent); }
.reel__label { left: var(--space-4); }
.reel__time { right: var(--space-4); }

/* ---------- Lifecycle ----------
   A single rail with the stages hung off it. One column on small screens; on
   wide ones the rail centres and the rows alternate sides. */
/* The gap is what makes the rail visible: it IS the run of line between one
   numeral and the next, so it has to be generous enough to read as a rail
   rather than as a seam. Both this and the diameter are bound to viewport
   height, because five numerals and four runs have to fit the window the
   section pins in — at 900px tall that comes to 820 of 900. */
.cycle { --cyc-gap: clamp(18px, 3vh, 40px);
  /* The numeral's diameter, and the unit the rail is measured in. Bound to
     viewport height so five of them always fit the window they pin in. */
  --cyc-mark: clamp(104px, 14vh, 144px); }
.cycle__list { list-style: none; margin: 0; padding: 0; position: relative;
  display: grid; gap: var(--space-8); }

/* The rail pins while it draws, the same device the before/after band uses and
   the one the PropIntel funnel and stack use: a runway of empty scroll after
   the content, and the content stuck to the middle of the window for the length
   of it. A five stage sequence is exactly the kind of thing worth holding still
   while it assembles.

   --pinH is derived from the gap rather than measured, so it cannot drift out
   of step: five rows and a heading come to 568px, plus the four gaps between
   them. max(0px, ...) keeps the offset from going negative. */
@media (min-width: 1000px) {
  .cycle__stage { --runway: 110vh; --pinH: calc(var(--cyc-mark) * 5 + var(--cyc-gap) * 4 + 82px); }
  .cycle__stage::after { content: ""; display: block; height: var(--runway); }
  .cycle__pin { position: sticky; top: max(0px, calc((100vh - var(--pinH)) / 2)); }
}
.cycle__row { position: relative; padding-inline-start: calc(var(--space-16) + var(--space-2)); }
/* A numbered circle, not an icon tile. Five glyphs in five rounded squares was
   a second icon system on a page that already has one, and none of the five
   said anything the stage name did not. The number is the only thing the mark
   has to carry: this is a sequence, and the rail is what makes it one. */
.cycle__mark { position: absolute; inset-inline-start: 0; top: 0;
  width: 44px; height: 44px; border-radius: var(--radius-full);
  /* The RESTING state is the completed one — filled, the way the scrub leaves
     it. The animation runs from empty to this, so a narrow screen, a browser
     without scroll timelines and a reader who asked for less motion all get the
     finished list rather than a lighter, different-looking one. */
  background: var(--purple-950); border: 1px solid var(--purple-950);
  display: grid; place-items: center; color: var(--periwinkle-200);
  font: var(--text-caption); font-family: var(--font-mono); letter-spacing: 0.04em; }
/* The rail runs between the marks rather than behind them, and it is two
   strokes on the same line: a hairline for the whole run, and a heavier one
   over the top for the part already travelled. Both sit in the same rule so
   they cannot drift apart. */
.cycle__row:not(:last-child)::before,
.cycle__row:not(:last-child)::after { content: ""; position: absolute;
  inset-inline-start: 21px; top: 44px; bottom: calc(var(--space-8) * -1);
  width: 1px; background: var(--periwinkle-300); }
/* The travelled part. Same ink as a filled numeral, so the path and the stages
   it joins read as one object rather than as a line beside some circles. Two
   pixels: enough to carry, not enough to become a bar. Its RESTING state is
   drawn, matching the numerals — see .cycle__mark. */
.cycle__row:not(:last-child)::after { width: 2px;
  background: var(--purple-950); transform-origin: top; }
.cycle__body { min-width: 0; }
.cycle__title { font: var(--text-h3); margin: 0 0 var(--space-2); }
.cycle__text { font: var(--text-body); color: var(--ink-secondary); margin: 0; max-width: 52ch; }

/* The numerals run down a rail in the middle of the measure and the stages
   alternate around it. Big enough to hold the page: at 44px they were correct
   and inert, and a five-stage sequence is the spine of the product, not a
   footnote to it.

   The rail is drawn BETWEEN the numerals rather than behind them, so nothing
   has to be masked out and an empty numeral mid-scrub stays a clean ring. */
@media (min-width: 900px) {
  .cycle__list { gap: var(--cyc-gap); grid-auto-rows: 1fr; }
  .cycle__row { padding-inline-start: 0; display: grid;
    grid-template-columns: minmax(0, 1fr) var(--cyc-mark) minmax(0, 1fr);
    gap: var(--space-8); align-items: center; }

  /* From the numeral's bottom edge to the next one's top: half of this row
     below the mark, the gap, then half of the next row above its mark. */
  .cycle__row:not(:last-child)::before,
  .cycle__row:not(:last-child)::after {
    inset-inline-start: 50%; translate: -50% 0;
    top: calc(50% + var(--cyc-mark) / 2);
    height: calc(100% - var(--cyc-mark) + var(--cyc-gap)); bottom: auto; }

  .cycle__mark { position: static; grid-column: 2; grid-row: 1;
    justify-self: center;
    width: var(--cyc-mark); height: var(--cyc-mark);
    font: 500 calc(var(--cyc-mark) * 0.34) / 1 var(--font-mono);
    letter-spacing: -0.02em; }

  .cycle__row:nth-child(odd) .cycle__body { grid-column: 3; }
  .cycle__row:nth-child(even) .cycle__body { grid-column: 1; grid-row: 1;
    text-align: end; margin-inline-start: auto; }

  .cycle__body { max-width: 38ch; perspective: 800px; }
  .cycle__text { max-width: none; }
}
/* The stages arrive one after another. Pinned, every row sits at
   the same place on screen for the whole scrub, so "as this row enters" stops
   meaning anything — the sequence is driven by how far through the pin you are.
   `contain` is the stretch where the section fills the window, which is the
   stretch it is pinned for.

   Resting state is the finished list: the rules only exist inside @supports and
   a reduced-motion query. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) and (min-width: 1000px) {
    /* A stage completes in three beats: the numeral fills, its heading slides
       out from under it towards the edge it points at, and the sentence folds
       down from the heading's baseline. */
    @keyframes cyc-fill {
      from { background-color: transparent; border-color: var(--periwinkle-300);
             color: var(--accent-primary); }
      to   { background-color: var(--purple-950); border-color: var(--purple-950);
             color: var(--periwinkle-200); } }
    /* Out FROM the numeral, so the direction of travel is different on each
       side and the page reads as opening outwards rather than sliding one way. */
    @keyframes cyc-out-r { from { opacity: 0; translate: -44px 0; } to { opacity: 1; translate: 0 0; } }
    @keyframes cyc-out-l { from { opacity: 0; translate: 44px 0; } to { opacity: 1; translate: 0 0; } }
    @keyframes cyc-fold { from { opacity: 0; transform: rotateX(-78deg); }
                          to   { opacity: 1; transform: rotateX(0deg); } }
    /* The run between two numerals, drawn top to bottom. */
    @keyframes cyc-path { from { scale: 1 0; } to { scale: 1 1; } }

    .cycle { view-timeline: --et-cycle block; }
    .cycle__row:nth-child(1) { --i: 0; }
    .cycle__row:nth-child(2) { --i: 1; }
    .cycle__row:nth-child(3) { --i: 2; }
    .cycle__row:nth-child(4) { --i: 3; }
    .cycle__row:nth-child(5) { --i: 4; }
    .cycle__mark, .cycle__title, .cycle__text,
    .cycle__row:not(:last-child)::after {
      animation-timeline: --et-cycle; animation-timing-function: linear;
      animation-fill-mode: both; }

    .cycle__mark { animation-name: cyc-fill;
      animation-range: contain calc(5% + var(--i) * 17%) contain calc(14% + var(--i) * 17%); }
    .cycle__row:nth-child(odd) .cycle__title { animation-name: cyc-out-r; }
    .cycle__row:nth-child(even) .cycle__title { animation-name: cyc-out-l; }
    .cycle__title {
      animation-range: contain calc(7% + var(--i) * 17%) contain calc(16% + var(--i) * 17%); }
    .cycle__text { transform-origin: top center; animation-name: cyc-fold;
      animation-range: contain calc(12% + var(--i) * 17%) contain calc(22% + var(--i) * 17%); }
    /* Between this numeral finishing and the next one starting, so the line is
       literally the move from one stage to the next. */
    .cycle__row:not(:last-child)::after { animation-name: cyc-path;
      animation-range: contain calc(13% + var(--i) * 17%) contain calc(22% + var(--i) * 17%); }
  }
}

/* ---------- Before and after: one figure, two sides ----------
   The band is a light ground carrying a single drawn figure. The problems sit
   on the left inside a bloom, the gains hang off an arc on the right, and the
   semicircle between them is the product.

   It runs LIGHT, unlike the PropIntel stack band it was first matched to. The
   reference this is built from is a saturated mass glowing on a near-white
   ground with white plates floating in it, and none of that survives being
   inverted: on the dark band the bloom had nothing to light and the figure's
   rib band went to grey. One token, --et-band, decides it.

   The fluted glass belongs to the hero and the close. Putting it here as well
   made three photographic bands out of seven and left the diagram sitting on a
   texture it had no relationship to. */
/* No overflow:hidden here. It would make the band a scroll container, and a
   sticky child of a container that does not scroll never sticks. Nothing needs
   clipping any more: the figure is a whole circle inside its own square box,
   and the box sits inside the grid. */
.changes {
  /* The band's measuring unit, declared here rather than on .flow-grid because
     the pin needs it too. It is bounded by viewport HEIGHT as well as width:
     the pinned content is this radius doubled plus 320px of heading and labels,
     and if that comes out taller than the window there is nowhere to pin it.
     Most laptop viewports are 750-800px once browser chrome is taken off, so
     20vw alone put the figure out of reach of the very screens it has to pin
     on. */
  --orb-w: clamp(160px, min(20vw, 26vh), 276px);
  background: var(--et-band); }

/* The band pins while its animation runs, the way the PropIntel funnel and
   stack pin theirs: a runway of empty scroll after the content, and the content
   itself stuck to the middle of the viewport for the length of it. The runway
   is what the scrub is spent on — without it the whole sequence would have to
   fit into the band simply passing by.

   This was guarded on `min-height: 860px` at first, which meant it did not pin
   at all on an ordinary laptop: 872px of content does not fit a 780px window,
   so the pin was switched off rather than the band being made to fit. --orb-w
   carries a 28vh term now, so the content is always shorter than the window and
   the pin always applies.

   --pinH is derived, not measured: two radii plus the 320px of heading, gap and
   labels above the figure. It only feeds the `top` that centres it, and max()
   keeps that from going negative and holding the heading above the window. */
@media (min-width: 1000px) {
  .changes__stage { --runway: 120vh; --pinH: calc(var(--orb-w) * 2 + 320px); }
  .changes__stage::after { content: ""; display: block; height: var(--runway); }
  .changes__pin { position: sticky;
    /* max(0px, ...) not max(24px, ...): the floor is only there to stop a
       negative top, and a positive floor beats the centring on a short window
       and pushes the foot of the band past the bottom of it. */
    top: max(0px, calc((100vh - var(--pinH)) / 2)); }
}

/* --orb-w is the band's measuring unit and lives on the grid, not on the
   figure, because both columns are set out from it. */
.flow-grid {
  /* The run between a point on the arc and the block hanging off it. One value
     for all four: every leader is the same length, so the blocks' left edges
     trace the arc itself and the column reads as following the curve.

     96, not 44, because the leader has to clear the arc across the whole HEIGHT
     of its block, not just at the point it is drawn from. The arc sweeps right
     as it comes back towards the equator, so it was passing through the second
     line of the outer blocks. 96 leaves 26px at the tightest. */
  --orb-lead: 96px;
  /* The largest and smallest cosine among the four node angles: the first sets
     where the blocks can start, the second where the outermost pair actually
     do — which is where the "With E-Tender" label has to sit to be over the
     thing it names. */
  --orb-reach: 0.9619;
  --orb-far: 0.5724;
  position: relative; display: grid; gap: var(--space-6);
  grid-template-columns: minmax(0, 1fr); }
@media (min-width: 1000px) {
  /* Two rows: the pair of labels, then the pair of columns. The labels are grid
     children rather than column children so the two sit on one line — inside
     their columns the right one had to clear the top node's text block, and
     there is no height at which it could. */
  /* 366 + 64 puts the figure's flat edge at 430 of 1136, which centres the
     semicircle — it is one radius wide, so its own middle lands on the middle
     of the band. At 300 the whole figure sat in the right half and the
     composition leaned left. */
  .flow-grid { grid-template-columns: minmax(0, 366fr) minmax(0, 706fr);
    grid-template-rows: auto minmax(0, 1fr); gap: var(--space-4) var(--space-16); }
}

/* Lowercase, because the product's name is. duroob sets its own name lowercase
   and e-tender follows it, so a label that contains the name cannot be an
   uppercase label. The letterspacing and the mono face carry the label voice
   instead of the casing. */
.flow-lab { font: var(--text-caption); font-family: var(--font-mono);
  text-transform: lowercase;
  letter-spacing: 0.14em; color: var(--ink-secondary); margin: 0; }
@media (min-width: 1000px) {
  /* "Before" sits on the page's own left edge, with the cards and the section
     title. "With" sits where its blocks start, not on its column's edge — the
     column begins at the circle's seam, which is 254px to the left of anything
     it names, and the label was floating over the figure. */
  .flow-lab--after { margin-inline-start: calc(var(--orb-w) * var(--orb-far) + var(--orb-lead)); }
}
@media (max-width: 999px) {
  /* Stacked, each label has to sit with the list it names, and source order
     puts them both first. */
  .flow-lab { order: -1; }
  .flow-lab--after { order: 1; }
  .problem-list { order: 0; }
  .orb { order: 2; }
}

/* ---------- The problems ----------
   Compact plates, all one width, floating in the bloom. They carry no connector:
   the drawn line belongs to the side that resolves into the figure, and a fan of
   beziers from here read as a funnel bracket in a different language from the
   leaders opposite. */
/* z-index, because the ghost is a child of .orb — later in source order — and
   reaches back under this column. The cards have to sit in front of it.

   3, not 1: .flow-anim sits at 2, so at 1 every run drew straight OVER the card
   it starts from — a dotted line and the travelling dot crossing the label.
   The path already begins at the card's right edge; it was only the stacking
   that was wrong. Above the runs, the line emerges from behind the card, which
   is also the right read: the connection leaves the card, it does not sit on
   top of it. */
.problem-list { position: relative; z-index: 3; display: grid; align-content: center; }

.prob-list { position: relative; z-index: 1; list-style: none; margin: 0; padding: 0;
  display: grid; gap: var(--space-6); }
/* Capped and left-aligned. The cards sit in FRONT of the out-of-focus half, not
   beside it and not tucked into it: the circle reaches back to the far side of
   the column, the stack covers its inner part, and what stays visible is the
   band between the cards and the seam. Pushing the stack right instead put the
   cards inside the mass and left the column's outer half empty. */
/* The cards hug their label rather than sharing one width. Each is one line
   now, so a fixed width left "No audit trail" sitting in a card sized for
   "Fragmented vendor data" with the difference as dead space. */
.prob-list { justify-items: start; }
.prob { max-width: 100%; }
/* ---------- Pane: the glass ----------
   A component, not a card treatment: everything that makes it glass lives here
   and nothing about what it contains. Put `pane` on anything that should sit in
   front of the figure.

   It is the browser's half of Figma's Glass effect. backdrop-filter blurs and
   re-saturates whatever is behind; the fill is a white thin enough to see the
   figure through it; the grain is the frost itself, laid over the content the
   way it would be in a real pane rather than under it. What CSS has no
   primitive for is the refraction Figma adds on top — the bending and the
   apparent thickness at the rim — so the edges are drawn.

   Depth comes from two things. The first two insets are the pane's own edges:
   light caught on the top bevel, the underside in shadow, which gives it a
   front and a back. The three cast shadows are one object at three distances —
   a tight contact shadow, a mid shadow for the gap it holds, and a wide ambient
   one. A single shadow reads as a sticker; the stack reads as an object above
   the drawing.

   A frost needs something behind it to work on, so where the figure is not
   drawn the fill goes opaque and the grain comes off — a frost over nothing is
   not glass, it is an unexplained tint. */
.pane { position: relative; isolation: isolate; border-radius: var(--radius-lg);
  /* The glass's own colour, so a variant can tint the pane without restating
     what makes it glass. */
  --pane-tint: #FFF;
  background: color-mix(in srgb, var(--pane-tint) 24%, transparent);
  backdrop-filter: blur(28px) saturate(165%);
  -webkit-backdrop-filter: blur(28px) saturate(165%);
  /* A bright rim, and a hairline of ink just outside it. On the figure the white
     rim is what reads; where the pane overhangs onto clear band, white on white
     is nothing and the outer hairline is what holds the shape. */
  border: 1px solid color-mix(in srgb, #FFF 88%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--purple-950) 6%, transparent),
              0 1px 0 color-mix(in srgb, #FFF 95%, transparent) inset,
              0 -1px 0 color-mix(in srgb, var(--purple-950) 12%, transparent) inset,
              0 1px 2px color-mix(in srgb, var(--purple-950) 10%, transparent),
              0 12px 24px -10px color-mix(in srgb, var(--purple-950) 22%, transparent),
              0 30px 56px -26px color-mix(in srgb, var(--purple-950) 28%, transparent); }
/* The house grain, at a tenth of the strength the photographic bands use. Over
   the content, because it is the surface you are looking through. Overlay at
   0.10 moves ink at 0.09 luminance by 0.003 — it textures the pane without
   touching what it covers. */
.pane::after { content: ""; position: absolute; inset: 0; z-index: 1;
  border-radius: inherit; pointer-events: none;
  background-image: url("../assets/duroob-texture-grain.jpg");
  background-size: 420px; mix-blend-mode: overlay; opacity: 0.10; }
@media (max-width: 999px) {
  .pane { background: #FFF; backdrop-filter: none; -webkit-backdrop-filter: none;
    border-color: color-mix(in srgb, var(--purple-950) 8%, transparent); }
  .pane::after { display: none; }
}

/* The problem itself: layout, and the faintest wash of the error ramp so the
   pane itself says what kind of thing it holds. */
.prob { --pane-tint: color-mix(in srgb, var(--error-100) 45%, #FFF);
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-3) var(--space-4); }
/* Flagged, not counted: these are four unrelated things going wrong, so an index
   was claiming an order they do not have. A dot, not a glyph in a disc — a
   40px badge with a flag in it was a lot of furniture to say one bit, and the
   ring around the dot is the part that reads as a status at a glance. */
.prob__dot { flex: none; width: 10px; height: 10px; border-radius: var(--radius-full);
  background: var(--error-600);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--error-600) 14%, transparent); }
.prob__t { font: var(--text-body); font-weight: 500; color: var(--ink); }

/* ---------- The orb ----------
   The four things E-Tender fixes are nodes on one system, not four loose cards,
   so they hang off an arc drawn around it.

   The figure is one asset, assets/orb.svg, inlined rather than linked because
   every colour in it is currentColor with an opacity, so it takes its ink from
   this stylesheet. It was assembled from CSS layers first — a div for the mass,
   a gradient for the ribs, a blurred div for the glow — and that failed twice:
   a repeating-conic-gradient cannot draw sub-degree ribs without aliasing into
   moire, and the layers each needed their own positioning, which is how a class
   name ended up shared between the disc and the node text. One file moves as
   one object and can be dropped into Figma as one vector.

   Regenerate it with tools/make-orb.py if the proportions change.

   margin:0 because this is a <figure>, and the browser default of 1em 40px was
   holding the whole column 40px inside its grid cell. */
.orb {
  /* Half the heading's line-height. The leader points at the HEADING, not at the
     middle of the block — centred on the block it arrived in the gap between the
     title and the sentence, pointing at nothing. */
  --orb-cap: 16px;
  /* The haze on the "before" half lives in the SVG, in user units, so it scales
     with the figure rather than with the viewport. tools/make-orb.py, HAZE. */
  position: relative; z-index: 0; margin: 0; }
@media (min-width: 1000px) {
  /* Two radii for the figure, plus enough room for half a text block above the
     top node and below the bottom one. The four angles are chosen by their SINE,
     not by even angular steps: -1, -1/3, +1/3, +1, so the blocks are evenly
     spaced down the page and the outer pair land exactly on the ends of the arc,
     which is where the reference hangs its outermost labels. Even angles put the
     middle pair 242px apart and the outer ones 147px from them. */
  .orb { min-height: calc(var(--orb-w) * 2 + 160px); }
}

/* The wrapper only places and colours the figure; the drawing is all in the SVG.
   It is sized one radius by two, matching the viewBox, because a semicircle is
   inherently 1:2 and any other ratio makes preserveAspectRatio letterbox it —
   at 0.938:1 the figure drew 208px inside a 391px box and the glow, which is
   positioned in viewBox units, ended up 157px short of the nodes. */
/* Centred ON the seam, not beside it: the figure is a whole circle, so the
   element is square and half of it reaches back over the problems. The viewBox
   is square too, which is what keeps preserveAspectRatio from letterboxing it —
   at 0.938:1 an earlier version drew 208px inside a 391px box and every point
   measured off the radius landed short. */
.orb__disc { position: absolute; inset-inline-start: calc(var(--orb-w) * -1); top: 50%;
  width: calc(var(--orb-w) * 2); height: calc(var(--orb-w) * 2); translate: 0 -50%;
  pointer-events: none; color: var(--accent-primary); }
/* The out-of-focus half runs one step brighter on the ramp. Blur flattens
   chroma, and purple-600 came back grey. */
#orb-before { color: var(--purple-500); }
/* Depth, drawn the way the PropIntel stack draws it rather than filtered in.
   There the lowest layer is a dashed outline with no fill and the slabs above
   carry derived darker faces; here the footprint is #orb-ground and this is the
   contact shadow that goes with it. It is hung on the two mass paths alone —
   on the whole figure it would trace all 138 ribs, which is both expensive and
   a mess. */
.orb-mass { filter: drop-shadow(0 14px 22px
              color-mix(in srgb, var(--purple-950) 16%, transparent)); }
/* The footprint sits under everything and reads as ground, so it stays quiet
   and takes the band's ink rather than the figure's accent. */
#orb-ground { color: var(--purple-950); }
/* overflow:visible because the haze on the left half is an SVG filter whose
   region reaches outside the square viewBox; clipped, the blurred edge would end
   on a straight line. .changes crops whatever reaches the band's edge. */
.orb__fig { width: 100%; height: 100%; display: block; overflow: visible; }

/* Centred ON the circle's centre, and set as an annotation rather than a title.
   It was an h3 in a box that started at the SEAM, so it sat 0.21 radii right of
   the middle of the thing it names — neither centred on the circle nor aligned
   to anything else — and at heading weight it read as a word dropped into the
   drawing rather than part of it. Mono, uppercase and letterspaced is the same
   voice the band's own labels use, and the seam breaks around it. */
.orb__name { position: absolute; top: 50%; inset-inline-start: 0;
  margin: 0; translate: -50% -50%; white-space: nowrap; text-align: center;
  font: var(--text-caption); font-family: var(--font-mono);
  text-transform: lowercase;
  /* Scales with the radius, because the gap in the seam it has to sit inside
     does too. */
  font-size: clamp(12px, calc(var(--orb-w) * 0.062), 16px);
  letter-spacing: 0.14em; color: var(--ink); }

/* The nodes hang off points on the arc rather than off a stack. Each carries the
   cosine and sine of its own angle. The four are chosen by their SINE, not by
   even angular steps — -0.82, -0.2733, +0.2733, +0.82 — so the blocks sit on a
   regular vertical rhythm. Even ANGLES put the middle pair 242px apart and the
   outer ones 147px from them. The outermost pair stop short of the poles so the
   arc has somewhere to end and the blocks have room to clear it. */
.orb__nodes { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; }

/* No plate. The reference sets these as plain text on the ground, and the glass
   panes they used to sit in were four different widths, because each one ran
   from its own point on the arc to the gutter.

   Each block now starts a fixed --orb-lead out from its OWN point on the arc, so
   the four left edges sit on the curve rather than on a straight line. They
   share one width — set by the longest reach, so nothing overruns the gutter —
   which keeps the text measure identical across all four; the outer pair simply
   stop short of the gutter, which is the curve showing. */
.orb__node { position: absolute;
  inset-inline-start: calc(var(--cos) * var(--orb-w) + var(--orb-lead));
  inset-inline-end: auto;
  width: calc(100% - var(--orb-w) * var(--orb-reach) - var(--orb-lead));
  top: calc(50% + var(--sin) * var(--orb-w));
  translate: 0 calc(var(--orb-cap) * -1); }

/* The leader: a hairline from the block back to the arc, with a dot sitting on
   the arc itself. One length for all four. */
/* Anchored by its LEFT edge at the arc, not by its right edge at the block.
   Same resting position either way, but it means the line can be grown from
   zero outward — pinned at the block it would have grown inward, back towards
   the orb, which is the wrong direction for what it is doing. */
.orb__leader { position: absolute; inset-inline-start: calc(var(--orb-lead) * -1);
  top: var(--orb-cap);
  width: var(--orb-lead); height: 1px; translate: 0 -50%;
  background: color-mix(in srgb, var(--accent-primary) 55%, transparent); }
.orb__leader::after { content: ""; position: absolute; inset-inline-start: 0; top: 50%;
  width: 7px; height: 7px; translate: -50% -50%;
  border-radius: var(--radius-full); background: var(--accent-primary); }

.orb__lead { display: block; font: var(--text-h2); letter-spacing: -0.02em;
  color: var(--ink); }
.orb__body { display: block; margin-top: var(--space-3); max-width: 44ch;
  font: var(--text-body); color: var(--ink-secondary); }

@media (max-width: 999px) {
  .orb { min-height: 0; display: grid; gap: var(--space-6); }
  .orb__disc, .orb__leader, .orb__name { display: none; }
  /* No arc to hang from once the figure is gone. The gap is wider than the
     problems' because each of these opens on an h2, and 24px under a 28px
     heading reads as one run-on block. */
  .orb__nodes { position: static; display: grid; gap: var(--space-8); }
  .orb__node { position: static; translate: none; width: auto; }
}


/* ---------- Motion: the band assembles as you scroll ----------
   One run per node, drawn as a single two-segment path: out of a problem card,
   straight in to the circle's centre, then straight out along that node's own
   radius to its point on the arc. A dot rides the head of it, the dotted trail
   is revealed behind the dot by a mask, and when the dot lands the leader grows
   outward and the text arrives. Four of them, staggered.

   The whole thing is scrubbed by the browser against the band's own view
   timeline — no scroll listeners, nothing on the main thread. js/flow.js only
   measures the geometry and writes the paths; it does not animate.

   The resting state of this band is the state with NO runs in it: the trails
   and the dots are transparent at rest, and the leaders and text are simply
   visible. So a browser without scroll timelines, a reader who has asked for
   less motion, and a page where the script never ran all get the finished
   composition rather than an empty one. That is why every animation here only
   exists inside the @supports block. */
.flow-anim { position: absolute; inset: 0; z-index: 2;
  width: 100%; height: 100%; pointer-events: none; overflow: visible; }
/* The connection itself: a solid line, revealed behind the dot as it travels.
   The run was dots alone and read as a suggestion rather than a link — the
   point of the sequence is that the card and the node end up joined. */
.flow-link { fill: none; stroke: var(--accent-primary); stroke-width: 1.25;
  stroke-linecap: round; opacity: 0; }
/* The dots ride on top of it as texture, in the same language as the figure's
   own spokes and rings. */
.flow-run { fill: none; stroke: var(--accent-primary); stroke-width: 2.5;
  stroke-linecap: round; stroke-dasharray: 0.1 7; opacity: 0; }
.flow-runner { fill: var(--accent-primary); opacity: 0; }
@media (max-width: 999px) { .flow-anim { display: none; } }

@keyframes fa-reveal { from { stroke-dashoffset: var(--len); } to { stroke-dashoffset: 0; } }
/* Held at full while the dot is still running and while the leader draws, then
   retired. It used to start fading at 72% of the RUN's own range, so the line
   was dimming while the dot was still on it. */
@keyframes fa-trail {
  0%   { opacity: 0; }
  8%   { opacity: 1; }
  70%  { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes fa-link {
  0%   { opacity: 0; }
  8%   { opacity: 0.55; }
  70%  { opacity: 0.55; }
  100% { opacity: 0; }
}
@keyframes fa-runner {
  0%   { offset-distance: 0%;   opacity: 0; }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { offset-distance: 100%; opacity: 0; }
}
@keyframes fa-dot { from { scale: 0; } to { scale: 1; } }
@keyframes fa-leader { from { width: 0; } to { width: var(--orb-lead); } }
@keyframes fa-text { from { opacity: 0; translate: 14px 0; } to { opacity: 1; translate: 0 0; } }
/* The cards come in from the left, which is the direction their runs then set
   off in. Each lands just before its own run starts. */
@keyframes fa-card { from { opacity: 0; translate: -44px 0; } to { opacity: 1; translate: 0 0; } }
/* A card is not flagged until it has been TRACED. It arrives as plain glass and
   takes its red as its own run crosses to the orb, landing exactly when the dot
   does — so the colour is the connection being made, not a tint that was always
   there. The red was static before, and only looked animated because fa-card
   fades the tinted glass up from nothing; that put the colour on arrival, two
   beats before the run it is supposed to belong to. */
@keyframes fa-flag {
  from { background-color: color-mix(in srgb, #FFF 24%, transparent);
         border-color: color-mix(in srgb, #FFF 88%, transparent); }
  to   { background-color: color-mix(in srgb, var(--pane-tint) 24%, transparent);
         border-color: color-mix(in srgb, var(--error-600) 20%, transparent); } }
@keyframes fa-flag-dot {
  from { background-color: var(--neutral-300);
         box-shadow: 0 0 0 4px color-mix(in srgb, var(--neutral-300) 0%, transparent); }
  to   { background-color: var(--error-600);
         box-shadow: 0 0 0 4px color-mix(in srgb, var(--error-600) 14%, transparent); } }

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) and (min-width: 1000px) {
    .changes { view-timeline: --et-band block; }

    .flow-reveal, .flow-run, .flow-link, .flow-runner, .prob, .prob__dot,
    .orb__leader, .orb__leader::after, .orb__lead, .orb__body {
      animation-timeline: --et-band;
      animation-timing-function: linear;
      animation-fill-mode: both; }

    /* `contain`, not `cover`: contain is the stretch where the band fills the
       viewport, which is the stretch it is pinned for. On cover the sequence
       was spending most of itself on the band flying past above and below.

       Each node owns a 20% slice and overlaps the next, so the four read as one
       relay rather than four separate events. --i is on the <li> and on every
       path the script writes. */
    .flow-reveal, .flow-runner {
      animation-range: contain calc(4% + var(--i) * 20%) contain calc(18% + var(--i) * 20%); }
    /* Two beats on one card: it arrives, then it is flagged. The flag range
       tracks the runner (4% -> 18%) so the colour lands with the dot. */
    .prob { animation-name: fa-card, fa-flag;
      animation-range: contain calc(var(--i) * 20%) contain calc(8% + var(--i) * 20%),
                       contain calc(6% + var(--i) * 20%) contain calc(18% + var(--i) * 20%); }
    .prob__dot { animation-name: fa-flag-dot;
      animation-range: contain calc(6% + var(--i) * 20%) contain calc(18% + var(--i) * 20%); }
    .flow-reveal { animation-name: fa-reveal; }
    .flow-runner { animation-name: fa-runner; }
    /* The line outlives the dot that drew it: it holds through the leader
       growing out of the node, and only then retires. Its own range is wider
       than the run's for exactly that reason. */
    .flow-run, .flow-link {
      animation-range: contain calc(4% + var(--i) * 20%) contain calc(34% + var(--i) * 20%); }
    .flow-run  { animation-name: fa-trail; }
    .flow-link { animation-name: fa-link; }

    /* The dot lands, then the line grows out from under it. */
    .orb__leader::after { animation-name: fa-dot;
      animation-range: contain calc(16% + var(--i) * 20%) contain calc(20% + var(--i) * 20%); }
    .orb__leader { animation-name: fa-leader;
      animation-range: contain calc(18% + var(--i) * 20%) contain calc(24% + var(--i) * 20%); }
    .orb__lead { animation-name: fa-text;
      animation-range: contain calc(22% + var(--i) * 20%) contain calc(30% + var(--i) * 20%); }
    /* The sentence follows its heading rather than arriving with it. */
    .orb__body { animation-name: fa-text;
      animation-range: contain calc(25% + var(--i) * 20%) contain calc(33% + var(--i) * 20%); }
  }
}

/* ---------- AI evaluation ----------
   The sister product's card, from its "How PropIntel builds an answer" row: a
   hairline box, a small uppercase label, the mark beneath it, then the
   sentence. The hierarchy runs label, mark, text — the name is set as a LABEL,
   not as a heading, so nothing in the card competes with the section title
   above it and the mark is free to be the card's middle weight.

   That role is the one the marks can hold. At 86px as the leading element of a
   column they read as stock, because the sheets they come from are identity
   systems where a mark is the figure at full scale. At 56px inside a card they
   are an accent, which is what they are.

   No numbers: these five are not a sequence, so an index would claim an order
   the reader neither needs nor has. PropIntel's own cards carry one because
   Collect, Ground, Answer genuinely is one. */
/* Pinned while the marks draw. Unlike the other two stages this one does NOT
   derive a --pinH: the cards are content-sized, so their height depends on how
   the sentences wrap and there is no honest formula for it. Instead the pin is
   a viewport-tall box that centres its own content — same result, nothing to
   keep in step, and it cannot drift when the copy changes. */
@media (min-width: 1000px) {
  .ai__stage { --runway: 90vh; }
  .ai__stage::after { content: ""; display: block; height: var(--runway); }
  .ai__pin { position: sticky; top: 0; min-height: 100vh;
    display: grid; align-content: center; }
}
.ai__grid { list-style: none; margin: 0; padding: 0; display: grid;
  grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
/* The mark sits BESIDE the name and the sentence, not above them. Stacked, it
   cost 56px of margin in a 254px card and left the two wide cards holding one
   40px sentence in a 560px box — the width they were given was the thing going
   to waste. Landscape, the same content comes to 152px and 120px. */
.ai__card { border: 1px solid var(--border); border-radius: var(--radius-lg);
  background: color-mix(in srgb, #FFF 55%, var(--bg));
  padding: var(--space-6);
  display: grid; grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--space-6); align-content: start; }

.ai__label { grid-column: 2; grid-row: 1;
  font: var(--text-caption); font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.14em; color: var(--ink); margin: 0; }
/* Spans both rows so it keeps its own column whatever the sentence does, and
   starts at the cap line of the name rather than floating against it. */
.ai__mark { grid-column: 1; grid-row: 1 / span 2; align-self: start;
  display: block; color: var(--accent-primary); margin: 0; }
/* 72px. Weight comes from the hairline being non-scaling, not from a thicker
   stroke: at 2.5 the marks read cheap rather than strong. */
.ai__mark svg { display: block; width: 72px; height: 72px; }
/* Scale about the mark's own centre, not each element's box: several of these
   elements are axis-aligned lines whose fill-box has a zero dimension, which
   makes fill-box scaling degenerate. view-box resolves to the 80-unit centre
   for all of them, so the figure settles toward the middle as one object. */
.ai__mark > svg > * { transform-box: view-box; transform-origin: center; }
.ai__mark .m-dot { transform-box: fill-box; transform-origin: center; }
.ai__text { grid-column: 2; grid-row: 2; font: var(--text-body);
  color: var(--ink-secondary); margin: var(--space-2) 0 0; max-width: 46ch; }

/* Six tracks, because five cards only ever close a row if the last one is free
   to take whatever is left. Two across, the fifth runs full width; three
   across, the last two take half each. Either way no row ends in a hole, which
   is what three equal columns did and what centring the leftover pair only
   disguised. */
@media (min-width: 720px) {
  .ai__grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
  .ai__card { grid-column: span 3; }
  .ai__card:last-child { grid-column: span 6; }
}
@media (min-width: 1100px) {
  .ai__card { grid-column: span 2; }
  .ai__card:nth-child(4), .ai__card:last-child { grid-column: span 3; }
}

/* ---------- Close ---------- */
.close { text-align: center; }
.close h2 { font: var(--text-h1); letter-spacing: -0.025em; margin: 0 0 var(--space-4);
  text-wrap: balance; }
@media (max-width: 900px) { .close h2 { font: var(--text-h2); letter-spacing: -0.015em; } }
.close p { font: var(--text-body-large); color: var(--ink-secondary);
  margin: 0 auto var(--space-8); max-width: 46ch; }

/* ---------- Footer ---------- */
.foot { padding-block: var(--space-8); border-top: 1px solid var(--divider); }
/* Two things on one line, then one line under them. The footer carried four
   section links and two policy links as well, which is six destinations nobody
   came here for and a row of small type fighting the lockup for the eye. The
   nav above already goes everywhere this page goes. */
.foot__in { display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8);
  align-items: center; justify-content: space-between; }
.foot__mark { display: flex; flex-direction: column; gap: var(--space-1); }
.foot__lockup { height: 26px; width: auto; }
.foot__label { font: var(--text-caption); color: var(--ink-secondary); margin: 0;
  text-transform: uppercase; letter-spacing: 0.14em; }
/* Level with the lockup, not with the label above it: align-items:center on the
   row lines it up with the mark as a whole, which is the thing it is paired
   with. */
.foot p.foot__note { font: var(--text-caption); color: var(--ink-secondary);
  margin: 0; text-align: end; }
/* The copyright is a machine-produced string, and it belongs to the page rather
   than to either side of the row above, so it sits centred under both. */
.foot__copy { font: var(--text-caption); font-family: var(--font-mono); letter-spacing: 0;
  color: var(--ink-secondary); text-align: center;
  margin: var(--space-6) 0 0; padding-top: var(--space-6);
  border-top: 1px solid var(--divider); }

/* ---------- Motion ----------
   Five sections, five entrances, and deliberately not the same one five times.
   Each is the movement its own content suggests:

     hero        arrives on load, because there is nothing to scroll yet
     walkthrough wipes on like a screen coming up
     lifecycle   steps down its rail, stage by stage
     before/after pins and runs its relay — the page's one authored moment
     AI          draws its own marks, because they are line drawings
     close       the heading is revealed rather than moved

   Everything scroll-driven is CSS scroll-driven animation on a view timeline,
   scrubbed by the browser. Nothing here runs on a scroll listener.

   Every rule lives inside @supports and a reduced-motion query, and the RESTING
   state is the finished one — so an unsupporting browser and a reader who asked
   for less motion both get the whole page rather than an empty one. */
.page .dr-btn { transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1); }
.page .dr-btn:active { transform: translateY(1px); }

@keyframes et-rise { from { opacity: 0; translate: 0 16px; } to { opacity: 1; translate: 0 0; } }
@keyframes et-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes et-wipe { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes et-reveal { from { clip-path: inset(0 0 110% 0); translate: 0 6px; }
                       to   { clip-path: inset(0 0 -10% 0); translate: 0 0; } }

@media (prefers-reduced-motion: no-preference) {
  /* The hero plays on load. An exponential ease-out from an already-visible
     default, so a slow connection never leaves the page blank. */
  .hero__copy > *, .hero .figure {
    animation: et-rise 620ms cubic-bezier(0.16, 1, 0.3, 1) both; }
  .hero__copy > :nth-child(1) { animation-delay: 60ms; }
  .hero__copy > :nth-child(2) { animation-delay: 140ms; }
  .hero__copy > :nth-child(3) { animation-delay: 220ms; }
  .hero .figure { animation-delay: 300ms; }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {

    /* The player comes up like a screen: a wipe down its own height, chrome
       first because that is the part that frames it. */
    .reel { animation: et-wipe linear both;
      animation-timeline: view(); animation-range: entry 10% cover 12%; }

    /* The cards arrive, each a beat after the one before, and the mark rises
       into its own card. The marks used to draw themselves stroke by stroke —
       legible, but it made five small line drawings the event of the section
       when they are an accent inside a card. */
    /* The mark RISES into place like everything else on this page. What
       animates is the notation on top of it: the dashed member travels, and the
       filled dot lands. Those two carry the meaning — one is the thing that is
       absent, the other is the point being made — so they are what moves, and
       the figure around them is just the frame they sit in.

       Drawing the whole vector on was tried and dropped: a hairline figure
       assembling itself stroke by stroke made five small line drawings the
       event of a section where they are an accent inside a card. */
    /* Each element SETTLES into place — it does not draw. This is PropIntel's
       treatment, and the two products should read as one hand: over there
       .pi-src-f scales 0.93 -> 1 and .pi-src-d lands from 0.4 at +180ms. The
       figure never assembles stroke by stroke; it arrives slightly small and
       resolves. */
    @keyframes ai-settle { from { opacity: 0; scale: 0.92; } to { opacity: 1; scale: 1; } }
    /* 16 is two full periods of the 3 5 pattern, so the dashes travel the
       length of two of their own repeats and land back in phase. */
    @keyframes ai-dash { from { opacity: 0; scale: 0.92; stroke-dashoffset: 16; }
                         to   { opacity: 1; scale: 1;    stroke-dashoffset: 0; } }
    /* Not from scale 0 — nothing appears out of nothing. 0.4 is PropIntel's. */
    @keyframes ai-dot  { from { opacity: 0; scale: 0.4; } to { opacity: 1; scale: 1; } }

    .ai { view-timeline: --et-ai block; }
    .ai__card:nth-child(1) { --i: 0; }
    .ai__card:nth-child(2) { --i: 1; }
    .ai__card:nth-child(3) { --i: 2; }
    .ai__card:nth-child(4) { --i: 3; }
    .ai__card:nth-child(5) { --i: 4; }
    .ai__card, .ai__mark, .ai__mark > svg > * {
      animation-timeline: --et-ai; animation-timing-function: linear;
      animation-fill-mode: both; }

    /* contain, not cover: the section is pinned now, so the sequence should
       spend itself on the stretch it is actually held for. */
    .ai__card { animation-name: et-in;
      animation-range: contain calc(6%  + var(--i) * 16%) contain calc(16% + var(--i) * 16%); }
    .ai__mark { animation-name: et-rise;
      animation-range: contain calc(8%  + var(--i) * 16%) contain calc(20% + var(--i) * 16%); }
    /* The solid frame settles first, one element after another so the figure
       resolves rather than popping whole. --j is the element's own index. */
    .ai__mark .m-draw { animation-name: ai-settle;
      animation-range: contain calc(10% + var(--i) * 16% + var(--j) * 1.5%)
                       contain calc(20% + var(--i) * 16% + var(--j) * 1.5%); }
    .ai__mark .m-dash { animation-name: ai-dash;
      animation-range: contain calc(15% + var(--i) * 16%) contain calc(26% + var(--i) * 16%); }
    .ai__mark .m-dot  { animation-name: ai-dot;
      animation-range: contain calc(22% + var(--i) * 16%) contain calc(30% + var(--i) * 16%); }
    .ai__mark > svg > :nth-child(1) { --j: 0; }
    .ai__mark > svg > :nth-child(2) { --j: 1; }
    .ai__mark > svg > :nth-child(3) { --j: 2; }
    .ai__mark > svg > :nth-child(4) { --j: 3; }
    .ai__mark > svg > :nth-child(5) { --j: 4; }
    .ai__mark > svg > :nth-child(6) { --j: 5; }

    /* The closing line is revealed rather than moved — clipped up from its own
       baseline, which is a different gesture from every rise above it. */
    .close h2 { animation: et-reveal linear both;
      animation-timeline: view(); animation-range: entry 20% cover 22%; }
    .close .dr-btn { animation: et-rise linear both;
      animation-timeline: view(); animation-range: entry 55% cover 30%; }
  }
}

.page .dr-btn:active { transform: translateY(1px); transition-duration: 0s; }
.page a, .app__nav li { transition: color 120ms ease, background-color 120ms ease; }
@media (prefers-reduced-motion: reduce) {
  .page .dr-btn { transition: none; }
  .page .dr-btn:active { transform: none; }
  .page a, .app__nav li { transition: none; }
}
