/* Lesson pages — requires global.css */

/* ── Layout ── */
.lesson-container {
  display: grid;
  grid-template-columns: 0 17.5rem 1fr;
  min-height: calc(100vh - 3.875rem);
  transition: grid-template-columns 0.25s ease;
}

.lesson-container.sidebar-collapsed {
  grid-template-columns: 2rem 0 1fr;
}

/* ── Sidebar tab — hidden when sidebar is open, visible when collapsed ── */
.lesson-sidebar-tab {
  grid-column: 1;
  grid-row: 1;
  align-self: start;
  writing-mode: vertical-rl;
  text-orientation: mixed;
  transform: rotate(180deg);
  background: var(--bg-card);
  border: none;
  border-right: 1px solid var(--border);
  color: var(--primary);
  font-family: "Sora", sans-serif;
  font-size: var(--fs-2xs);
  font-weight: 700;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  cursor: pointer;
  padding: 1.25rem 0.5rem;
  display: none;
  align-items: center;
  justify-content: center;
  position: sticky;
  top: 3.875rem;
  height: calc(100vh - 3.875rem);
  transition:
    background 0.15s,
    color 0.15s;
  z-index: 10;
}

.sidebar-collapsed .lesson-sidebar-tab {
  display: flex;
}

.lesson-sidebar-tab:hover {
  background: var(--bg-alt);
  color: var(--primary-d);
}

.lesson-content-wrap {
  display: flex;
  justify-content: center;
  gap: 2.5rem;
  align-items: flex-start;
  /* Trimmed horizontal padding (was 4rem) to give the article + scrolly
     diagrams more width — the article is flex:1 and was starved by padding +
     the on-page TOC, not by its max-width. */
  padding: 0.75rem 2rem;
}

/* ── Sidebar ── */
.lesson-sidebar {
  grid-column: 2;
  background: var(--bg-alt);
  border-right: 1px solid var(--border);
  padding: 1.5rem 0;
  position: sticky;
  top: 3.875rem;
  height: calc(100vh - 3.875rem);
  overflow-y: auto;
  overflow-x: hidden;
  transition: all 0.25s ease;
}

.sidebar-collapsed .lesson-sidebar {
  padding: 0;
  border-right: none;
}

.lesson-sidebar-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 0.75rem 1rem 1.5rem;
  border-bottom: 1px solid var(--border);
  margin-bottom: 1rem;
}

.lesson-sidebar-course {
  flex: 1;
}

.lesson-sidebar-close {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--fs-base);
  padding: 0.25rem 0.5rem;
  line-height: 1;
  border-radius: 0.25rem;
  transition:
    color 0.15s,
    background 0.15s;
}

.lesson-sidebar-close:hover {
  color: var(--text);
  background: var(--bg-alt);
}

.lesson-sidebar-course a {
  color: var(--text);
  text-decoration: none;
  font-weight: 600;
  font-size: var(--fs-base);
}

.lesson-sidebar-course a:hover {
  color: var(--primary);
}

.lesson-sidebar-nav {
  display: flex;
  flex-direction: column;
}

.lesson-sidebar-item {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  padding: 0.625rem 1.5rem;
  color: var(--text-muted);
  text-decoration: none;
  font-size: var(--fs-base-sm);
  line-height: 1.4;
  border-left: 2px solid transparent;
  transition: all 0.15s;
}

.lesson-sidebar-item:hover {
  background: rgba(105, 152, 165, 0.15);
  color: var(--text);
}

.lesson-sidebar-item.active {
  background: rgba(105, 152, 165, 0.1);
  color: var(--primary);
  border-left-color: var(--primary);
}

.lesson-sidebar-title {
  flex: 1;
}

.lesson-sidebar-tag {
  font-size: var(--fs-2xs);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border-radius: 3px;
  padding: 0.1rem 0.4rem;
  flex-shrink: 0;
}

.lesson-sidebar-tag.tag-project {
  background: transparent;
  color: var(--primary);
  border: 1px solid var(--primary);
}

.lesson-sidebar-tag.tag-quiz {
  background: transparent;
  color: var(--emerald);
  border: 1px solid var(--emerald);
}

.lesson-sidebar-tag.tag-assignment {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-muted);
}

.lesson-sidebar-tag.tag-capstone {
  background: transparent;
  color: var(--gold);
  border: 1px solid var(--gold);
}

.lesson-sidebar-tag.tag-optional {
  background: transparent;
  color: var(--text-muted);
  border: 1px solid var(--border);
}

/* ── Main article ── */
.lesson-main {
  min-width: 0;
  overflow-x: clip;
}

.lesson-article {
  color: var(--text);
  font-size: var(--fs-base);
  line-height: 1.7;
  flex: 1;
  min-width: 0;
  /* No max-width and no overflow-x: clip here. The prose measure and the
     breakout both live on .lesson-full-content's grid below — capping or
     clipping the article as well would re-trap the breakout, which is the
     bug this replaced (clip yields no scrollbar, so the scrolly steps column
     was silently truncated mid-word). .lesson-main still clips, and that is
     what guards the page against horizontal scroll. */
}

