/* landing.css — the public landing page at /about (and at "/" on the apex host).
   ===========================================================================
   SELF-CONTAINED ON PURPOSE. This is the only stylesheet about.html loads: no
   style.css, no about.css, no writing-mood.css. That is the whole point of the
   file existing.

   Why: about.css is SHARED with /tutorial — .about-nav*, .about-foot*,
   .about-eyebrow, .lp-browser, .lp-band and .lp-hl are all used by both pages.
   Restyling the landing inside that file would silently redesign /tutorial too,
   and /tutorial's console replica is built from the REAL app markup, so the
   blast radius reaches style.css and from there the lesson screen. Cutting the
   landing out into its own file puts that radius at zero.

   Cost of the split, stated plainly: /tutorial still wears the serif direction
   from v1.4.40 while this page is neo-brutalist, and the navbar links straight
   to it. That is a known, accepted mismatch, not an oversight.

   Direction: Product-Led Neo-Brutalism — flat colour, 2px black rules, hard
   offset shadows with no blur, a 32px technical grid on the ground, and a
   visible mock of the actual product instead of a wall of adjectives.

   No @media (prefers-reduced-motion) anywhere in this file — CLAUDE.md rule 4.
   Every device in this project reports reduce-motion as ON, so such a block
   would permanently disable whatever it guarded (it once froze a spinner and
   made the app look hung). Motion here is gated on @media (hover: hover)
   instead, which is a capability query, not a preference query.

   Token prefix is --ls-* ("landing shell") rather than the app's --lp-*: those
   still live in style.css and belong to the serif direction. Two palettes with
   one prefix is how a colour system becomes impossible to reason about. */


/* ---------- fonts ----------
   Self-hosted, and not optional: the CSP sets `font-src 'self'` (main.py
   SECURITY_HEADERS), so the <link href="fonts.googleapis.com"> the mockup used
   would simply be blocked and every heading would fall back to a system face.

   Same subsetting approach as style.css:57-135 — latin + vietnamese only, no
   latin-ext. The VIETNAMESE subset is the non-negotiable one: latin-ext stops
   short of U+1EA0–1EF9, which is where most Vietnamese tone marks live, so a
   face without it drops to a system font MID-WORD on any toned syllable. That
   is not hypothetical here; "Archivo Black" did exactly that on this codebase.

   Space Grotesk is a VARIABLE font: one file per subset covers the whole
   300–700 axis, so `font-weight: 300 700` is literal, not the aliasing trick
   Cal Sans needs in style.css.

   Be Vietnam Pro is a STATIC family: every weight is its own file, which is why
   there are eight of them below and not two. Verified as four genuinely
   different files by checksum, not assumed. */

