/* ============================================================================
   MrKan — the site's one stylesheet.
   Palette and styling reference: stripe.com (per internal/spec.md).

   EXTRACTED FROM index.html AT #2207.2, when the site stopped being one page.
   Four pages now share it — landing, signup, privacy, terms — and a copy per page
   is the shape this project keeps finding elsewhere: two definitions of one thing,
   free to drift, with nothing that fails when they do.

   Deliberately single-theme light. This is a marketing site, not a themed app, and
   every colour is painted explicitly so no page borrows a ground it cannot see.
   ========================================================================= */
/* ============================================================================
   MrKan — landing page
   Palette and styling reference: stripe.com (per spec).
   Deliberately single-theme light: this is a marketing site, not a themed app.
   Every colour is painted explicitly so the page holds on any host ground.
   ========================================================================= */
:root{
  /* Stripe-family neutrals: slate biased toward the indigo accent, never flat grey */
  --ink:#0a2540;          /* headings */
  --body:#425466;         /* running text */
  --mute:#697386;         /* captions, eyebrows */
  --ground:#ffffff;
  --tint:#f6f9fc;         /* alternating section ground */
  --hair:#e3e8ee;
  --hair-soft:#edf1f6;

  --accent:#635bff;       /* Stripe indigo — 4.70:1 on white, and 4.45:1 on --tint */
  --accent-deep:#4b44d4;
  /* #2218: THE EYEBROW NEEDED ITS OWN TOKEN, because it is the one accent-coloured TEXT that appears
     on BOTH grounds. Measured: --accent is 4.70:1 on white (passes) and 4.45:1 on --tint (fails 4.5
     by 0.05). One value cannot serve two grounds when it only just clears the easier one — the same
     lesson as #2223, at a much smaller scale.
     --accent-text is one step darker and passes on both (5.30 / 5.02), which is what a text token
     has to do. --accent itself is UNCHANGED: it is the button fill and the focus ring, where white
     sits on IT (4.70:1) and where the requirement for a non-text control is 3:1 anyway. */
  --accent-text:#5a52f5;
  --accent-wash:#f0efff;
  --cyan:#00d4ff;
  --magenta:#ff5996;

  /* the product's own UI tokens — the board shot below is the real thing */
  --ui-bg:#0f1216; --ui-panel:#171b21; --ui-panel-2:#1d2229; --ui-line:#262c35;
  --ui-sel:#2b3b58;   /* #2267: the selected row of the assignee menu. A SOLID colour, not an
                         rgba wash over --ui-panel-2, so internal/a11y-audit.py can read the ground
                         the option text sits on straight out of this block — a wash would have to be
                         composited, and a composite written down once is a number that drifts the
                         moment either of its inputs moves. */
  --ui-ink:#e7ecf3; --ui-mute:#8b96a6; --ui-accent:#5b93ff;
  --p0:#d92f3c; --p1:#f5c518; --p2:#3fb950;
  --t-bug:#ff6b8a; --t-feature:#4cd08a; --t-task:#7f8a99;

  /* #2262 (@admin: "check site stuffly.co, what font is used / change to that one").
     MEASURED rather than read off their CSS: loaded stuffly.co in headless Chrome and asked
     `CSS.getPlatformFontsForNode` which faces actually painted the text. Every sampled element --
     h1, h2, p, a, nav a, .btn -- came back `.SF NS` with `isCustomFont: false`, and the page
     references no font host at all. So "the font stuffly.co uses" is the OS UI font via `system-ui`:
     SF Pro on macOS, Segoe UI on Windows, Roboto on Android. It is not a webfont, and copying their
     stack is the whole change.

     This replaces BOTH of our webfont faces -- Instrument Sans (display) and Source Sans 3 (text) --
     with one stack, because stuffly draws headings and body from the same one. The display/text split
     is kept as two variables so the distinction survives if we ever want it back; they simply resolve
     to the same list today.

     `--mono` takes stuffly's declaration too, but note what it MEANS there: they declare IBM Plex Mono
     and never load it, so on a machine without it installed their mono is really ui-monospace/SF Mono.
     We keep loading it (below) so ours is the same everywhere -- our mono is deterministic today and
     silently making it OS-dependent is a regression nobody asked for. */
  --display:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  --text:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  --mono:"IBM Plex Mono",ui-monospace,"SF Mono","Menlo","Cascadia Code","Roboto Mono",monospace;

  /* #2259 (@admin: "on desktop browser there is a wide margin on left side with a lot of whitespace
     ... shorten the white vertical margin on desktop"). Was a flat 1120px, so the band GREW with the
     screen: content started 200px in at 1440, 440px at 1920, 760px at 2560.
     Now it tracks the viewport between a floor and a ceiling. The floor keeps every width at or below
     1120 EXACTLY as it was — this is a desktop change and must not touch the phone. The ceiling stops
     the product shot becoming a mural on a 27-inch monitor.
     WIDENING IS SAFE FOR TEXT, which is worth stating because it is the usual objection: every long
     line on this site is already capped by its own element — .hero-copy 34rem, .close h2 18ch,
     .close p 42ch — and the features grid is four fixed columns, so its cells stay narrow. The wrap
     was never what protected the measure. */
  --wrap:clamp(1120px, 92vw, 1560px);
  --bar-h:66px;
}

/* ---------------------------------------------------------------- dark mode (#2264)
   @admin: "current website is light mode / implement dark mode / Default dark or light depending on
   time of day / add a switch in the title bar".

   THE SELECTOR IS `:root[data-theme="dark"]`, and the attribute is set by an inline script in each
   page's <head> BEFORE the stylesheet paints — see the `boot theme` block in index.html. A toggle
   written after first paint gives every dark-mode visitor a white flash, which is the one failure
   this feature cannot have.

   WHAT IS **NOT** INVERTED, and why it matters more than what is: the `--ui-*` block above is the
   PRODUCT'S own palette, and the banner mock is a picture of the real app — which is dark in both
   themes. Inverting it would turn a screenshot of MrKan into a screenshot of a different product.
   The `.close` band is the same case: it is a deliberately dark section, and its literal `#0a2540`
   is correct in both themes precisely BECAUSE it is a literal rather than `var(--ink)`.

   The accent moves and the neutrals inverT. #635bff is 4.70:1 on white but only 3.51:1 on the dark
   ground, so the dark theme uses a lighter indigo — the same reasoning as #2218's `--accent-text`,
   which needed its own value because one colour cannot serve two grounds. Measured values are in
   the a11y audit, which now runs BOTH themes. */