.lesson-article h1 {
  font-family: "Crimson Pro", serif;
  font-size: var(--fs-h1);
  /* Crimson Pro is a book-style display serif — 400 reads elegant; 600 was
     chunky/heavy for the face. (h3 is Sora sans and stays heavier.) */
  font-weight: 400;
  margin-bottom: 1.5rem;
  color: var(--text);
  line-height: 1.3;
}

.lesson-article h2 {
  font-family: "Crimson Pro", serif;
  font-size: var(--fs-h2);
  font-weight: 400;
  margin-top: 3rem;
  margin-bottom: 0.75rem;
  color: var(--text);
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--border);
}

.lesson-article h3 {
  font-size: var(--fs-h3);
  font-weight: 600;
  margin-top: 2rem;
  margin-bottom: 0.75rem;
  color: var(--text);
}

.lesson-article p {
  margin-bottom: 1.25rem;
  color: var(--text);
}

.lesson-article strong {
  color: var(--text);
  /* 700, not 600: body is Sora 400, and on the dark ground 400 already reads
     heavy, so a 600 bold barely separated from it ("everything looks bold").
     700 gives emphasis real contrast. (700 is loaded; body stays 400 — thinner
     body would hurt legibility / WCAG.) */
  font-weight: 700;
}

.lesson-article em {
  font-style: italic;
}

.lesson-article a {
  color: var(--primary);
  text-decoration: underline;
  text-decoration-color: rgba(105, 152, 165, 0.3);
  text-underline-offset: 0.15em;
  transition: text-decoration-color 0.2s;
}

.lesson-article a.btn-primary {
  color: var(--on-primary);
  text-decoration: none;
}

.lesson-article a.btn-ghost {
  text-decoration: none;
}

.lesson-article a:hover {
  text-decoration-color: var(--primary);
}

.lesson-article ul,
.lesson-article ol {
  margin-bottom: 1.25rem;
  padding-left: 1.5rem;
  color: var(--text-muted);
}

.lesson-article li {
  margin-bottom: 0.5rem;
}

.lesson-article li::marker {
  color: var(--primary);
}

.lesson-article code {
  font-family: "JetBrains Mono", monospace;
  font-size: 0.875em;
  background: var(--bg-alt);
  padding: 0.15rem 0.4rem;
  border-radius: 4px;
  color: var(--coral);
}

.lesson-article pre {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  padding: 1rem 1.25rem;
  margin-bottom: 1.5rem;
  overflow-x: auto;
}

.lesson-article pre code {
  background: none;
  padding: 0;
  font-size: var(--fs-base-sm);
  color: var(--text);
  line-height: 1.6;
}

.lesson-article blockquote {
  border-left: 3px solid var(--primary);
  padding-left: 1.25rem;
  margin: 1.5rem 0;
  color: var(--text-muted);
  font-style: italic;
}

.lesson-article hr {
  border: none;
  border-top: 1px solid var(--border);
  margin: 2.5rem 0;
}

.lesson-article table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 1.5rem;
  font-size: var(--fs-base-sm);
}

.lesson-article th,
.lesson-article td {
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  text-align: left;
}

.lesson-article th {
  background: var(--bg-alt);
  font-weight: 600;
  color: var(--text);
}

.lesson-article td {
  color: var(--text-muted);
}

/* ── Gated-lesson preview vs full ──
   The real paywall is server-side: the middleware excises the FULLCONTENT block
   for under-tier requests, so .lesson-full-content is ONLY in the DOM when the
   viewer is entitled. These rules switch the page between the two states:
     • entitled  → .lesson-full-content present → show it, hide preview + gate
     • not entitled → block excised → show the faded preview excerpt + gate
   The mask below is now purely cosmetic (fades the short excerpt); it is no
   longer a security boundary. */
.lesson-preview-body {
  position: relative;
}

/* ── The prose measure ──
   `ch` is the advance measure of the "0" glyph IN THE ELEMENT'S OWN FONT
   (MDN, CSS Values 4). So `65ch` is NOT one width — it is whatever the
   element's own font makes it: ~860px in the body face (Sora 18px) but
   ~1764px on an h1 (Crimson Pro 48px), which is wider than the article and
   therefore never binds. That is a units bug, not a missing cap, and it only
   shows above ~1764px where the article stops being the constraint.

   Registering --measure as a <length> makes it compute AT ITS DECLARING
   ELEMENT and inherit as an absolute value. Declared on .lesson-article,
   which carries the body font, `65ch` resolves once in Sora and every
   consumer below inherits that same computed width — the h1 included. Still
   font-relative (change the body font or size and the measure follows), but
   resolved in exactly one place, so there is no number to keep in sync. */