@font-face {
  font-family: 'Space Grotesk'; font-style: normal; font-weight: 300 700;
  font-display: swap; src: url('/static/fonts/space-grotesk-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Space Grotesk'; font-style: normal; font-weight: 300 700;
  font-display: swap; src: url('/static/fonts/space-grotesk-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 400;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-400-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 400;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 500;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-500-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 500;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 600;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-600-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 600;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 700;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-700-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 700;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}


/* ---------- tokens ---------- */

:root {
  --ls-font-head: 'Space Grotesk', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --ls-font-body: 'Be Vietnam Pro', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  /* No self-hosted mono here: the two places that want one (the window URL and
     the fake input) are decorative English strings inside an aria-hidden mock,
     so a system stack costs nothing and saves 26KB. */
  --ls-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --ls-ink: #000000;
  --ls-paper: #f7f7f5;
  --ls-white: #ffffff;
  --ls-slate: #f4f4f5;
  --ls-red: #e11d48;
  /* The announcement bar's own red. True CSS crimson, deliberately NOT
     --ls-red: the bar sits directly above a hero whose CTA is --ls-red, and
     Marc wanted the two reading as neighbours rather than as one flat slab
     of the same colour running down the page. */
  --ls-crimson: #dc143c;
  --ls-yellow: #facc15;
  --ls-green: #22c55e;

  /* Body text is not pure black — at 1.65 leading over a long Vietnamese
     paragraph, #000 on #f7f7f5 vibrates. The headings keep the pure black. */
  --ls-body-ink: #27272a;
  --ls-muted: #52525b;
  --ls-faint: #71717a;

  /* Highlight fills for the mock essay. Same two categories the real product
     uses (logic = amber, language = blue), so the legend is not inventing a
     taxonomy that does not exist. */
  --ls-hl-logic: #fef08a;
  --ls-hl-lang: #bae6fd;

  /* Hard shadows, no blur. Three depths only — the mockup used 3/4/6 and there
     is no reason for a fourth. */
  --ls-shadow-sm: 3px 3px 0 var(--ls-ink);
  --ls-shadow: 4px 4px 0 var(--ls-ink);
  --ls-shadow-lg: 6px 6px 0 var(--ls-ink);

  --ls-border: 2px solid var(--ls-ink);
  --ls-hair: 1px solid var(--ls-ink);

  --ls-max: 1120px;
  --ls-gutter: clamp(16px, 4vw, 40px);
}


/* ---------- reset ----------
   Bare ELEMENT selectors, specificity (0,0,1). That is deliberate and it is the
   fix for a trap this project has already paid for: a reset written as
   `.mock p { margin: 0 }` scores (0,1,1) and beats every single-class component
   rule, which silently ate six vertical gaps and turned a button into black
   text on a black background. Element selectors lose to any class, so component
   rules below always win. Since this stylesheet is alone on the page, there is
   nothing else for them to reach. */

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

/* `.ls-announce` đặt `display:flex`, và một selector class (0,1,0) thắng rule
   mặc định của trình duyệt `[hidden] { display: none }` (0,0,0). Thiếu dòng này
   thì about.js set `banner.hidden = true` bao nhiêu lần thanh thông báo vẫn
   hiện — hết campaign mà landing vẫn rao miễn phí, và món nợ tay 05/10 đã bị
   xoá khỏi STATUS.md vì tin rằng cơ chế này chạy.

   Cùng lỗi đã trả giá một lần ở style.css:888 (thẻ "Kết thúc & xem báo cáo"
   nằm lì trên màn hình báo cáo). Trang này cố ý KHÔNG nạp style.css — xem đầu
   file — nên nó không thừa hưởng dòng vá đó và phải có bản của riêng mình.

   `!important` ở đây là đúng chỗ chứ không phải lười: `hidden` là chỉ dẫn của
   HTML chứ không phải một lựa chọn tạo hình, nên nó phải thắng mọi rule bố cục
   bên dưới. test_mechanics.py canh cả dòng này lẫn cái rule làm nó cần thiết. */
[hidden] { display: none !important; }

body {
  margin: 0;
  font-family: var(--ls-font-body);
  font-weight: 400;
  font-size: 18.5px;
  line-height: 1.65;
  letter-spacing: 0.01em;
  color: var(--ls-body-ink);
  background-color: var(--ls-paper);
  /* The technical grid. 5% black hairlines on a 32px module — the exact figure
     from the spec, and the same 5% the old landing used for its texture, so the
     page has one texture register rather than two arguing. */
  background-image:
    linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
  background-size: 32px 32px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

h1, h2, h3 {
  margin: 0;
  font-family: var(--ls-font-head);
  font-weight: 700;
  line-height: 1.2;
  /* NEVER negative. Vietnamese stacks diacritics above AND below the same
     syllable (ệ ố ự); pulling the tracking in at display size is where the
     marks start colliding with their neighbours. The spec called this out and
     it is right. */
  letter-spacing: normal;
  color: var(--ls-ink);
  text-wrap: balance;
}

p { margin: 0; }
ul { margin: 0; padding: 0; list-style: none; }
a { color: inherit; }
img, svg { max-width: 100%; }

:focus-visible {
  outline: 3px solid var(--ls-red);
  outline-offset: 2px;
}


/* ---------- shared box language ----------
   Two utilities instead of Tailwind's border-2 / shadow-[Npx_Npx_0px_#000].
   Everything on the page is one of these two weights. */

.ls-box    { border: var(--ls-border); box-shadow: var(--ls-shadow-lg); }
.ls-box-sm { border: var(--ls-border); box-shadow: var(--ls-shadow-sm); }

.ls-shell {
  width: 100%;
  max-width: var(--ls-max);
  margin-inline: auto;
  padding-inline: var(--ls-gutter);
}


/* ---------- A. the pinned top block ----------
   .ls-topbar wraps the announcement bar AND the header, and it is the single
   sticky element on this page. Marc's call, reversing what used to be here: the
   announcement rode in normal flow and only the header pinned, so scrolling
   peeled the two apart and the promise left the screen after 40px.

   The old arrangement had a real reason and that cost is now paid on purpose —
   two pinned bars eat ~93px of a 375px phone's viewport before the page has
   said anything. If that ever reads too heavy, the lever is .ls-nav's padding
   in the narrow-screen block at the end of this file, not un-pinning the bar.

   z-index lives here, on the wrapper, rather than on .ls-nav: both bars have to
   sit above the page content as one layer. Sticky-inside-sticky would do
   nothing for the header anyway — its parent box is exactly as tall as it is. */

.ls-topbar {
  position: sticky;
  top: 0;
  z-index: 80;
}

.ls-announce {
  background: var(--ls-crimson);
  border-bottom: var(--ls-border);
  padding: 10px var(--ls-gutter);
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  /* White, not --ls-ink: black on crimson clears AA only just (~4.4:1) and reads
     as a warning label; white is the higher contrast AND the right register. */
  color: var(--ls-white);
  display: flex;
  align-items: center;
  /* Was `justify-content: center; text-align: center` for a single centred
     line. A running ticker can't be centred — its content is wider than the
     visible track by design — so the tag anchors the left edge and the track
     fills whatever is left. */
  gap: 14px;
}

.ls-announce-tag {
  flex: none;
  background: var(--ls-ink);
  color: var(--ls-white);
  padding: 2px 8px;
  font-size: 12.5px;
  letter-spacing: 0.06em;
}

/* ---------- the ticker ----------
   Pure CSS, no JS, same call as the highlight sweep on this page: a fixed
   `translateX` loop is compositor-only (no paint per frame), which is also
   why this doesn't reintroduce the kind of scroll cost the 100vh/100dvh fix
   in style.css was written to remove.

   The track clips; the scroll strip inside it holds TWO identical groups side
   by side (`width: max-content` lets it grow past the track instead of
   wrapping). Animating exactly -50% moves the strip by one group's width, so
   the moment the first group has scrolled fully off, the second —
   pixel-identical — is sitting exactly where the first started: the loop point
   is invisible.

   Each group is TWELVE copies of the sentence, not one. That count is the whole
   fix for the bar reading half-empty: the loop only stays seamless while one
   group is wider than the visible track. Measured at desktop size, one copy of
   the current sentence is ~342px including its trailing gap, so a two-copy
   strip would be ~684px against a ~1750px track on a 1920px screen — the
   message runs out and a bare stretch rides across the bar, which is exactly
   what it used to do. Twelve copies is ~4100px, clear of the track even on a
   3440px ultrawide.

   This count is tied to the SENTENCE, not fixed forever: the earlier, longer
   wording measured ~521px a copy and needed only eight. Reword the message and
   re-measure rather than assuming the old number still holds — and measure at
   DESKTOP width, which is the case that decides. The narrow-screen block below
   drops the font to 12.5px, so a copy there is only ~291px, but the track
   narrows further still; a count that survives a wide screen survives a phone.
   The -50% maths never changes — only how many copies sit inside each group,
   and the duration below, which is a speed.

   The copies live in the markup rather than in CSS content generation, which
   cannot be given separate accessibility treatment — only the very first span
   is readable, the other eleven are aria-hidden. */
.ls-announce-track {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
}

.ls-announce-scroll {
  display: flex;
  width: max-content;
  /* Read this as a SPEED, not a duration: one group is ~4100px wide, so 90s is
     ~46px/s. The number cannot be compared with the 22s this ran at before —
     back then a group was a single copy. Change the copy count or the sentence
     and this has to move with it, or the pace changes underneath you. 46px/s is
     the pace Marc settled on; ~26px/s read as too slow. */
  animation: ls-marquee 90s linear infinite;
}

.ls-announce-group {
  display: flex;
}

.ls-announce-scroll span {
  white-space: nowrap;
  padding-right: 64px;
}

@keyframes ls-marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* Pause to read, pointer devices only — never gated on prefers-reduced-motion
   (CLAUDE.md rule 4: every device in this project reports it on, so that
   query would stop the ticker permanently instead of on request). */
@media (hover: hover) {
  .ls-announce:hover .ls-announce-scroll { animation-play-state: paused; }
}


/* ---------- B. header ----------
   The pinning moved up to .ls-topbar; what is said there still holds here.
   Sticky, never fixed: the block sits first in the document so it behaves
   identically on load and on scroll while still participating in layout, which
   is measurably better behaved when mobile Safari animates its address bar.
   That failure class has bitten this codebase before.

   Opaque paper, no grid: the grid's phase would shift against the content
   sliding under it, which reads as a rendering fault rather than a texture.

   No border at rest — the separation is the hard shadow that about.js turns on
   past 4px of scroll. A shadow, not a border, so nothing shifts by 2px the
   moment the reader touches the wheel. */

.ls-nav {
  /* Not sticky itself — .ls-topbar above pins this and the announcement bar
     together as one block. */
  background: var(--ls-paper);
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px var(--ls-gutter);
  transition: box-shadow 0.16s ease;
}

.ls-nav.is-scrolled { box-shadow: 0 2px 0 var(--ls-ink); }

.ls-brand {
  display: flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  flex: none;
}

.ls-brand-mark {
  width: 34px;
  height: 34px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ls-ink);
  color: var(--ls-white);
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 22px;
  line-height: 1;
  border: var(--ls-border);
  box-shadow: var(--ls-shadow-sm);
}

.ls-brand-name {
  display: block;
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 23px;
  line-height: 1.15;
  color: var(--ls-ink);
}

/* The sub-label is the one place uppercase micro-tracking earns its keep: it is
   an English system name, not Vietnamese prose, so tracking cannot collide with
   any diacritic. */
.ls-brand-sub {
  display: block;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ls-faint);
  line-height: 1.3;
}

.ls-nav-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
}

.ls-nav-link {
  font-weight: 600;
  font-size: 16px;
  color: var(--ls-body-ink);
  text-decoration: none;
  padding: 8px 10px;
  white-space: nowrap;
}
.ls-nav-link:hover { text-decoration: underline; text-underline-offset: 4px; }

.ls-nav-cta {
  flex: none;
  background: var(--ls-white);
  color: var(--ls-ink);
  font-weight: 700;
  font-size: 16px;
  text-decoration: none;
  padding: 8px 18px;
  white-space: nowrap;
  border: var(--ls-border);
  box-shadow: var(--ls-shadow-sm);
}


/* ---------- C. hero ---------- */

.ls-hero {
  padding: clamp(36px, 7vh, 76px) 0 clamp(28px, 5vh, 48px);
  text-align: center;
}

.ls-hero-inner { max-width: 780px; margin-inline: auto; }

/* .ls-badge, .ls-badge-dot and the @keyframes ls-pulse that drove the blinking
   red dot are gone with the "first critical-tutor system" badge they styled
   (Marc's call — see about.html). Removed rather than left behind: this file is
   the landing's only stylesheet, so a rule with no element is dead weight
   nobody will recognise as dead six months from now.

   Losing the pulse also removes the one thing on this page that moved forever.
   The highlight sweep below replaces it, and that one runs ONCE. */



.ls-hero-title {
  font-size: clamp(2.1rem, 6.4vw, 4rem);
  /* 1.28, not the 1.2 the rest of the headings take. This is the one string on
     the page that gets a red block on its second line: the block's 2px border
     plus its padding physically occupy more than the line box, and at 1.2 the
     border clipped the descenders of the line above it. */
  line-height: 1.28;
}

/* The red slab. inline-block so the border and the hard shadow wrap the text
   rather than the whole line box, and rotated a single degree — enough to read
   as placed by hand, little enough that it never looks like a mistake. */
.ls-hero-mark {
  display: inline-block;
  margin-top: 12px;
  background: var(--ls-red);
  color: var(--ls-white);
  border: var(--ls-border);
  box-shadow: var(--ls-shadow);
  padding: 4px 18px 8px;
  transform: rotate(-1deg);
}

.ls-hero-sub {
  margin-top: 26px;
  margin-inline: auto;
  max-width: 62ch;
  font-size: clamp(0.98rem, 2.2vw, 1.08rem);
  font-weight: 500;
  line-height: 1.7;
  color: var(--ls-muted);
  text-wrap: pretty;
}

.ls-hero-actions {
  margin-top: 32px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

/* .lp-cta is NOT a leftover name — track.js:82 binds the funnel's `cta_click`
   event to exactly this class. Rename it and the only top-of-funnel instrument
   this project has goes dark with no error and no symptom. The second CTA at
   the foot of the page carries it too, on purpose: both are the same event. */
.lp-cta {
  display: inline-block;
  background: var(--ls-red);
  color: var(--ls-white);
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: clamp(1rem, 2.4vw, 1.15rem);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-decoration: none;
  padding: 17px 38px;
  border: var(--ls-border);
  box-shadow: var(--ls-shadow-lg);
}

/* No max-width. It carried 46ch, which cut the line one word past the "•"
   separator and left the bullet dangling at the end of line one — the one
   place the line must NOT break. The string is two clauses joined by that
   bullet; balance splits them on it instead. */
.ls-microcopy {
  font-size: 15px;
  font-weight: 500;
  color: var(--ls-faint);
  text-wrap: balance;
}


/* ---------- D. product showcase ----------
   A hand-built marketing illustration, and it says so in the markup: this is
   NOT the "replica assembled from real production markup" doctrine that
   about.css follows for /tutorial. That doctrine exists so a depiction cannot
   drift from the product, and it is worth its cost on a page whose whole job is
   showing the interface in detail. Here the job is different — one glance, one
   idea ("it argues back") — and importing style.css to get it would drag the
   entire lesson stylesheet onto the landing page's critical path.

   The whole block is aria-hidden: every number in it is illustrative, and a
   screen reader announcing a fake band score as if it were a result is worse
   than silence. The three-step section below says the same thing in words. */

.ls-showcase { padding-bottom: clamp(40px, 8vh, 88px); }

.ls-window {
  background: var(--ls-white);
  border: var(--ls-border);
  box-shadow: var(--ls-shadow-lg);
  max-width: 980px;
  margin-inline: auto;
  overflow: hidden;
}

.ls-window-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 14px;
  background: var(--ls-ink);
  border-bottom: var(--ls-border);
}

.ls-window-dots { display: flex; gap: 6px; flex: none; }
.ls-dot { width: 11px; height: 11px; border: 1px solid var(--ls-white); }
.ls-dot--r { background: var(--ls-red); }
.ls-dot--y { background: var(--ls-yellow); }
.ls-dot--g { background: var(--ls-green); }

.ls-window-url {
  margin-left: 6px;
  font-family: var(--ls-font-mono);
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: 0.03em;
  color: #a1a1aa;
  /* The URL is the first thing to go when the bar runs out of room; the badge
     on the right is carrying real information and must not be pushed off. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

.ls-window-badge {
  margin-left: auto;
  flex: none;
  background: var(--ls-yellow);
  color: var(--ls-ink);
  border: var(--ls-hair);
  padding: 2px 8px;
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
}

.ls-window-body {
  padding: clamp(16px, 3vw, 26px);
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 18px;
}

/* 7 / 5 of a 12-column grid, as specified. Collapses to one column below 900px
   rather than squeezing: the essay column stops being readable long before the
   grid stops fitting. */
@media (min-width: 900px) {
  .ls-window-body { grid-template-columns: 7fr 5fr; gap: 22px; }
}

/* --- essay column --- */

.ls-essay {
  background: #fafafa;
  border: var(--ls-border);
  padding: clamp(14px, 2.4vw, 20px);
  display: flex;
  flex-direction: column;
}

.ls-essay-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding-bottom: 10px;
  margin-bottom: 14px;
  border-bottom: 2px solid #e4e4e7;
}

.ls-essay-label {
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ls-red);
}