:root[data-theme="dark"]{
  --ink:#e8edf5;          /* headings */
  --body:#b3bdcc;         /* running text */
  --mute:#8d99ab;         /* captions, eyebrows */
  --ground:#0d1117;
  --tint:#141a22;         /* alternating section ground */
  --hair:#232b36;
  --hair-soft:#1b222c;

  --accent:#8b86ff;       /* lighter indigo: 6.06:1 on --ground, and white-on-it is not used in dark */
  --accent-deep:#a7a3ff;
  --accent-text:#9a95ff;  /* the eyebrow token — must pass on BOTH --ground and --tint */
  --accent-wash:#1c1b3a;

  /* the button fill keeps white text, so it needs a DARKER accent than the text token, not a
     lighter one — two different jobs that #2218 already separated. */
  --accent-fill:#5b53f0;

  --btn-ghost-bg:#151b24;
  --btn-ghost-hair:#313b49;
  --bar-bg:rgba(13,17,23,.85);
  /* #2264: the signup error text. #b3253a is 6.48:1 on white and only 2.92:1 on this
     ground — a validation message nobody can read is the worst thing to lose to a theme
     swap, because it only appears when something has already gone wrong. 6.91 / 6.39. */
  --danger:#ff6b81;

  color-scheme: dark;
}
/* Light keeps the same two names so the rules below never branch on theme. */
:root{ --accent-fill:var(--accent); --btn-ghost-bg:#fff; --btn-ghost-hair:#cfd7e3;
       --bar-bg:rgba(255,255,255,.88);
       --danger:#b3253a; }          /* 6.48:1 on white */

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

body{
  margin:0; background:var(--ground); color:var(--body);
  font-family:var(--text); font-size:17px; line-height:1.65;
  -webkit-font-smoothing:antialiased; overflow-x:hidden;
}
a{color:inherit; text-decoration:none}
img{max-width:100%}
:focus-visible{outline:2px solid var(--accent); outline-offset:3px; border-radius:4px}
/* #2218: --accent stays here deliberately. A focus indicator is a NON-TEXT control, which needs 3:1
   against what is adjacent, not 4.5 — and it is 4.70:1 on white and 3.8:1 against the tinted section.
   `outline-offset` matters as much as the colour: the ring sits OUTSIDE the element, so on a filled
   button it is measured against the page rather than against the button it would otherwise vanish
   into. */

.wrap{width:100%; max-width:var(--wrap); margin-inline:auto; padding-inline:clamp(20px,5vw,40px)}

h1,h2,h3{font-family:var(--display); color:var(--ink); margin:0; text-wrap:balance; letter-spacing:-.022em; font-weight:700; line-height:1.1}
h1{font-size:clamp(2.6rem,6.2vw,4.1rem); letter-spacing:-.032em}
h2{font-size:clamp(1.9rem,3.6vw,2.6rem)}
h3{font-size:1.06rem; letter-spacing:-.012em; line-height:1.35}
p{margin:0}

.eyebrow{
  font-family:var(--mono); font-size:.72rem; font-weight:500;
  letter-spacing:.14em; text-transform:uppercase; color:var(--accent-text);   /* #2218 */
}

/* ---------------------------------------------------------------- buttons */
.btn{
  display:inline-flex; align-items:center; gap:.5rem;
  font-family:var(--display); font-weight:600; font-size:.98rem; letter-spacing:-.005em;
  padding:.68rem 1.15rem; border-radius:999px; border:1px solid transparent;
  transition:background-color .15s ease, color .15s ease, transform .15s ease, box-shadow .15s ease;
  white-space:nowrap; cursor:pointer;
}
.btn-primary{background:var(--accent-fill); color:#fff; box-shadow:0 2px 5px rgba(50,50,93,.11), 0 1px 2px rgba(0,0,0,.08)}
.btn-primary:hover{background:var(--accent-deep); transform:translateY(-1px); box-shadow:0 6px 14px rgba(99,91,255,.28)}
.btn-ghost{color:var(--ink); border-color:var(--hair); background:var(--btn-ghost-bg)}
.btn-ghost:hover{border-color:var(--btn-ghost-hair); transform:translateY(-1px)}
.btn-on-dark{background:#fff; color:var(--accent-deep)}
.btn-on-dark:hover{background:#f3f2ff; transform:translateY(-1px)}
.btn .arw{transition:transform .15s ease}
.btn:hover .arw{transform:translateX(3px)}

/* ---------------------------------------------------------------- title bar */
.bar{
  position:sticky; top:0; z-index:40; height:var(--bar-h);
  /* #2264: TOKENISED. This was a literal `rgba(255,255,255,.88)`, and it is the one surface a
     theme switch cannot afford to miss — the bar is sticky, so a light bar over a dark page follows
     you down the whole document. The translucency is the point (the blur samples what scrolls under
     it), so the dark value is an alpha of the dark ground rather than an opaque block. */
  background:var(--bar-bg); backdrop-filter:saturate(180%) blur(12px);
  border-bottom:1px solid var(--hair-soft);
}
.bar-in{height:100%; display:flex; align-items:center; gap:2rem}
/* #2249 (@admin: "place the icon/mrkan in the top left corner, and the features, board, in the top
   right corner ... too much whitespace margin on both sides"). The titlebar is the one band that runs
   EDGE TO EDGE; the page body still stops at --wrap, so the bar frames the content rather than lining
   up with it.
   The markup keeps `class="wrap bar-in"` and this rule overrides `.wrap` by SPECIFICITY (0,2,0 beats
   0,1,0) rather than by source order. Order would work today — `.wrap` is declared earlier — but it is
   the kind of thing a later reshuffle breaks silently, and #2229 was exactly that: a shared class
   reaching a page it was never meant for.
   The gutter is its own clamp rather than `.wrap`'s: it has to hold the mark off the glass edge at
   1920px without eating the row on a 360px phone, which is a different job from centring a column. */
.bar .bar-in{max-width:none; margin-inline:0; padding-inline:clamp(18px,2.6vw,44px)}
.brand{display:flex; align-items:center; gap:.55rem; font-family:var(--display); font-weight:700; font-size:1.22rem; color:var(--ink); letter-spacing:-.03em}
/* #2212: the wordmark is MrKan, and it carries the same two-colour treatment the app got in #2206 —
   `Mr` in the ink colour, `Kan` in the accent. One product, one mark, so the site and the app do not
   introduce a reader to two different names for the same thing. */
.wordmark b{font-weight:700}
.wordmark b.mr{color:var(--ink)}
.wordmark b:not(.mr){color:var(--accent)}
/* #2230 (@admin: "the icon to the left of MrKan should be the robot icon we already have in chat
   button on the app"). It is the SAME drawing, traced from <Kanbot> in web/src/HelpBot.tsx — same
   viewBox, same coordinates — rather than a robot of the site's own. Two robots for one product is
   the #2206 problem (two names for one thing) in a different medium.
   It keeps the gradient tile it replaces: the tile is what makes the mark legible at 26px against a
   near-white bar, and dropping it would leave a line drawing floating in the nav. The robot is white
   ON the tile, and the face plate is punched through to the tile's own gradient — in the app that
   hole shows the popup's background, so it is a hole in both, not a fill. */
/* #2254 (@admin: "flatten the icon color in title so it's single color (not a color shade), matching
   the purple in the `Kan` and the color of trial button"). One accent, three places: the tile here,
   `Kan` in the wordmark, and .btn-primary's ground — all `var(--accent)`, so the mark cannot drift
   from the button it sits beside.
   The face plate stays `var(--accent)` and is now EXACTLY the tile colour rather than a sampled
   mid-point of the gradient, which is what the punched-out hole was always meant to read as. */
.brand .glyph{
  width:28px; height:28px; border-radius:8px; flex:none;
  background:var(--accent);
  display:grid; place-items:center; color:#fff;
}
.brand .glyph svg{width:21px; height:21px; display:block}
/* The punched-out face. `mix-blend-mode:destination-out` would be the literal hole, but it is not
   reliable inside a gradient-filled box across browsers, so the plate takes a flat tone from the
   middle of the gradient — the same read at 21px, and it cannot fail to paint. */
.brand .glyph .face{fill:var(--accent)}
.nav{display:flex; align-items:center; gap:1.6rem; margin-left:auto}
/* #2264: the theme switch. It lives in the nav beside the links, so the titlebar's existing
   flex row places it and #2249's gutter measurement is unaffected — the check counts painted
   boxes, so a new child is measured rather than assumed.
   It is a <button> with `aria-pressed`, not a checkbox or a link: it performs an action on this
   page rather than navigating, and a screen reader needs to hear the CURRENT state, which
   `aria-pressed` carries and a bare label does not. The two glyphs are both in the DOM and one is
   hidden by the state, so the swap needs no script beyond flipping the attribute. */
.theme-toggle{
  display:grid; place-items:center; width:32px; height:32px; flex:none;
  border:1px solid var(--hair); border-radius:9px; background:var(--btn-ghost-bg);
  color:var(--mute); cursor:pointer; padding:0; line-height:0;
  transition:color .15s ease, border-color .15s ease, background .15s ease;
}
.theme-toggle:hover{color:var(--ink); border-color:var(--btn-ghost-hair)}
.theme-toggle svg{width:16px; height:16px; display:block}
/* Show the glyph for the theme you would SWITCH TO, which is what the control does. */
.theme-toggle .i-sun{display:none}
:root[data-theme="dark"] .theme-toggle .i-sun{display:block}
:root[data-theme="dark"] .theme-toggle .i-moon{display:none}
@media (prefers-reduced-motion:reduce){ .theme-toggle{transition:none} }

.nav a.lnk{font-family:var(--display); font-weight:600; font-size:.94rem; color:var(--mute); transition:color .15s ease}
.nav a.lnk:hover{color:var(--ink)}
@media (max-width:760px){ .nav a.lnk{display:none} }

/* ---------------------------------------------------------------- hero */
.hero{position:relative; padding-block:clamp(3rem,5.5vw,4.6rem) 0; overflow:hidden}
/* #2223 (@admin: "font color ... is grey, hard to read with blue background due to poor contrast").
   THE HERO COPY STRADDLED A GROUND BOUNDARY. The band used to start partway down it, so the sub ran
   from white onto blue while styled for exactly one of them.
   I first fixed it by putting the whole hero ON the band and making the type white. MEASURED before
   shipping, that is WORSE than the bug:
       white on #00d4ff (the 4% stop)   1.77:1     <- the cyan end is where the copy column sits
       white on #ff5996 (the 100% stop) 2.95:1
       white on #635bff (the 44% stop)  4.70:1     <- the only stop that passes
   against 4.5:1 for normal text. So the band moves instead of the type: it is BEHIND THE SHOT ONLY,
   and every piece of text on this page stays on the white ground it was designed for —
       --ink  #0a2540 on white   15.54:1
       --body #425466 on white    7.80:1
   No colour is legible on two grounds at once, and tuning one to straddle them is not a fix. */
/* #2226: THE RULE ABOVE IS NOW STRUCTURAL. It was enforced by two tuned numbers — `left:34%` for the
   two-column hero and a `top:clamp(330px,62vw,540px)` for the stacked one — each correct for the
   window it was tuned in. `internal/hero-layout-check.mjs` asks the renderer instead of the CSS, and on
   the SHIPPED page it found hero text over the band at **11 of the 13 widths it samples**: the sub at
   1920/1600/1440 and at every stacked width, the fineprint below 980, the caption at 1120. #2223 held
   at 1280 and 1024, which is where it was looked at.
   So the band is no longer positioned against the hero at all. It is a child of .shot-stage, which
   wraps the shot and nothing else, and it is inset from THAT box — bounded by the shot itself at every
   width and in both layouts. There is no number left that a new layout can invalidate. */
.shot-stage{position:relative}
.shot-stage > .shot{position:relative; z-index:1}
/* Stripe's signature: an angled gradient band whose lower edge is skewed. The right bleed is in vw so
   it always reaches past the viewport edge (`.hero` clips it, so the page never scrolls sideways).
   #2236: THE LOWER BLEED IS IN rem, NOT A PERCENTAGE. It has to clear the caption below it, and the
   caption's margin is a fixed length — so a percentage of a shot whose height depends on how long its
   card titles happen to be is a clearance that changes when the CONTENT changes. Rewriting the mock's
   titles was enough to put the caption back on the band at 981–1024px, which hero-layout-check.mjs
   caught. Two lengths that must stay apart should be measured in the same units. */
.band{
  position:absolute; z-index:0; left:-6%; right:-17vw; top:-15%; bottom:-2rem;
  background:linear-gradient(104deg,#00d4ff 4%,#635bff 44%,#7a4df0 68%,#ff5996 100%);
  transform:skewY(-7deg); transform-origin:0 0;
}
/* Stacked, the shot is full width and the band needs to bleed on BOTH sides rather than only right. */
/* Stacked, the shot is full width, so the band bleeds on BOTH sides rather than only right — and it
   gets NO upward bleed, because in one column the hero copy is directly above the shot and there is no
   room for one. The bleed goes where there is space, never where there is text. */
@media (max-width:980px){ .band{left:-12vw; right:-12vw; top:0; bottom:-1.6rem} }
.band::after{
  content:""; position:absolute; inset:0;
  background:linear-gradient(180deg,rgba(255,255,255,0) 26%,rgba(255,255,255,.06) 60%,rgba(10,37,64,.28) 100%);
}
/* #2212: THE SHOT SITS BESIDE THE HEADLINE, not under it. Two columns at desktop width, stacking to
   one below 980px where a half-width board stops being legible. The copy column is the narrower of the
   two on purpose — the headline wants ~36 characters a line, and the shot wants every pixel it can get. */
/* #2226 (@admin: "title is too big font, takes up 6 lines ... the entire column is too thin").
   MEASURED IN THE BROWSER, not argued from the rule: the copy column was laying out at **304px**
   while its grid track was 456px, so the headline got ~8 characters a line and wrapped to 6.
   `max-width:36ch` was the cap, and the comment beside it — "the headline wants ~36 characters a
   line" — was not what it did. `ch` resolves against the font of THE ELEMENT THAT DECLARES IT:
   .hero-copy inherits the 17px body face, so 36ch is 304px, which is ~8 characters of a 66px
   headline. One more measure standing in for the thing it was named after.
   The cap is now a real length, chosen for the headline it has to hold, and the desktop measure is
   the grid track — ONE place, so the two cannot disagree again. */
/* #2265 (@admin: "on desktop there seems to be some space to make the mock a bit more wide").
   There was: measured, 220px of dead ground sat between the copy's longest line and the shot's left
   edge at 1920 and above, and 176px at 1600. The cause is that the copy had HALF the hero while
   `.hero-copy` is capped at 34rem, so past a certain width the extra was going to a column that had
   already stopped using it.
   So the copy track is capped at the width it can actually use and the rest goes to the shot. The cap
   is `min(--copy-max, 50% - half the gap)`, and the second half of that min() is what makes it safe:
   `50% - gap/2` IS what `1fr` resolves to, so where the cap is not binding this is exactly the old
   layout and the shot cannot lose a pixel.
   TWO WRONG VERSIONS CAME FIRST, and both are why this is spelled out rather than inferred:
     `min(34rem, 50%)`   — a grid track's `50%` is half the CONTAINER, while `1fr` is half of what is
                           left AFTER the gap. It quietly took gap/2 = 24px off the shot at every
                           width where the cap was not binding. It reads as the obviously-safe form
                           and it is the opposite.
     the same, above 1300px — moving it behind a breakpoint only moved the problem to the breakpoint:
                           at 1300 the 34rem cap is still wider than half a track, so the shot lost
                           8.8px there. internal/shot-width.mjs is what caught it, by measuring
                           against the commit before this one rather than against my reasoning.
   `--hero-gap` and `--copy-max` are declared once and used twice each, because two copies of a length
   that must agree is how #2242, #2259 and #2260 each went wrong. */
.hero-in{position:relative; z-index:1; display:grid;
  --hero-gap:clamp(1.8rem,3.5vw,3rem); --copy-max:34rem;
  gap:var(--hero-gap);
  grid-template-columns:minmax(0,min(var(--copy-max),calc(50% - var(--hero-gap)/2))) minmax(0,1fr);
  align-items:center}
/* #2228: A GRID ITEM'S DEFAULT MINIMUM SIZE IS ITS CONTENT, and the console the mock just gained is
   full of `white-space:nowrap` text. In the stacked layout that made the single 1fr track 545px wide
   on a 430px phone — the whole hero, headline included, laid out past the right edge.
   `minmax(0,...)` above only says that about the TWO-column form; the stacked `1fr` and the items
   themselves need saying too. Every one of those nowrap boxes clips itself once it is allowed to be
   narrower than its text. */
.hero-in > *{min-width:0}
/* The stacked gap is also the clearance between the fineprint and the band's upper edge (see
   .band below), so it is a little larger than a purely visual gap would be. */
@media (max-width:980px){ .hero-in{grid-template-columns:1fr; gap:clamp(3rem,6vw,3.6rem)} }
.hero-copy{max-width:var(--copy-max,34rem)}   /* #2265: the same length the grid track is capped at */
/* Smaller than the global h1, because this one lives in HALF the container. The page-wide
   clamp(2.6rem,6.2vw,4.1rem) is sized for a full-width heading and is untouched — signup, privacy
   and terms still use it. */
/* #2270: `margin-top:1rem` is gone with the eyebrow it was separating the headline FROM. The h1 is
   the first thing in .hero-copy now, so that margin was pushing the whole copy column down against
   nothing — and because .hero-in centres the two columns against each other, it showed up as the
   copy sitting low beside the shot rather than as space above the headline. */
.hero h1{font-size:clamp(2.1rem,3.2vw,3rem)}
.hero .sub{margin-top:1.2rem; font-size:1.2rem; color:var(--body)}
/* #2224 (@admin: "start free trial, see a live board should be in same row").
   `flex-wrap: wrap` broke the row WHEREVER it ran out of space, and in the narrower copy column of
   the two-column hero that was BETWEEN the two buttons rather than after them. Three items were
   competing for a track sized for two, because the fineprint was in the row as if it were a button.
   It moved out (see index.html), and the row is `nowrap` so the break cannot come back. */
.hero .cta{display:flex; flex-wrap:nowrap; gap:.75rem; margin-top:1.9rem; align-items:center}
.hero .fineprint{font-family:var(--mono); font-size:.78rem; color:var(--mute); letter-spacing:-.01em;
  margin-top:.85rem}
/* Two buttons side by side need ~360px of text and padding, and a 400px phone offers 368px inside
   the gutters — which is a fit with nothing to spare. So they get slightly tighter below 520px
   rather than being left to overflow or to wrap after all. Measured against the longest pair the
   page has: "Start free trial →" and "See a live board". */
@media (max-width:520px){
  .hero .cta .btn{padding:.6rem .85rem; font-size:.9rem}
  .hero .cta{gap:.5rem}
}
/* Below 380px there is no honest way to keep them on one line, so they stack rather than run off the
   edge — an instruction that cannot be met at every width is met where it can be and states where it
   cannot, instead of producing a page that scrolls sideways. */
@media (max-width:379px){ .hero .cta{flex-wrap:wrap} }

/* ---------------------------------------------------------------- product shot */
/* The shot sits INSIDE its half of the split, centred in it (#2260). It used to bleed off the right
   edge at desktop width — the argument was that four columns of cards in half a container is cramped,
   and that a board running past the frame reads as a window onto something larger. @admin's #2260
   overrides that: an off-centre picture in a two-column hero reads as a mistake before it reads as a
   window. `body{overflow-x:hidden}` is still there and still wanted — the gradient band bleeds. */
.shot-wrap{position:relative; z-index:1; padding-bottom:clamp(3.5rem,7vw,6rem)}
/* #2228 was "make the mock bigger", and it bought that size from the BLEED rather than from the copy
   column — #2226 had just established the copy was too thin, so taking it back would have traded one
   of @admin's cards for another. **That mechanism is gone as of #2260**: the bleed was also what put
   the shot off-centre, and centring won. This comment is kept because the CONSTRAINT it records still
   binds — if the mock needs to be bigger again, the width must not come out of the copy column. */
/* #2260 (@admin): "on the right, the screenshot is too far right / should be centered within the
   right half". THE BLEED IS REMOVED AT DESKTOP, and the reason it read as off-centre is worth keeping
   because it was not a tuning error — it was structural.

   The shot's left edge was always exactly the right track's left edge, and the negative margin let it
   grow RIGHTWARD past the track by the bleed. So its optical centre sat `bleed / 2` right of the
   track's centre, at every width, by construction:

       width   bleed      measured offset from the half's centre
       1120    -24px       12px
       1280    -75.2px     38px
       1440    -81.6px     41px
       1920    -204px     102px
       2560    -332.8px   166px

   With no margin the shot fills its track exactly and is centred by construction rather than by a
   number — which is already what happened at 1024/981, where the clamp's upper bound made the margin
   0 and the measured offset was 0. There is nothing left to keep in sync.

   WHAT THIS COSTS, stated because it reverses a previous card rather than refining it: #2228 was
   "make the mock bigger", and its extra size was bought FROM the bleed — deliberately, so the copy
   column #2226 had just widened would not have to give it back. Removing the bleed returns the shot
   to its track width (680 -> 598 at 1440, 920 -> 716 at 1920). Centring and that extra size cannot
   both come from the same place; #2260 is the newer instruction, so size is what gives way. If the
   mock should be bigger again, the width has to come from the grid split, not from an overhang.

   The prior derivation (#2259) is left in the history rather than here: it computed the bleed from
   --wrap so the two could not drift apart. That problem no longer exists, because there is no second
   length. */
@media (max-width:980px){ .shot-wrap{margin-top:0} }
.shot{
  border-radius:14px; overflow:hidden; background:var(--ui-bg);
  border:1px solid rgba(255,255,255,.09);
  box-shadow:0 50px 100px -20px rgba(10,37,64,.5), 0 30px 60px -30px rgba(0,0,0,.45);
  /* #2222 (@admin: "add animated effect so the screenshot slowly floats and hovers").
     SLOWLY is the whole brief. 7s, 10px of travel and an eased sine — at this speed it reads as
     the object being lifted rather than as something moving, which is what a floating shot is for.
     A faster or larger version is the tell of a page trying to be noticed.
     The shadow travels WITH it, tightening as it rises: a shape that moves while its shadow stays
     put does not read as floating, it reads as broken. */
  animation:float 7s ease-in-out infinite;
  will-change:transform;
}
@keyframes float{
  0%, 100% { transform:translateY(0);      box-shadow:0 50px 100px -20px rgba(10,37,64,.50), 0 30px 60px -30px rgba(0,0,0,.45) }
  50%      { transform:translateY(-10px);  box-shadow:0 62px 112px -20px rgba(10,37,64,.44), 0 38px 70px -30px rgba(0,0,0,.38) }
}
/* The global `prefers-reduced-motion` rule at the top of this file already disables every
   animation, so a reader who has asked for stillness gets a shot that simply sits there — at its
   RESTING position, because 0% and 100% are the un-transformed frame rather than the raised one. */
.shot-chrome{
  display:flex; align-items:center; gap:.7rem; padding:.62rem .9rem;
  background:var(--ui-panel); border-bottom:1px solid var(--ui-line);
}
.dots{display:flex; gap:6px; flex:none}
.dots i{width:10px; height:10px; border-radius:50%; background:#39404c; display:block}
.urlbar .lock{width:11px; height:11px; flex:none; color:#5fcf8e; margin-right:.35rem}
/* #2228: these three were #5c6675 / #697386 and measured 3.23:1 and 3.62:1 against the panels they
   sit on — caught by internal/a11y-audit.py before shipping, which is the whole reason #2218 left a
   script behind instead of a verdict. #78859a is the shade the card ids already use (4.63:1 on
   --ui-panel, more on the darker --ui-bg). */
.urlbar b{font-weight:400; color:#78859a}
.urlbar{
  flex:1; min-width:0; display:flex; align-items:center; font-family:var(--mono); font-size:.72rem; color:var(--ui-mute);
  background:var(--ui-bg); border:1px solid var(--ui-line); border-radius:6px;
  padding:.24rem .6rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* ---------------------------------------------------------------- the app title bar (#2234)
   @admin: "it should look like titlebar of app so should be below it — add titlebar to the mock with
   appname, dropdown for board, and search box". So this is the app's own <header className="topbar">,
   not a browser tab: the toggle, the wordmark, the pickers, the search, and the three right-hand
   controls, in the order App.tsx renders them. */
.appbar{
  display:flex; align-items:center; gap:.5rem; padding:.42rem .7rem;
  background:var(--ui-panel); border-bottom:1px solid var(--ui-line); min-width:0;
}
.ab-burger{color:var(--ui-mute); font-size:.8rem; flex:none}
/* the same two-colour wordmark as #2206, in the app's own dark tokens */
.ab-logo{font-family:var(--display); font-weight:700; font-size:.86rem; letter-spacing:-.02em; flex:none}
.ab-logo b.mr{color:var(--ui-ink)}
.ab-logo b:not(.mr){color:var(--ui-accent)}
/* a <select>: a label and a caret, in a bordered box */
.ab-sel{
  display:inline-flex; align-items:center; gap:.4rem; flex:none;
  font-family:var(--display); font-weight:600; font-size:.7rem; color:var(--ui-ink);
  background:var(--ui-panel-2); border:1px solid var(--ui-line); border-radius:6px; padding:.24rem .45rem;
}
.ab-sel i{
  width:0; height:0; flex:none; display:block;
  border-left:3.5px solid transparent; border-right:3.5px solid transparent; border-top:4px solid #8b96a6;
}
.ab-search{
  flex:1 1 9rem; min-width:0; font-family:var(--text); font-size:.7rem; color:#78859a;
  background:var(--ui-bg); border:1px solid var(--ui-line); border-radius:6px; padding:.26rem .55rem;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.ab-sp{flex:0 1 1.5rem}
.ab-ico{position:relative; color:var(--ui-mute); font-size:.76rem; flex:none; line-height:1}
.ab-bot{width:15px; height:15px; color:var(--ui-accent)}
.ab-bot svg{width:15px; height:15px; display:block}
.ab-bot .face{fill:var(--ui-panel)}
/* the bell's unread count, as the app badges it */
.ab-bell em{
  position:absolute; top:-5px; right:-6px; font-style:normal;
  font-family:var(--mono); font-size:.5rem; font-weight:700; color:#fff; background:var(--p0);
  border-radius:99px; padding:0 3px; line-height:1.5;
}
/* A phone shows the name, one picker and the search: the second picker and the right-hand controls are
   the first things a narrow app bar drops too.
   `.ab-sel ~ .ab-sel`, NOT `.ab-sel:nth-of-type(2)` — every child of this bar is a <span>, so
   `nth-of-type` counts spans and resolves to the WORDMARK, which is not an `.ab-sel` and so matched
   nothing at all. A rule that quietly selects nobody is worse than no rule; this one was only caught
   because the screenshot still had two pickers in it. */
@media (max-width:640px){ .appbar .ab-sel ~ .ab-sel, .appbar .ab-ico{display:none} }

.shot-body{display:grid; grid-template-columns:124px 1fr; min-height:0}
/* #2229 (@admin: the signup page's "What you get on day one" box "has a black background and shows
   like a black square"). IT WAS THIS RULE. The shot's sidebar was called `side` in the SHARED
   stylesheet, and signup.html's aside carries the same class — so the marketing page's column
   inherited the product UI's dark panel. A cascade collision across two pages, introduced when the
   stylesheet was extracted at #2207.2 and invisible from either file alone.
   Renamed to match every other class in this picture (`shot-chrome`, `shot-body`, `shot-cap`), which
   is what it should always have been — a generic name in a shared sheet is a claim on every page that
   will ever use it. */
.shot-side{border-right:1px solid var(--ui-line); padding:.9rem .75rem; background:var(--ui-panel)}
.shot-side .grp{font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; color:#616c7d; margin:.1rem 0 .55rem}
.shot-side .item{
  display:flex; align-items:center; gap:.5rem; font-family:var(--display); font-weight:600;
  font-size:.82rem; color:var(--ui-mute); padding:.3rem .45rem; border-radius:6px; margin-bottom:.14rem;
}
.shot-side .item.on{background:var(--ui-panel-2); color:var(--ui-ink)}
.shot-side .item .d{width:6px; height:6px; border-radius:50%; background:#4a5464; flex:none}
.shot-side .item.on .d{background:var(--ui-accent)}
@media (max-width:820px){ .shot-body{grid-template-columns:1fr} .shot-side{display:none} }

/* `--col-gap` is declared rather than repeated: the drag in #2244 moves the card by exactly ONE GRID
   STEP, and a step is the card's own width plus this gap. Two copies of that number would be free to
   drift, and the drop would land half a card off. */
.cols{display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--col-gap,.6rem);
  padding:.8rem; overflow-x:auto; position:relative}
/* #2265: the fifth column, and the width at which it is affordable. A column costs width, so the
   test it has to pass is simple and measurable: ADDING DONE MUST NOT MAKE THE CARDS SMALLER THAN
   THEY ARE TODAY. Otherwise the board is not wider, it just shows less of each card and wraps more
   rows to do it.
   1620 is where that becomes true, measured against the shipped page rather than chosen:

       width   4 cols today   5 cols here
       2560         134px        140px     <- the wrap is capped, so this is the steady state
       1700         134          140
       1620         125          126       <- the crossover
       1600         123          122       <- one pixel short, so 1600 is NOT the threshold
       1500         111          104

   Below it the shot is still wider than it was — that half of the card stands on its own — and the
   board keeps four columns. internal/shot-width.mjs pins both halves against a baseline taken from
   the commit before this one, so a later change to the card content or the gap fails the check and
   names the drift instead of the board quietly going cramped. */
@media (min-width:1620px){ .cols{grid-template-columns:repeat(5,minmax(0,1fr))} }
@media (max-width:1619px){ .col-done{display:none} }
@media (max-width:820px){ .cols{grid-template-columns:repeat(2,minmax(150px,1fr))} }
@media (max-width:460px){ .cols{grid-template-columns:repeat(2,minmax(128px,1fr)); --col-gap:.5rem; padding:.6rem} }

/* ---------------------------------------------------------------- the drag (#2244)
   @admin: "show a mouse pointer moving on the mock screen — the mouse moves a card from backlog to
   assigned #4097, and assigns it to kanbot."

   It is the SAME #4097 jo asks kanbot to take in the console below, so the two loops tell one story
   rather than two. Both run on a 20s clock for the same reason.

   THE RESTING STATE IS THE FINISHED ONE, as everywhere else on this page: the card is declared
   already dropped in Assigned with MrKan on it, the menu is declared closed, and the pointer is
   declared invisible. The animation
   moves away from that and comes back. `prefers-reduced-motion` kills every animation here, so
   stillness gets a board where the card has been assigned — not one frozen mid-drag with a pointer
   stuck on it.

   #2267 (@admin): "after mouse drag and drop to move card, move mouse to the Unassigned and simulate
   user clicking on dropdown and selecting MrKan." So the drop is no longer the end of the story and
   the card no longer arrives pre-assigned — it arrives UNASSIGNED, which is what a card dragged out
   of a backlog actually looks like, and the pointer then does the assigning. The chip that used to
   appear out of nowhere at the drop is now the RESULT of a visible choice.

      0 – 4.0s   rest: #4097 in Assigned, MrKan on it, no pointer, no menu
      4.2s       the card blinks back to Backlog (the loop's reset, hidden in one frame's opacity)
                 and the chip reads Unassigned behind that frame
      4.6 – 6.6s the pointer comes in from off-board and reaches the card
      7.0s       press — the card lifts
      7.0 – 9.4s drag: exactly one grid step right
     10.0s       release
     11.2s       the pointer reaches the Unassigned chip
     11.4s       click — the assignee menu opens
     12.2 – 12.8s the pointer travels down the menu; MrKan highlights
     12.8s       click — the menu closes and the chip reads MrKan
     14.4 – 20s  the pointer leaves, rest again

   The move needs NO measured vertical offset: the card is the first child of its column and the slot
   is the first child of the next one, so the two share a top edge at every width — including the
   two-column layout below 820px, where Backlog and Assigned are still adjacent. */
.tk.drag{
  position:relative; z-index:3;
  transform:translateX(calc(100% + var(--col-gap,.6rem)));
  animation:drag-card 20s ease-in-out infinite;
}
/* the two assignee states, crossfaded at the moment of the click (64%) rather than at the drop */
.tk.drag .who.taken{animation:drag-taken 20s linear infinite}
.tk.drag .who.free{animation:drag-free 20s linear infinite}
/* #2267: the assignee menu. Anchored to the CARD's right edge, so it inherits the landing geometry
   instead of restating it — the card is the thing that knows where it ended up. `top:100%` hangs it
   below the card, which is where there is room in every layout (measured: internal/drag-pick.mjs
   asserts the whole menu stays inside the board at all eleven widths). */
.pick{
  position:absolute; z-index:4; right:.4rem; top:calc(100% - .3rem);
  min-width:112px; padding:.28rem; text-align:left;
  background:var(--ui-panel-2); border:1px solid var(--ui-line); border-radius:8px;
  box-shadow:0 12px 26px -10px rgba(0,0,0,.8);
  opacity:0; transform:scale(.96); transform-origin:top right; pointer-events:none;
  animation:pick-open 20s ease-out infinite;
}
.pick-o{
  margin:0; padding:.19rem .3rem; border-radius:5px; display:flex; align-items:center; gap:.3rem;
  font-family:var(--mono); font-size:.6rem; color:var(--ui-ink); white-space:nowrap;
}
/* the row the pointer settles on. It highlights BEFORE the click and clears after it, so the choice
   reads as a choice rather than as the menu simply vanishing. */
.pick-pick{animation:pick-hi 20s linear infinite}
/* The landing slot holds the height so the column cannot jump at the drop. It is a real card's markup
   so it cannot drift out of step with the card that lands on it. */
.tk.slot{visibility:hidden}
/* #2267: THE POINTER IS A CHILD OF THE CARD, not of the board, and that is the whole reason the
   second half of this animation can be aimed at all. Everything it visits after the drop — the
   assignee badge, the menu rows — is part of the card, and the card's insets are in `rem`, so a
   card-relative offset is the same number of pixels at every width. A board-relative percentage is
   not: measured across sixteen widths, the badge sits anywhere from 28.4% to 38.1% down the board,
   because `.cols` is as tall as whichever column happens to wrap most, and the intersection of those
   bands is EMPTY — there is no percentage that is on the badge everywhere.
   Two things fall out of it. The pointer now moves WITH the card during the drag by construction,
   which is what a drag looks like and what the old percentages had to restate; and the
   `max-width:820px` copy of these keyframes is gone, because there is no longer a grid for the
   pointer to be measured against. That copy was also WRONG — at two columns it put the pointer at
   `top:26%` while the card ended at 21.7%, so the press landed about 25px under the card. */
.mouse{
  position:absolute; left:2.2rem; top:calc(100% + 1.4rem); width:30px; height:30px; z-index:5; opacity:0;
  fill:#fff; stroke:rgba(10,16,24,.65); stroke-width:1.1; pointer-events:none;
  filter:drop-shadow(0 1px 2px rgba(0,0,0,.5));
  animation:drag-mouse 20s ease-in-out infinite;
}
@keyframes drag-card{
  0%,20%   { transform:translateX(calc(100% + var(--col-gap,.6rem))) scale(1); opacity:1; box-shadow:none }
  21%      { transform:translateX(calc(100% + var(--col-gap,.6rem))) scale(1); opacity:0; box-shadow:none }
  22%,34%  { transform:translateX(0) scale(1); opacity:1; box-shadow:none }
  /* the press: a small lift, and a shadow, which is what makes it read as picked up rather than slid */
  35%,36%  { transform:translateX(0) scale(1.04); opacity:1;
             box-shadow:0 8px 18px -6px rgba(0,0,0,.65) }
  47%      { transform:translateX(calc(100% + var(--col-gap,.6rem))) scale(1.04); opacity:1;
             box-shadow:0 8px 18px -6px rgba(0,0,0,.65) }
  50%,100% { transform:translateX(calc(100% + var(--col-gap,.6rem))) scale(1); opacity:1; box-shadow:none }
}
/* The flip happens AT THE CLICK (64%), not at the drop. Both switch in the same frame, and the 21%
   frame — the loop's reset, where the card is at opacity 0 — is what hides the other flip. */
@keyframes drag-taken{ 0%,20%{opacity:1} 21%,64%{opacity:0} 65%,100%{opacity:1} }
@keyframes drag-free { 0%,20%{opacity:0} 21%,64%{opacity:1} 65%,100%{opacity:0} }
/* The close STARTS at the click (64%) rather than straddling it. It used to ramp 63.5->64.5, which
   left the menu at 0.32 opacity in the very frame the pointer is clicking it — the click landed on a
   menu that was already half gone. internal/drag-pick.mjs is what noticed; it reads the opacity at
   the beat rather than trusting the shape of the keyframes. */
@keyframes pick-open {
  0%,55%   { opacity:0; transform:scale(.96) }
  57%,64%  { opacity:1; transform:scale(1) }
  65%,100% { opacity:0; transform:scale(.97) }
}
@keyframes pick-hi   { 0%,59%{background:transparent} 61%,64%{background:var(--ui-sel)}
                       66%,100%{background:transparent} }
/* Every offset below is card-relative. `100%` is the card's own box, so `calc(100% - 1.9rem)` is
   "just inside its right edge" at every width — and the drag beats (33-50%) do not move at all,
   because the card is carrying the pointer. */
@keyframes drag-mouse{
  0%,21%   { left:2.2rem; top:calc(100% + 1.4rem); opacity:0 }   /* off, below the board */
  23%      { left:2.2rem; top:calc(100% + 1.4rem); opacity:1 }   /* fades in, comes up */
  33%,36%  { left:2.6rem; top:2.4rem; opacity:1 }                /* on the card; press at 35% */
  47%,50%  { left:2.6rem; top:2.4rem; opacity:1 }                /* carried by the card, and released */
  56%,58%  { left:calc(100% - 1.9rem); top:calc(100% - 1.5rem); opacity:1 }  /* the badge; click 57% */
  63%,65%  { left:calc(100% - 2.8rem); top:calc(100% + 4.4rem); opacity:1 }  /* MrKan; click 64% */
  72%,100% { left:2.2rem; top:calc(100% + 1.4rem); opacity:0 }   /* leaves the way it came */
}

.col-h{
  display:flex; align-items:baseline; gap:.45rem; margin-bottom:.55rem;
  font-family:var(--display); font-weight:700; font-size:.76rem; color:var(--ui-ink); letter-spacing:.01em;
}
.col-h b{font-family:var(--mono); font-weight:500; font-size:.68rem; color:#78859a; font-variant-numeric:tabular-nums}
.tk{
  background:var(--ui-panel); border:1px solid var(--ui-line); border-radius:8px;
  padding:.55rem .6rem; margin-bottom:.45rem;
}
/* #2218: 3.06:1 on --ui-panel, now 4.63:1. The shot is `role="img"` with a text alternative, so a
   screen reader never reaches these and WCAG would arguably exempt them as part of a picture — but a
   low-vision reader still LOOKS at it, and "technically exempt" is a poor reason to ship a card id
   nobody can read. One shade lighter costs nothing. */
/* #2245: the id row carries the SLA on its right. `baseline` rather than `center` so the two mono
   strings sit on one line even though they are different sizes. */
.tk .id{
  display:flex; flex-wrap:wrap; align-items:baseline; gap:.15rem .4rem;
  font-family:var(--mono); font-size:.62rem; color:#78859a; font-variant-numeric:tabular-nums;
}
.tk .ti{font-family:var(--display); font-weight:600; font-size:.73rem; color:var(--ui-ink); line-height:1.32; margin-top:.15rem; letter-spacing:-.005em}
.tk .ft{display:flex; align-items:center; gap:.35rem; margin-top:.5rem; flex-wrap:wrap}
.chip{
  font-family:var(--mono); font-size:.58rem; font-weight:700; letter-spacing:.04em;
  padding:.1rem .34rem; border-radius:4px; color:#0f1216;
}
.chip.p0{background:var(--p0); color:#fff} .chip.p1{background:var(--p1)} .chip.p2{background:var(--p2); color:#0f1216}
.chip.bug{background:transparent; color:var(--t-bug); border:1px solid rgba(255,107,138,.45)}
.chip.feat{background:transparent; color:var(--t-feature); border:1px solid rgba(76,208,138,.45)}
.chip.task{background:transparent; color:var(--t-task); border:1px solid rgba(127,138,153,.45)}
.who{
  margin-left:auto; font-family:var(--mono); font-size:.6rem; color:var(--ui-mute);
  display:inline-flex; align-items:center; gap:.25rem;
}
/* #2267: the assignee cell. Both states occupy the SAME grid cell, so the cell is as wide as the
   wider of them (Unassigned) and the chip flip at 64% cannot change the row's width — a swap by
   `display` would, and `.ft` already wraps at 1440 and below, so a width change there would rewrap
   the row and change the card's height mid-animation. */
.asg{margin-left:auto; display:grid; justify-items:end; min-width:0}
.asg .who{grid-area:1/1; margin-left:0}
.asg .who.free{opacity:0}     /* the resting state is the finished one: MrKan */
.asg .who.taken{opacity:1}
/* #2267: `.who .av` until this card. The assignee menu shows the same avatars, so the selector was
   broadened rather than copied — two copies of one look are free to drift. */
.av{width:14px; height:14px; border-radius:50%; background:#2b3442; color:#aeb9c8; display:grid; place-items:center; font-size:.5rem; font-weight:700; flex:none}
.av.bot{background:rgba(91,147,255,.18); color:#8fb6ff}
/* the empty seat: a dashed ring, no initials, because there is nobody to initial */
.av.none{background:transparent; border:1px dashed #4a5464}
/* `margin-left:auto` rather than `justify-content:space-between` on the row, so the SLA still hugs
   the right when it WRAPS — and it has to be able to wrap. Measured before this line existed: at 1280
   and 1024 the shot is four columns at its narrowest, and "SLA 21h left" ran 13px and 50px past the
   card's edge. It fits on the id row at every other width and drops to its own right-aligned line at
   those two, which is what "same row as the card number" can honestly mean in 105px. */
.sla{font-family:var(--mono); font-size:.54rem; color:#78859a; font-variant-numeric:tabular-nums;
  white-space:nowrap; margin-left:auto; min-width:0; overflow:hidden; text-overflow:ellipsis}
.sla.hot{color:var(--p1)}
/* #2245: a merge sha, not a countdown — it was `.sla` until this card, which made one class mean two
   different things. It stays at the FOOT of the card: the id row is for what the card IS, and a
   result belongs with the work. */
.sha{font-family:var(--mono); font-size:.58rem; color:#78859a; font-variant-numeric:tabular-nums}

/* ---------------------------------------------------------------- the console (#2228)
   The bottom panel of the real app: a title, the tab row, the log, and the composer. Rendered from
   the same --ui-* tokens as the board above it, so the two halves of the picture are one product. */
.con{border-top:1px solid var(--ui-line); background:var(--ui-panel)}
.con-head{
  display:flex; align-items:center; gap:.75rem; padding:.42rem .7rem;
  border-bottom:1px solid var(--ui-line); background:var(--ui-panel-2);
}
.con-title{font-family:var(--display); font-weight:700; font-size:.72rem; color:var(--ui-ink); flex:none}
.con-tabs{display:flex; align-items:center; gap:.15rem; min-width:0; overflow:hidden}
.con-tabs b{
  font-family:var(--display); font-weight:600; font-size:.66rem; color:var(--ui-mute);
  padding:.18rem .42rem; border-radius:5px; white-space:nowrap;
}
.con-tabs b.on{background:var(--ui-bg); color:var(--ui-ink); box-shadow:inset 0 0 0 1px var(--ui-line)}
.con-log{padding:.5rem .7rem; display:flex; flex-direction:column; gap:.34rem}
.con-log p{display:flex; align-items:baseline; gap:.5rem; min-width:0}
.con-log .who{
  font-family:var(--mono); font-size:.64rem; font-weight:500; color:var(--ui-accent); flex:none;
  min-width:3.4rem;
}
/* An agent is marked in the app, so it is marked here. A picture that makes the two look identical
   would be selling the opposite of what the page claims. */
.con-log .who.bot{color:#5fcf8e}
.con-log .msg{
  font-family:var(--text); font-size:.72rem; color:var(--ui-ink); line-height:1.35;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; flex:1;
}
.con-log .at{font-family:var(--mono); font-size:.6rem; color:#78859a; flex:none; font-variant-numeric:tabular-nums}
/* ------------------------------------------------- the composer and the reply (#2238, #2243)
   @admin: "Simulate Jo typing on the screen ... when they press enter, it appears as a message from
   jo" (#2238), then "show 'kanbot is thinking...' and then show the response from kanbot" (#2243).
   CSS only — this page has no JavaScript and did not need any for either.

   ONE 20-SECOND CYCLE, and every element reads its phase off the same clock:

      0 – 6.0s   rest: jo's question and kanbot's answer both in the log, composer empty
      6.4s       both leave the log; the placeholder steps aside
      6.4 – 11.2s  jo types, ~9 characters a second, 42 of them
     11.2 – 11.6s  a blinking caret at the end of the line
     11.6s       RETURN: jo's message lands, the composer clears
     11.6 – 13.2s  "kanbot is thinking..." in the reply's row
     13.2s       the answer replaces it
     13.2 – 20s  rest again

   THE RESTING STATE IS THE FINISHED ONE. Every declared value here is what the console looks like
   AFTER the exchange: placeholder visible, typed text and caret at zero, both messages in the log,
   the thinking line hidden. The animations move away from that and come back. The global
   `prefers-reduced-motion` rule at the top of this file is `animation:none !important`, so a reader
   who has asked for stillness gets exactly that still frame — a complete conversation — rather than a
   half-typed line frozen mid-cycle, which is what declaring the START state would have produced.
   It is the same reason t=0 is the finished state: a link preview or a thumbnail catches the page at
   load, and the first frame has to read.

   The composer is in the mono face, unlike the app's textarea. `ch` is the unit the reveal is measured
   in, and in a proportional face it is the width of a "0" rather than of the letter actually being
   revealed — so the caret drifts out of step with the text. One deviation from fidelity, bought for a
   flourish, and named rather than hidden. */
.con-input{
  position:relative; margin:.1rem .7rem .6rem; padding:.34rem .55rem; border-radius:7px;
  background:var(--ui-bg); border:1px solid var(--ui-line);
  font-family:var(--mono); font-size:.68rem; color:#78859a;
  white-space:nowrap; overflow:hidden; min-height:1.5em;
}
.con-input .ph{position:absolute; left:.55rem; top:.34rem; opacity:1; animation:ph-fade 20s linear infinite}
.con-input .typed{
  display:inline-block; vertical-align:bottom; overflow:hidden; white-space:nowrap;
  width:0; opacity:0; color:var(--ui-ink);
  /* 42 characters, one step each: the reveal lands on letter boundaries rather than sliding. */
  animation:type-in 20s steps(42) infinite;
}
.con-input .caret{
  display:inline-block; width:1px; height:.95em; vertical-align:-.12em; background:var(--ui-ink);
  opacity:0; animation:caret-show 20s linear infinite, caret-blink .9s steps(1) infinite;
}
@keyframes ph-fade   { 0%,31%{opacity:1}  32%,57%{opacity:0} 58%,100%{opacity:1} }
@keyframes type-in   { 0%,31%{width:0; opacity:0} 32%{width:0; opacity:1}
                       56%,57%{width:42ch; opacity:1} 58%,100%{width:0; opacity:0} }
@keyframes caret-show{ 0%,31%{opacity:0} 32%,57%{opacity:1} 58%,100%{opacity:0} }
@keyframes caret-blink{ 0%,49%{opacity:1} 50%,100%{opacity:.15} }

/* jo's message: in the log at rest, gone while it is being typed, back at the return key. */
.con-log p.sent{animation:msg-land 20s linear infinite}
@keyframes msg-land  { 0%,30%{opacity:1; transform:none}
                       32%,57%{opacity:0; transform:translateY(2px)}
                       58%,100%{opacity:1; transform:none} }
/* kanbot's row leaves with jo's and comes back one beat LATER — the answer cannot arrive before the
   question. It returns at 58% showing the thinking line, and the answer takes over at 66%. */
.con-log p.reply{animation:reply-row 20s linear infinite}
@keyframes reply-row { 0%,30%{opacity:1} 32%,57%{opacity:0} 58%,100%{opacity:1} }
.con-log p.reply .msg{position:relative}
/* The thinking line is laid OVER the answer, so the row keeps the answer's height throughout and the
   console never grows or loses a line. */
.con-log .think{
  position:absolute; left:0; top:0; white-space:nowrap; color:var(--ui-mute);
  opacity:0; animation:think-show 20s linear infinite;
}
.con-log .think .dots{
  display:inline-block; width:0; overflow:hidden; vertical-align:bottom; font-style:normal;
  font-family:var(--mono); animation:dots 1.2s steps(4) infinite;
}
.con-log .say, .con-log p.reply .at{opacity:1; animation:say-show 20s linear infinite}
@keyframes think-show{ 0%,57%{opacity:0} 58%,65%{opacity:1} 66%,100%{opacity:0} }
@keyframes say-show  { 0%,30%{opacity:1} 32%,65%{opacity:0} 66%,100%{opacity:1} }
@keyframes dots      { 0%{width:0} 100%{width:3ch} }
/* Below 560px one log line goes, and it is the FIRST — not the last. The last is jo's, which is the
   one being typed (#2238); culling it would leave the composer typing a message that lands nowhere.
   The tab row used to be culled here too — that rule is gone with #2241, which left two tabs. */
@media (max-width:560px){ .con-log p:nth-of-type(1){display:none} }

.shot-cap{
  /* #2226: clears the band's lower bleed. The caption is on the white ground like every other piece of
     hero text, so it has to start below where the band ends, not .9rem under the shot. */
  margin-top:3.4rem; font-family:var(--mono); font-size:.72rem;
  color:var(--mute); letter-spacing:.01em; max-width:60ch;
}

/* ---------------------------------------------------------------- logos strip */
.strip{background:var(--ground); padding-block:clamp(2.4rem,4vw,3.2rem)}
.strip .line{
  display:flex; flex-wrap:wrap; align-items:center; justify-content:center; gap:.6rem 2.2rem;
  font-family:var(--mono); font-size:.76rem; color:var(--mute); letter-spacing:.02em;
}
.strip .line span{display:inline-flex; align-items:center; gap:.45rem}
.strip .line em{font-style:normal; color:var(--ink); font-weight:700}

/* ---------------------------------------------------------------- features */
.features{background:var(--tint); border-block:1px solid var(--hair); padding-block:clamp(3.4rem,6vw,5.4rem)}
.sec-head{max-width:52ch}
.sec-head h2{margin-top:.9rem}
.sec-head p{margin-top:.9rem; font-size:1.08rem}
.grid{
  display:grid; gap:1px; margin-top:clamp(2.2rem,4vw,3.2rem);
  grid-template-columns:repeat(4,minmax(0,1fr));
  background:var(--hair); border:1px solid var(--hair); border-radius:12px; overflow:hidden;
}
/* #2410: the feature grid is split into "for everyone" (6) and "for developers" (2). The base
   .grid is 4-across, which leaves BOTH of those ragged inside a bordered rounded box. Six at
   three-across and two at two-across each fill their rows exactly, at every breakpoint. */
/* WRITTEN AS .grid.grid-3, NOT .grid-3, ON PURPOSE. Both would be specificity 0,1,0 against the
   base `.grid{repeat(4,...)}`, so which one won would depend on SOURCE ORDER — and the base rule's
   own 980px/560px media queries sit BELOW this block. Ordering that happens to work today is the
   kind that a later tidy-up silently reverses, and the symptom would be a ragged grid nobody
   connects to a CSS reshuffle. Two classes is 0,2,0 and wins wherever it is written. */
.grid.grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid.grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}
@media (max-width:980px){ .grid.grid-3{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (max-width:560px){ .grid.grid-3, .grid.grid-2{grid-template-columns:1fr} }
@media (max-width:980px){ .grid{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (max-width:560px){ .grid{grid-template-columns:1fr} }
.cell{background:var(--ground); padding:1.5rem 1.35rem 1.65rem; display:flex; flex-direction:column; gap:.55rem}
.cell .ico{width:30px; height:30px; margin-bottom:.35rem; color:var(--accent)}
.cell .ico svg{width:100%; height:100%; display:block}
.cell p{font-size:.95rem; line-height:1.6}
.cell code{
  font-family:var(--mono); font-size:.82em; background:var(--accent-wash);
  color:var(--accent-deep); padding:.06em .32em; border-radius:4px;   /* 6.83:1 on white, 6.61 on the wash */
}

/* ---------------------------------------------------------------- closing cta */
.close{position:relative; overflow:hidden; background:#0a2540; color:#fff; padding-block:clamp(3.4rem,6vw,5rem)}
.close::before{
  content:""; position:absolute; inset:-40% -10% auto -10%; height:600px;
  background:linear-gradient(104deg,#00d4ff 0%,#635bff 46%,#ff5996 100%);
  transform:skewY(-7deg); opacity:.92;
}
.close-in{position:relative; z-index:1; display:flex; flex-wrap:wrap; align-items:center; gap:1.6rem; justify-content:space-between}
.close h2{color:#fff; max-width:18ch}
.close p{color:rgba(255,255,255,.88); margin-top:.7rem; max-width:42ch}

/* ---------------------------------------------------------------- footer */
footer{background:var(--ground); padding-block:2.4rem; border-top:1px solid var(--hair-soft)}
.foot{display:flex; flex-wrap:wrap; gap:1rem 2rem; align-items:center; justify-content:space-between}
.foot .small{font-family:var(--mono); font-size:.74rem; color:var(--mute)}
.foot nav{display:flex; flex-wrap:wrap; gap:1.4rem}
.foot nav a{font-family:var(--display); font-weight:600; font-size:.88rem; color:var(--mute)}
.foot nav a:hover{color:var(--ink)}