@property --measure {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.lesson-article {
  /* Resolved HERE, in the body font. Everything below inherits the length. */
  --measure: 65ch;
}

/* The two pieces that live OUTSIDE the breakout grid and would otherwise lose
   the measure entirely: the lesson title, and the ungated free-preview body
   (gated lessons put their prose inside .lesson-preview-content, which is the
   grid and already caps itself at [content]). */
.lesson-article > h1,
.lesson-article > .lesson-preview-body {
  max-width: min(var(--measure), 100%);
  /* Centred, because max-width alone only sets a ceiling — it says nothing
     about where the narrow box sits in a wide one, and the default is flush
     left. The article stretches to fill the space between the outline and the
     contents, so on a wide screen the prose pinned to the sidebar with all the
     slack piling up on the right: 32px before the first word and 516px after
     the last at 1920.

     Gated lessons never showed this. Their prose lives in
     .lesson-preview-content, which is the breakout grid below and centres
     itself — so the bug was only ever on free-preview lessons and the pages
     that render ungated, which is why it looked intermittent. */
  margin-inline: auto;
}

/* ── Content measure + breakout track ──
   Named-line grid (the standard "layout breakout" pattern) instead of negative
   margins. Children sit in [content] at the prose measure; a .scrolly-block
   opts into [breakout] and widens symmetrically about the same centre line.

   Why a grid and not a bleed: the page has three layout regimes (single
   column; sidebar only; sidebar + on-page TOC) and the phone stacks the block
   into a column. minmax(0, 12%) is measured against this container, so the
   track absorbs whatever slack the current regime has and collapses to zero
   when there is none — one rule, no absolute units, nothing to keep in sync
   with .lesson-toc or the wrap's gap.

   Note the track is 0 until the article grows past the 65ch measure (~1500px):
   below that the block simply fills the article, which is already the full
   content column. The breakout only has anything to reclaim above it. */
.lesson-full-content,
.lesson-preview-content {
  /* Inherited from .lesson-article, already resolved in the body font. */
  --breakout-track: minmax(0, 12%);

  display: grid;
  grid-template-columns:
    [breakout-start] var(--breakout-track)
    [content-start] min(var(--measure), 100%) [content-end]
    var(--breakout-track) [breakout-end];
  justify-content: center;
}

.lesson-full-content > *,
.lesson-preview-content > * {
  grid-column: content;
  min-width: 0;
}

.lesson-full-content > .scrolly-block,
.lesson-preview-content > .scrolly-block {
  grid-column: breakout;
}

.lesson-preview-content {
  -webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 85%);
  mask-image: linear-gradient(to bottom, black 40%, transparent 85%);
  pointer-events: none;
  user-select: none;
}

/* When the full content survived (entitled viewer), drop the preview + gate so
   the intro is not shown twice and no upgrade card appears. */
.lesson-preview-body:has(.lesson-full-content) .lesson-preview-content,
.lesson-preview-body:has(.lesson-full-content) .lesson-gate {
  display: none;
}

.lesson-gate {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  padding-bottom: 1rem;
}

.lesson-gate-card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 2rem 2.5rem;
  text-align: center;
  max-width: 27.5rem;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.lesson-gate-heading {
  font-family: "Crimson Pro", serif;
  font-size: var(--fs-h3);
  font-weight: 500;
  color: var(--text) !important;
  margin: 0 !important;
}

.lesson-gate-sub {
  font-size: var(--fs-base-sm);
  color: var(--text-muted) !important;
  line-height: 1.6;
  margin: 0 0 0.5rem !important;
}

/* ── Gate card CTA button ──
   Full width because it is the card's only action. The ivory treatment is
   DARK-MODE ONLY: it exists because --primary is too soft against the dark
   card, but in light mode the card is white and an ivory button on white is
   1.12:1 — readable text inside a rectangle nobody can see the edges of.
   Light mode keeps the default --primary, which measures 5.23:1 both for its
   white label and for the button against the card. */
.lesson-gate-card .btn-primary {
  font-weight: 600;
  width: 100%;
  text-align: center;
}
[data-theme="dark"] .lesson-gate-card .btn-primary {
  background: var(--ivory);
  color: var(--text-dark);
}
[data-theme="dark"] .lesson-gate-card .btn-primary:hover {
  background: var(--ivory-dim);
}
[data-theme="light"] .lesson-gate-card .btn-primary {
  background: var(--primary);
  color: var(--on-primary);
}
[data-theme="light"] .lesson-gate-card .btn-primary:hover {
  background: var(--primary-d);
}

/* ── Signup CTA (open lessons) ── */
.lesson-signup-cta {
  margin: 2rem 0 1rem;
  padding: 1.25rem 1.5rem;
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-left: 3px solid var(--primary);
  border-radius: 0.5rem;
  display: flex;
  align-items: center;
  gap: 1.5rem;
  flex-wrap: wrap;
}

.lesson-signup-cta p {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--fs-base-sm);
  flex: 1;
}

.lesson-signup-btn {
  display: inline-block;
  padding: 0.5rem 1.25rem;
  background: var(--primary);
  color: var(--text-dark);
  border-radius: 6px;
  font-size: var(--fs-base-sm);
  font-weight: 600;
  white-space: nowrap;
  text-decoration: none;
  transition: background 0.15s;
}

.lesson-signup-btn:hover {
  background: var(--primary-d);
  color: var(--text-dark);
}

.lesson-article a.lesson-signup-btn {
  color: var(--text-dark);
  text-decoration: none;
}

/* ── Footer prev/next ── */
.lesson-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  margin-top: 4rem;
  padding-top: 2rem;
  border-top: 1px solid var(--border);
}