.ls-essay-count {
  flex: none;
  font-family: var(--ls-font-mono);
  font-size: 12.5px;
  font-weight: 700;
  background: #e4e4e7;
  padding: 2px 8px;
  color: var(--ls-body-ink);
}

.ls-essay-text { font-size: 16px; line-height: 1.85; color: #18181b; }
.ls-essay-text + .ls-essay-text { margin-top: 12px; }
.ls-essay-text--faded { color: #a1a1aa; }

/* box-decoration-break:clone is the load-bearing line, not a nicety: a flagged
   phrase in a column this narrow wraps, and without it only the FIRST line
   fragment gets the fill and the border — the rest of the phrase loses its
   highlight entirely. Same reason about.css:774-784 does it. */
/* ---------- the flagged phrases ----------
   PAINTED WITH A GRADIENT, NOT A BORDER, AND THAT IS THE BUG FIX.

   This used to be `background` + a real 1px `border`. With
   box-decoration-break: clone — which is required, or a phrase that wraps
   loses its highlight on every line after the first — each line fragment gets
   all FOUR of its own edges. So a phrase spanning two lines rendered a closing
   right edge halfway through itself and an opening left edge on the next line,
   and read as two unrelated marks. Marc's report was exactly that: "highlight
   không hết câu". Three of the five phrases on this page wrap.

   A background-image and an inset bottom rule are not boxes, so clone repeats
   them per fragment without drawing any vertical edges — the phrase reads as
   one continuous mark however many lines it takes. Same technique about.css
   already uses for /tutorial's .lp-hl.

   The gradient is also what makes the sweep below possible: animating
   background-size from 0% wipes the fill in from the left. A `background`
   colour has no size to animate. */
.ls-hl {
  /* TWO gradient layers, not a fill plus a box-shadow: the underline has to be
     part of the same paint so it sweeps WITH the fill. As a box-shadow it was
     unaffected by background-size, so before the sweep every phrase showed a
     full-width underline with nothing inside it — which reads as a rendering
     fault, not as "not analysed yet". */
  background-image:
    linear-gradient(to top, rgba(0, 0, 0, 0.34) 0 1.5px, transparent 1.5px),
    linear-gradient(var(--ls-hl-c), var(--ls-hl-c));
  background-repeat: no-repeat;
  background-size: 100% 100%;
  --ls-hl-c: var(--ls-hl-logic);
  padding: 0.04em 0.22em;
  margin: 0 -0.02em;
  font-weight: 500;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.ls-hl--lang { --ls-hl-c: var(--ls-hl-lang); }

/* ---------- the sweep ----------
   Each phrase wipes in from the left as the mock scrolls into view, staggered
   so they land in reading order rather than all at once. about.js adds
   .is-lit once, then stops watching (see that file).

   Starts at 0% width, so before it fires the essay reads as plain text and the
   marks arrive as the tutor "finding" them — which is the one thing this page
   is trying to show.

   NO @media (prefers-reduced-motion) guard (CLAUDE.md rule 4: every device
   here reports it on, so the guard would delete the effect for everyone).
   Instead the motion is bounded by design — it runs ONCE per phrase, ends in
   a static state, and nothing on this page loops.

   The un-swept state is applied only when about.js has confirmed it will run
   (html.ls-sweep). Without JS the marks are simply painted, never blank. */
/* NO transition on the un-lit state, and that placement is the whole trick.
   about.js adds .ls-sweep after the document parses, so if the transition lived
   here the marks would animate 100% -> 0% first: every highlight would visibly
   drain away and only then wipe back in. Declaring the transition on the
   DESTINATION means collapsing to 0% is instantaneous and only the sweep in is
   animated. Caught by reading computed background-size rather than by watching,
   because the drain is one frame of the page's life. */
.ls-sweep .ls-hl { background-size: 0% 100%; }
.ls-sweep .ls-hl.is-lit {
  background-size: 100% 100%;
  transition: background-size 0.55s cubic-bezier(0.22, 1, 0.36, 1);
  transition-delay: calc(var(--i, 0) * 90ms);
}

.ls-legend {
  margin-top: auto;
  padding-top: 14px;
  border-top: var(--ls-border);
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  font-size: 14px;
  font-weight: 600;
}
/* margin-top:auto above needs something to push against on a short column. */
.ls-legend-item { display: inline-flex; align-items: center; gap: 7px; }
.ls-swatch { width: 13px; height: 13px; flex: none; border: var(--ls-hair); }
.ls-swatch--logic { background: var(--ls-hl-logic); }
.ls-swatch--lang  { background: var(--ls-hl-lang); }

/* --- socratic column --- */

.ls-side { display: flex; flex-direction: column; gap: 16px; }

.ls-band {
  background: var(--ls-yellow);
  border: var(--ls-border);
  box-shadow: var(--ls-shadow-sm);
  padding: 14px 16px;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
}

.ls-band-label {
  display: block;
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ls-ink);
}

.ls-band-now {
  display: block;
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 32px;
  line-height: 1.1;
  color: var(--ls-ink);
  font-variant-numeric: tabular-nums;
}

.ls-band-cap { text-align: right; }

.ls-band-ceiling {
  display: inline-block;
  margin-top: 2px;
  background: var(--ls-white);
  color: var(--ls-red);
  border: var(--ls-hair);
  padding: 1px 9px;
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 23px;
  line-height: 1.25;
  font-variant-numeric: tabular-nums;
}

.ls-ask {
  background: var(--ls-ink);
  border: var(--ls-border);
  padding: 18px;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.ls-ask-tag {
  align-self: flex-start;
  background: var(--ls-red);
  color: var(--ls-white);
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 3px 8px;
  margin-bottom: 12px;
}

.ls-ask-text {
  font-size: 15.5px;
  line-height: 1.7;
  color: #e4e4e7;
  font-weight: 400;
  /* The FLOOR for the gap above the composer. The input's margin-top:auto does
     the spreading, but auto collapses to 0 the moment the question fills the
     panel — at which point the text would touch the input box. */
  margin-bottom: 16px;
}

/* margin-top:auto, not a fixed gap: .ls-ask is a stretched flex column, so the
   input parks at the bottom of the panel the way a real composer does instead
   of floating wherever the question text happens to end. */
.ls-ask-input {
  margin-top: auto;
  padding: 10px 12px;
  background: #18181b;
  border: 1px solid #3f3f46;
  font-family: var(--ls-font-mono);
  font-size: 14px;
  color: #71717a;
}


/* ---------- E. three steps ---------- */

.ls-steps { padding: clamp(40px, 7vh, 76px) 0; border-top: var(--ls-border); }

.ls-section-head { text-align: center; max-width: 620px; margin: 0 auto clamp(28px, 5vh, 46px); }

.ls-section-title { font-size: clamp(1.7rem, 4.4vw, 2.5rem); }

.ls-section-sub {
  margin-top: 14px;
  font-size: 1rem;
  font-weight: 500;
  color: var(--ls-muted);
  text-wrap: pretty;
}

.ls-step-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 22px;
}

@media (min-width: 860px) {
  .ls-step-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.ls-step {
  background: var(--ls-white);
  border: var(--ls-border);
  box-shadow: var(--ls-shadow);
  padding: 26px 24px 28px;
  display: flex;
  flex-direction: column;
}

.ls-step-num {
  width: 40px;
  height: 40px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--ls-yellow);
  border: var(--ls-border);
  font-family: var(--ls-font-head);
  font-weight: 700;
  font-size: 20.5px;
  color: var(--ls-ink);
  margin-bottom: 18px;
}

.ls-step-title { font-size: 1.22rem; margin-bottom: 10px; }

.ls-step-desc {
  font-size: 16.5px;
  line-height: 1.7;
  color: var(--ls-muted);
  text-wrap: pretty;
}

/* The caveat under step 3. Quieter than the body it follows and set off by a
   rule, because it qualifies the promise above it rather than being a fourth
   thing the step does. */
.ls-step-ps {
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid var(--ls-hl-logic);
  font-size: 13px;
  line-height: 1.6;
  font-weight: 500;
  color: var(--ls-faint);
}


/* ---------- closing CTA ---------- */

.ls-close {
  border-top: var(--ls-border);
  padding: clamp(40px, 7vh, 76px) 0;
  text-align: center;
}

.ls-close-inner { max-width: 640px; margin-inline: auto; }

.ls-close-title { font-size: clamp(1.6rem, 4.2vw, 2.3rem); margin-bottom: 14px; }

.ls-close-sub {
  font-size: 1rem;
  font-weight: 500;
  color: var(--ls-muted);
  margin-bottom: 30px;
  text-wrap: pretty;
}


/* ---------- F. footer ---------- */

.ls-foot {
  border-top: var(--ls-border);
  background: var(--ls-white);
  padding: 26px 0;
  font-size: 15px;
  font-weight: 500;
}

.ls-foot-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 26px;
}

.ls-foot-links { display: flex; flex-wrap: wrap; gap: 10px 22px; }
.ls-foot-links a { color: var(--ls-muted); text-decoration: none; }
.ls-foot-links a:hover { color: var(--ls-ink); text-decoration: underline; text-underline-offset: 3px; }


/* ---------- hover / press ----------
   Gated on (hover: hover) so a touch device never sticks in a hover state.
   Never gated on prefers-reduced-motion — CLAUDE.md rule 4; see the file
   header. The press effect is the neo-brutalist idiom: the box travels into its
   own shadow, so the offset shrinks by exactly what the box moved. */

@media (hover: hover) {
  .ls-nav-cta, .lp-cta, .ls-step, .ls-window {
    transition: transform 0.12s ease, box-shadow 0.12s ease;
  }

  .ls-nav-cta:hover { transform: translate(1px, 1px); box-shadow: 2px 2px 0 var(--ls-ink); }
  .lp-cta:hover     { transform: translate(2px, 2px); box-shadow: var(--ls-shadow); }
  .ls-step:hover    { transform: translate(-1px, -2px); box-shadow: 6px 6px 0 var(--ls-ink); }
  .ls-window:hover  { transform: translate(-1px, -2px); box-shadow: 8px 8px 0 var(--ls-ink); }
}

/* :active is NOT inside the hover query — a finger tap fires it, and the
   button should read as pressed on a phone too. */
.ls-nav-cta:active { transform: translate(3px, 3px); box-shadow: none; }
.lp-cta:active     { transform: translate(5px, 5px); box-shadow: none; }


/* ---------- narrow screens ----------
   Sizes below are measured against nav.scrollWidth vs nav.clientWidth at 375px,
   the narrowest phone worth targeting — not guessed. Fitting the brand block,
   a text link and a button across that width takes every one of these. */

@media (max-width: 640px) {
  .ls-announce {
    font-size: 12.5px;
    letter-spacing: 0.03em;
    padding: 9px 14px;
    gap: 8px;
    /* `flex-wrap: wrap` used to live here for the old centred, non-scrolling
       bar, where a long message genuinely could wrap to a second line on a
       narrow screen. The message is a fixed-width scrolling strip now
       (.ls-announce-track has min-width: 0 and clips it), so there is
       nothing left to wrap — removed rather than kept as dead weight with a
       comment that no longer describes what's here. */
  }
  .ls-announce-tag { font-size: 12px; }

  /* Re-tuned after the site-wide type bump (+15%) pushed this row 29px past
     the viewport at 375px. Everything here is measured against
     nav.scrollWidth vs nav.clientWidth, not guessed — the bar breaks in two
     different ways otherwise: the label wraps to three lines and blows the
     bar's height, or the row overflows and the login button leaves the screen.
     These stay above the 12px floor; they are simply the smallest step of the
     scale rather than the middle of it. */
  .ls-nav { gap: 4px; padding: 10px 10px; }
  .ls-brand { gap: 7px; }
  .ls-brand-mark { width: 28px; height: 28px; font-size: 17px; }
  .ls-brand-name { font-size: 17px; }
  /* Kept visible, not hidden. /tutorial is the only route to the full
     walkthrough and a phone visitor needs it as much as anyone — the old
     landing hid this link on phones and that left a visitor with a logo, a
     login button, and no way to the product tour at all. */
  .ls-nav-link { font-size: 13px; padding: 8px 2px; }
  .ls-nav-cta { font-size: 13px; padding: 7px 9px; }
  .ls-nav-right { gap: 2px; }

  .lp-cta { padding: 15px 26px; }
  .ls-hero-mark { padding: 3px 14px 7px; }
  .ls-foot-inner { justify-content: flex-start; }
}

/* The sub-label is the first thing to go, and only here.
   MEASURED, not guessed: at 375px the row wants brand 165 + gap + right group
   211 against a 347px content box — 37px over, which put the login button off
   the right edge. "ACTIVE LEARNING SYSTEM" is the widest single string in the
   brand block and the least load-bearing thing in the bar: it is decorative
   English micro-type that is barely legible at 8px on a phone anyway, whereas
   the two links are the page's only routes onward. Dropping it is what buys
   the room to keep them both. */
/* The sub-label is the first thing to go, and only here. "ACTIVE LEARNING
   SYSTEM" is the widest single string in the brand block and the least
   load-bearing item in the bar — decorative English micro-type — whereas the
   two links are the page's only routes onward. Dropping it buys the room to
   keep them both.
   The name does NOT grow back here: it used to be set larger at this
   breakpoint than at 640px, which quietly cancelled the tightening above and
   was how the row ended up overflowing again. */
@media (max-width: 480px) {
  .ls-brand-sub { display: none; }
  .ls-brand-name { line-height: 1; }
}