.lesson-nav-prev,
.lesson-nav-next {
  display: flex;
  flex-direction: column;
  padding: 1rem 1.25rem;
  background: var(--bg-alt);
  border-radius: 0.5rem;
  text-decoration: none;
  max-width: 45%;
  transition: background 0.15s;
}

.lesson-nav-prev:hover,
.lesson-nav-next:hover {
  background: var(--border);
}

.lesson-nav-next {
  text-align: right;
  align-items: flex-end;
}

.lesson-nav-label {
  font-size: var(--fs-xs);
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  margin-bottom: 0.25rem;
}

.lesson-nav-text {
  color: var(--text);
  font-weight: 500;
}

.lesson-nav-arrow {
  color: var(--primary);
  font-size: var(--fs-h3);
}

.lesson-nav-prev .lesson-nav-arrow {
  margin-bottom: 0.25rem;
}

.lesson-nav-next .lesson-nav-arrow {
  margin-bottom: 0.25rem;
}

/* ── Right TOC ── */
.lesson-toc {
  position: sticky;
  top: calc(3.875rem + 2rem);
  height: fit-content;
  width: 12.5rem;
  flex-shrink: 0;
  padding: 0 1rem;
  border-left: 1px solid var(--border);
  align-self: start;
}

.lesson-toc-title {
  font-size: var(--fs-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
  margin-bottom: 0.75rem;
}

.lesson-toc-nav {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-bottom: 2rem;
}

.lesson-toc-link {
  font-size: var(--fs-xs);
  color: var(--text-muted);
  text-decoration: none;
  padding: 0.15rem 0 0.15rem 0.5rem;
  line-height: 1.4;
  border-left: 2px solid transparent;
  transition: color 0.15s;
}

.lesson-toc-link:hover {
  color: var(--text);
}

/* A step inside a walk-through. Indented under its section and set a shade
   quieter, so the eye reads the sections first and the steps as detail
   beneath them. Same active treatment as any other link. */
.lesson-toc-sub {
  padding-left: 1.25rem;
  font-size: calc(var(--fs-xs) * 0.94);
  opacity: 0.85;
}

.lesson-toc-link.active {
  color: var(--primary);
  border-left-color: var(--primary);
}

/* The line above the sign-up button on an open lesson. It asks a question the
   button answers, so it needs to read as prose next to the button rather than
   as a second heading — muted, and sized with the sidebar's own small text.
   Both colours are tokens, so neither theme needs an override. */
.cta-note {
  margin: 0 0 0.5rem;
  color: var(--text-muted);
  font-size: var(--fs-sm);
  line-height: 1.4;
}

/* ── Sidebar search ── */
.lesson-sidebar-search {
  padding: 0 1rem 0.75rem;
}

.lesson-sidebar-search-btn {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.5rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-muted);
  font-family: "Sora", sans-serif;
  font-size: var(--fs-sm);
  cursor: pointer;
  transition:
    border-color 0.15s,
    color 0.15s;
  text-align: left;
  text-decoration: none;
}

.lesson-sidebar-search-btn:hover {
  border-color: var(--primary);
  color: var(--text);
}

/* ── Light theme overrides ── */
[data-theme="light"] .lesson-sidebar-course {
  border-bottom-color: var(--border);
}

[data-theme="light"] .lesson-sidebar-item:hover {
  background: rgba(105, 152, 165, 0.1);
  color: var(--text);
}

[data-theme="light"] .lesson-sidebar-item.active {
  background: rgba(105, 152, 165, 0.15);
}

[data-theme="light"] .lesson-toc {
  border-left-color: var(--border);
}

/* ── Responsive ── */
/* The contents appear only once there is room for them AND a full measure.
   90rem, and the number is arithmetic rather than taste:

     17.5rem  sidebar
   +  2rem    wrap padding, twice
   + ~47.8rem the prose measure (65ch in the body face at the default size)
   +  2.5rem  flex gap
   + 12.5rem  contents
   = ~89.8rem

   At the old 1100px the contents arrived long before that, and since the
   article is flex:1 they were paid for out of the prose: 63ch on an iPad Pro
   against 91ch on a desktop. A measure that swings by half its own width is
   not a measure. Below this the contents hide and the prose keeps its width,
   which is the right trade — the outline in the left sidebar already covers
   navigation, and the contents are a second way to do the same thing.

   rem rather than px so the breakpoint tracks the reader's own font size: at
   a 20px base the same layout needs proportionally more room, and a px query
   would hand them three cramped columns instead of two comfortable ones.

   Not `ch`, which was the tempting unit since the measure is in ch — but ch
   in a MEDIA query resolves against the root font, not the body font that
   sets --measure, so it measures the wrong thing. Tested: a 105ch query put
   the contents back at 1180px and squeezed the prose to 45ch, which is worse
   than the bug it was meant to fix. */
@media (max-width: 90rem) {
  /* The contents list, not the whole aside. The signup CTA is a child of
     .lesson-toc, so hiding the aside took the only invitation on the page with
     it — a signed-out reader below 90rem got no way to sign up. Caught by
     lesson-cta.spec.js, whose default viewport is 1280px and therefore sits
     squarely inside the range this query covers. */
  .lesson-toc-title,
  .lesson-toc-nav {
    display: none;
  }

  /* The aside stops being a column and becomes part of the article's flow:
     without this it still holds its 12.5rem track, so hiding the list frees
     nothing and the prose is squeezed exactly as before. The CTA moves under
     the lesson text, which is where a reader who has finished reading is
     looking anyway.

     Without the list there is also nothing for the rule to divide, and a bare
     vertical line beside a lone button reads as a rendering fault. */
  .lesson-toc {
    position: static;
    width: auto;
    border-left: 0;
    padding: 0;
    margin-top: 2rem;
  }
  .lesson-content-wrap {
    flex-wrap: wrap;
    gap: 0;
  }
  .lesson-content-wrap > .lesson-article {
    flex: 1 1 100%;
  }
}

/* rem, not px: a media query in px ignores the reader's own font size, so
   someone browsing at 20px base gets the desktop layout at a width where
   their text no longer fits it. 56.25rem is 900px at the 16px default. */
@media (max-width: 56.25rem) {
  .lesson-container {
    grid-template-columns: 1fr;
  }
  .lesson-sidebar {
    display: none;
  }
  .lesson-content-wrap {
    padding: 2rem 1.5rem;
  }
}

/* ── Try It Tabs ── */
.try-it-tabs {
  margin: 1.5rem 0;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  overflow: hidden;
}

.try-it-tab-bar {
  display: flex;
  border-bottom: 1px solid var(--border);
  background: var(--bg-card);
}

.try-it-tab-btn {
  padding: 0.6rem 1.25rem;
  font-size: var(--fs-base-sm);
  font-weight: 600;
  font-family: inherit;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color 0.15s,
    border-color 0.15s;
  margin-bottom: -1px;
}

.try-it-tab-btn.active {
  color: var(--primary);
  border-bottom-color: var(--primary);
}

.try-it-tab-panel {
  padding: 1.25rem 1.5rem;
  display: none;
}

.try-it-tab-panel.active {
  display: block;
}

/* Light theme. Both spellings, because there are three states and not two:
   an explicit choice stamps data-theme, and the default "system" setting
   stamps nothing — so a viewer on system-light hits neither the dark default
   nor an attribute rule, and got no background at all. --bg-card already
   resolves to #fff in light, so the token does this correctly on its own. */
[data-theme="light"] .try-it-tabs {
  background: var(--bg-card);
}

[data-theme="light"] .try-it-tab-bar {
  background: var(--bg-alt);
}

/* ── Pyodide Code Runner ── */
.code-runner {
  position: relative;
  margin: 1.5rem 0;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  overflow: hidden;
}

.code-runner .code-runner-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.4rem 1rem;
  background: var(--bg-card);
  border-bottom: 1px solid var(--border);
}

.code-runner .code-runner-header span {
  color: var(--primary);
  font-family: "JetBrains Mono", monospace;
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.code-runner .code-runner-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.code-runner .reset-btn {
  background: none;
  color: var(--text-muted);
  border: 1px solid var(--border);
  padding: 0.3rem 0.8rem;
  border-radius: 4px;
  cursor: pointer;
  font-family: "Sora", sans-serif;
  font-size: var(--fs-sm);
  transition: all 0.2s;
}

.code-runner .reset-btn:hover {
  color: var(--text);
  border-color: var(--text-muted);
}

.code-runner .run-btn {
  background: var(--primary-d);
  color: var(--on-primary);
  border: none;
  padding: 0.3rem 0.8rem;
  border-radius: 4px;
  cursor: pointer;
  font-family: "Sora", sans-serif;
  font-size: var(--fs-sm);
  transition: background 0.2s;
}

.code-runner .run-btn:hover {
  background: var(--primary);
}

.code-runner .run-btn:disabled {
  opacity: 0.5;
  cursor: default;
}

.code-runner .run-btn.loading {
  background: var(--gold);
  color: var(--text-dark);
}

.code-runner pre,
.code-runner .code-runner-pre {
  margin: 0;
  border-radius: 0;
  border: none;
  min-height: 100%;
  max-height: none;
  overflow-x: auto;
  overflow-y: visible;
}

.code-runner pre code,
.code-runner .code-runner-pre code {
  font-size: var(--fs-base-sm);
  display: block;
  min-height: 100%;
}

.code-runner pre[contenteditable],
.code-runner .code-runner-pre[contenteditable] {
  outline: none;
  cursor: text;
}

/* Plain (un-highlighted) editable code area */
.code-runner-pre {
  background: var(--editor-bg);
  color: var(--editor-text);
  font-family: "JetBrains Mono", monospace;
  padding: 1.25rem 1.5rem;
  line-height: 1.6;
  tab-size: 4;
  white-space: pre;
  overflow-x: auto;
}

/* Override .lesson-article code color for runner blocks */
.lesson-article .code-runner .code-runner-pre code,
.lesson-article .code-runner pre code {
  font-family: "JetBrains Mono", monospace;
  background: none;
  padding: 0;
  color: inherit;
  font-size: var(--fs-base-sm);
}

[data-theme="light"] .code-runner-pre {
  background: var(--editor-bg-light);
  color: var(--editor-text-light);
}

.code-output {
  padding: 0.8rem 1rem;
  background: var(--bg);
  color: var(--emerald);
  font-family: "JetBrains Mono", monospace;
  font-size: var(--fs-sm);
  line-height: 1.5;
  border-top: 1px solid var(--border);
  white-space: pre-wrap;
  display: none;
  max-height: 400px;
  overflow-y: auto;
}

.code-output.visible {
  display: block;
}

.code-output.error {
  color: var(--coral);
}

.code-output.success {
  border-top-color: var(--emerald);
}

.code-output img {
  max-width: 100%;
  border-radius: 4px;
  margin-top: 0.5rem;
}

#pyodide-loading {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  background: var(--bg-card);
  color: var(--gold);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  padding: 0.6rem 1.2rem;
  font-family: "Sora", sans-serif;
  font-size: var(--fs-sm);
  z-index: 1000;
  display: none;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

#pyodide-loading.visible {
  display: block;
}

#pyodide-loading.ready {
  color: var(--emerald);
  border-color: var(--emerald);
}

[data-theme="light"] .code-output {
  background: var(--editor-output-bg-light);
  color: var(--editor-output-text-light);
}

/* ── Document checkers (agreement + project) ──
   Shared: both render the same pass/fail rows, and a copy is how one of
   them ends up with a fix the other never got. */
.agreement-checker .code-output,
.project-checker .code-output {
  color: var(--text);
  white-space: normal;
}

.agreement-checker .check-result,
.project-checker .check-result {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.2rem 0;
}

.agreement-checker .check-mark,
.project-checker .check-mark {
  flex: 0 0 auto;
}

.agreement-checker .check-pass,
.project-checker .check-pass .check-mark {
  color: var(--emerald);
}

.agreement-checker .check-fail,
.project-checker .check-fail .check-mark {
  color: var(--coral);
}

.agreement-checker .check-section,
.project-checker .check-section {
  flex: 0 0 auto;
  color: var(--text);
  white-space: nowrap;
}

.agreement-checker .check-note,
.project-checker .check-note {
  color: var(--text-muted);
}

.agreement-checker .check-summary,
.project-checker .check-summary {
  margin-top: 0.6rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-family: "Sora", sans-serif;
}

/* ── Try It Block ── */
.try-it-block {
  margin: 2rem 0;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  overflow: hidden;
}

.tryit-header {
  padding: 0.5rem 1.25rem;
  background: var(--bg-card);
  border-bottom: 1px solid var(--border);
  font-size: var(--fs-xs);
  font-weight: 600;
  font-family: "Sora", sans-serif;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--primary);
}

.tryit-body {
  display: grid;
  grid-template-columns: 1fr 4px 1.6fr;
  min-height: 12rem;
}

.tryit-resizer {
  background: var(--border);
  cursor: col-resize;
  transition: background 0.15s;
  position: relative;
}

.tryit-resizer:hover,
.tryit-resizer.dragging {
  background: var(--primary);
}

.tryit-left {
  padding: 1.25rem 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  overflow: hidden;
}

.tryit-prose {
  font-size: var(--fs-base-sm);
  line-height: 1.6;
  color: var(--text-muted);
  flex: 1;
}

.tryit-prose p {
  margin-bottom: 0.75rem;
  color: var(--text-muted);
}

.tryit-prose p:last-child {
  margin-bottom: 0;
}

.tryit-reveals {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
}

.tryit-reveal-btn {
  background: none;
  border: none;
  padding: 0.25rem 0;
  font-family: "Sora", sans-serif;
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--primary);
  cursor: pointer;
  text-align: left;
  transition: color 0.15s;
}

.tryit-reveal-btn:hover {
  color: var(--primary-d);
}

.tryit-right {
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow-x: auto;
}

/* Remove outer margin/border from the code-runner when inside tryit-right */
.tryit-right .code-runner {
  margin: 0;
  border: none;
  border-radius: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.tryit-right .code-runner-pre {
  flex: 1;
  overflow-x: auto;
}

/* Responsive: stack on narrow screens */
/* 47.5rem is 760px at the 16px default. See the note on the 56.25rem query. */
@media (max-width: 47.5rem) {
  .tryit-body {
    grid-template-columns: 1fr;
  }

  .tryit-left {
    border-right: none;
    border-bottom: 1px solid var(--border);
  }
}

/* ── Scrollytelling blocks ── */
.scrolly-block {
  /* Width and centring come from the [breakout] grid column on
     .lesson-full-content above — no negative margins, nothing measured
     against the viewport. */
  display: flex;
  align-items: flex-start;
  gap: 0;
  position: relative;
  margin-top: 2rem;
  margin-bottom: 2rem;
}

/* The explanation column. Sticky by default: all step texts stack in one grid
   cell and the active one is shown, so the card rides beside the artifact and
   swaps as each annotated area scrolls into the reading band. Every scrolly in
   the course wants this, which is why all 14 used to restate it locally. */
.scrolly-steps {
  width: 34%;
  min-width: 15rem;
  padding: 0 2rem;
  position: sticky;
  top: 6rem;
  align-self: flex-start;
  display: grid;
  z-index: 2;
}

.scrolly-step {
  /* Stacked in one cell; only .is-active is visible. visibility (not just
     opacity) keeps an inactive card out of the accessibility tree and out of
     hit-testing while it is faded out. */
  grid-area: 1 / 1;
  min-height: 0;
  padding: 0;
  display: block;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease;
}

.scrolly-step.is-active {
  opacity: 1;
  visibility: visible;
}

.scrolly-step h3 {
  font-family: "Crimson Pro", serif;
  font-size: var(--fs-h3);
  font-weight: 600;
  color: var(--text);
  margin-bottom: 0.75rem;
}

.scrolly-step p {
  font-size: var(--fs-base);
  line-height: 1.65;
  color: var(--text);
  margin-bottom: 0.75rem;
}

.scrolly-step.is-active h3 {
  color: var(--primary);
}

/* The artifact column. Every scrolly in the course annotates a document that
   scrolls in normal flow at its full height (the page is the only scroll
   context), so that is the default here rather than something each lesson
   restates. The old default was a sticky, 66%-wide, 80vh-capped box for a D3
   figure whose aspect ratio was "set per-figure in lesson.js" — that JS no
   longer exists, and all 14 scrollies overrode every one of those properties.
   A future SVG-figure scrolly can opt back in locally. */
.scrolly-figure {
  width: 66%;
  display: block;
  position: static;
  height: auto;
  max-height: none;
  overflow: visible;
  min-width: 0;
  padding: 0;
  box-sizing: border-box;
}

.scrolly-figure svg {
  /* Let the SVG size by its own viewBox aspect ratio: fill the width, and let
     height follow (height:auto) instead of being forced to 100% of a box whose
     height is capped independently — that mismatch made `meet` shrink the
     drawing and leave empty padding ("diagram doesn't fill its box"). The figure
     box's aspect-ratio (set from the viewBox in lesson.js) keeps the box the
     right shape; max-height below caps tall diagrams. */
  width: 100%;
  height: auto;
  max-height: 100%;
  display: block;
  background: transparent;
}

@media (max-width: 47.5rem) {
  /* Phone: one column — the explanation card sticks ABOVE the artifact and the
     artifact follows it (order: 1), so the reader sees what the card is talking
     about directly below the words. The old rule ordered these the other way
     (order: -1) and capped the figure at 70vh for a D3 box; all 14 scrollies
     overrode both, so the default now matches what they actually do. */
  .scrolly-block {
    flex-direction: column;
    width: 100%;
    margin-left: 0;
  }
  .scrolly-steps {
    width: 100%;
    position: sticky;
    top: 3.5rem;
    z-index: 3;
    background: var(--bg-card);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 0.75rem 1rem;
    margin-bottom: 0.75rem;
  }
  .scrolly-figure {
    width: 100%;
    order: 1;
    min-height: 0;
    margin-bottom: 1rem;
  }
}

/* ── Mark-complete control — sits between Prev/Next in the footer row.
   lesson-progress.js reveals it when signed in and auto-selects it at the end. ── */
/* ── Project banner ──
   project.njk emits <div.project-banner><span.project-badge>Project</span>
   <span.project-banner-text>…</span></div>. These had no CSS, so the badge
   ran into the text as one unstyled run-on line. Style it as a distinct,
   flagged callout so a project reads as "build this yourself", not as prose. */
.project-banner {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin: 0 0 2rem;
  padding: 0.9rem 1.15rem;
  background: var(--card-hover-bg);
  border: 1px solid var(--border);
  border-left: 3px solid var(--primary);
  border-radius: 8px;
}
.project-badge {
  flex-shrink: 0;
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--on-primary);
  background: var(--primary);
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
}
.project-banner-text {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  line-height: 1.5;
}

/* ── Note callout (::: note … :::) ── */
.lesson-note {
  border-left: 3px solid var(--gold);
  background: var(--bg-card);
  border-radius: 6px;
  padding: 1rem 1.25rem;
  margin: 1.5rem 0;
}
.lesson-note p:last-child {
  margin-bottom: 0;
}

/* Author-drawn inline SVG figures. Hoisted out of the three lessons that each
   carried a byte-identical copy: nine more figures are landing across the
   module, and a per-lesson copy is how the scrolly styles drifted before
   #191 consolidated them.

   align-self: start because .own-figure is a direct child of the breakout
   grid, and a grid child stretches to fill its row otherwise. */
.own-figure {
  align-self: start;
  margin: 1.75rem 0;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-card);
}
.own-figure svg {
  display: block;
  margin: 0 auto;
}
.own-figure figcaption {
  margin-top: 0.9rem;
  font-size: 0.88rem;
  color: var(--text-muted);
  text-align: center;
}

/* ── Notebook cells, rendered as Jupyter renders them ──────────────────
   The hand-written panes these replace were paraphrases in a bare <pre>, so
   they read as code excerpts. A reader who has the file open should recognise
   what is on the page: the In[n] gutter, the input box, the output underneath
   with no box of its own. Colours come from the theme tokens, so this works in
   both themes without a second definition. */
.nb-doc {
  margin: 1.5rem 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-card);
}
.nb-cell + .nb-cell {
  border-top: 1px solid var(--border);
}
.nb-gutter {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem 0.85rem 0.3rem;
  font-family: "JetBrains Mono", monospace;
  font-size: 0.72rem;
  color: var(--text-muted);
}
.nb-dest {
  font-style: italic;
}
.nb-doc pre {
  /* Wrap rather than scroll sideways. In the prose column the longest source
     line here (92 characters) needs ~690px against ~540 available, and 36 of
     the 44 cells scrolled horizontally before this. A reader comparing
     execution counts down the left margin should never have to scroll right to
     finish a line. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  margin: 0;
  padding: 0.3rem 0.85rem 0.7rem;
  background: none;
  border: none;
  overflow-x: auto;
  font-family: "JetBrains Mono", monospace;
  font-size: 0.78rem;
  line-height: 1.55;
}
/* The input carries the accent bar Jupyter puts beside a selected cell. */
.nb-in {
  border-left: 3px solid var(--border);
}
/* Output sits under the input, indented, quieter — it is what the cell said
   rather than what it was asked. */
.nb-out {
  color: var(--text-muted);
  border-left: 3px solid transparent;
}
.nb-out-result::before,
.nb-out-stream::before {
  content: "Out: ";
  font-style: italic;
  opacity: 0.7;
}
.nb-out-error {
  color: var(--coral, #e05555);
}

/* Outputs are collapsed per cell. Walking a notebook, you want to see what a
   cell does before what it printed — and df.info() alone is thirty lines, so
   showing every output by default buries the code in its own results. */
.nb-outwrap > summary {
  padding: 0.15rem 0.85rem 0.55rem;
  font-family: "JetBrains Mono", monospace;
  font-size: 0.7rem;
  color: var(--text-muted);
  cursor: pointer;
  list-style: none;
}
.nb-outwrap > summary::-webkit-details-marker {
  display: none;
}
.nb-outwrap > summary::before {
  content: "▸ ";
}
.nb-outwrap[open] > summary::before {
  content: "▾ ";
}
.nb-outwrap > summary:hover {
  color: var(--text);
}
.nb-outwrap > summary:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: 3px;
}

/* The card's detail, folded on a first pass. Scrolling the walk the first time
   is about seeing the notebook — one line per component beside the cells is
   enough to know where you are. The argument is for the second pass, and it is
   one click away rather than competing with the file. */
.step-more > summary {
  margin-top: 0.5rem;
  font-size: 0.85rem;
  color: var(--text-muted);
  cursor: pointer;
  list-style: none;
}
.step-more > summary::-webkit-details-marker {
  display: none;
}
.step-more > summary::before {
  content: "▸ ";
}
.step-more[open] > summary::before {
  content: "▾ ";
}
.step-more > summary:hover {
  color: var(--text);
}
.step-more > summary:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: 3px;
}
.step-more p {
  margin-top: 0.7rem;
}

/* Cells outside the component being discussed. Dimmed rather than hidden —
   the reason to show the whole file is that a reader can see where a cell sits
   relative to its neighbours, which is how In[46] near the top gives away that
   it ran last. */
.nb-cell {
  transition: opacity 0.25s ease;
}
.nb-cell.nb-dim {
  opacity: 0.3;
}

/* A plot is an output like any other: under its cell, indented to match the
   text outputs, scaled to the pane rather than the page. */
.nb-out-image {
  padding: 0.3rem 0.85rem 0.8rem;
}
.nb-out-image img {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: 4px;
}
/* Cells outside the active component. Not hidden — the whole point of showing
   the file is that the reader sees where a cell sits relative to its
   neighbours — just quieted so the active ones read first. */
.nb-cell.nb-dim {
  opacity: 0.32;
}
.nb-cell {
  transition: opacity 0.25s ease;
}

/* A plot is an output like any other — it sits under its cell, indented to
   match the text outputs, and scales to the pane rather than the page. */
.nb-out-image {
  padding: 0.3rem 0.85rem 0.8rem;
}
.nb-out-image img {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: 4px;
}

/* ── Project checker: the repository field and the install prompt ── */
.project-checker {
  margin: 1.5rem 0;
}
.project-checker-row {
  display: flex;
  gap: 0.6rem;
  align-items: stretch;
  flex-wrap: wrap;
}
.project-checker-repo {
  flex: 1 1 18rem;
  min-width: 0;
  padding: 0.6rem 0.75rem;
  font-family: "JetBrains Mono", monospace;
  font-size: var(--fs-base-sm);
  color: var(--text);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 0.4rem;
}
.project-checker-repo:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 1px;
}
/* The completion line sits above the individual results: it is the answer the
   student came for, and burying it under twelve rows makes them hunt. */
.project-checker-done {
  margin-bottom: 0.6rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--border);
  color: var(--emerald);
}
.project-checker-install p {
  margin: 0 0 0.75rem;
}
