
/* ── 0. Paint the theme background on <body>, not only <html> ──────────────
   The site puts "background: var(--bg)" on <html> alone and leaves <body>
   transparent. That works on the web because the root background propagates
   to the canvas, but it means every element in the page reports a transparent
   background chain, so anything compositing over the body (and any contrast
   audit) sees "no background". Painting it on <body> too is purely cosmetic
   and makes the rendered colours match the declared theme in both schemes. */
html, body { background: var(--bg, #FAFAF6); color: var(--fg, #0E1413); }

/* ── 1. Kill the desktop container ─────────────────────────────────────────
   A centered max-width column is the single loudest desktop tell on a phone. */
main, main.page {
  max-width: none !important;
  margin: 0 !important;
  padding: 16px 16px 40px !important;
}
.inner { max-width: none !important; padding-left: 16px !important; padding-right: 16px !important; }

/* ── 2. Hide site chrome; the app owns navigation ───────────────────────── */
header.site, nav#site-nav, footer.site, .brandfoot, .nav-toggle, .breadcrumb {
  display: none !important;
}

/* ── 3. Typography for phone reading ────────────────────────────────────────
   Body at 17px/1.55 (not the 14–16px desktop body), and a flatter heading
   scale — a 44px h1 is absurd at 393pt wide. */
html { -webkit-text-size-adjust: 100%; }
body {
  font-size: 17px !important;
  line-height: 1.55 !important;
  letter-spacing: 0 !important;
}
p, li, dd, blockquote, td {
  font-size: 17px !important;
  line-height: 1.55 !important;
}
h1 { font-size: 28px !important; line-height: 1.2 !important; letter-spacing: -0.3px !important; }
h2 { font-size: 22px !important; line-height: 1.25 !important; margin-top: 28px !important; }
h3 { font-size: 20px !important; line-height: 1.3 !important; margin-top: 22px !important; }
h4, h5, h6 { font-size: 17px !important; }
.lead { font-size: 18px !important; line-height: 1.5 !important; }
/* The lead gets the size step; the OTHER paragraph in a hero must not.
   Every hero on the site holds exactly one <p class="lead">, and the
   simulation-model pages add a second, unclassed one under it — the
   "A video walkthrough is available on YouTube." line, which the page sizes
   inline at font-size:14px with --fg-soft. This rule used to read
   ".lead, .hero p", so that footnote was pulled UP to 18px — an !important
   declaration outranks the inline style — and rendered a hair larger than the
   body copy and identical to the lead above it. Measured on
   /pwm-simulink-fast-slow-leg/: 358x22 bare (one line, clearly subordinate),
   358x71 injected (three lines, same weight as the lead), which reads as three
   equally important opening paragraphs instead of a heading, a summary and a
   footnote. 15px is the .gate-note treatment below — subordinate, still well
   over the 13px floor. Eleven pages carry this paragraph: the four
   pwm-simulink-* models plus /3-phase-inverter-dq/, /boost-current-control/,
   /boost-pfc/, /boost-state-space-model/, /buck-walkthrough/,
   /how-3-phase-inverter-works/, /totem-pole-pfc/ and
   /bidirectional-totem-pole-pfc/. --fg-soft carries it at 5.9:1 light and
   9.4:1 dark, so only the size was ever wrong. */
.hero p:not(.lead) { font-size: 15px !important; line-height: 1.5 !important; }

/* Nothing below 13px: the iOS floor is 11pt and the site has a lot of 12s.
   The course-reader template (/courses/<name>-course/) brought a second crop
   of micro-labels, all measured with this stylesheet already injected:
   .sidebar-eyebrow and .toc-num 11px, .chapter-eyebrow 12px, the figure
   numbers 11px, .box-label / .signin-cta .eyebrow / .chapter-nav .dir
   10.5px, and .lock-pill at 8.5px — the smallest type on
   the site and the thing that says a chapter is locked. They are uppercase
   and letter-spaced, so they read even smaller than they measure. Re-checked
   after the bump: no label wraps and the TOC rows keep their height.

   The interactive Python widget (§6h) brought a third crop, all mono and all
   under the floor: .pyw-tag 10.5px (the "§2 · How PWM Works" eyebrow),
   .pyw-status 11.5px (the "Ran successfully" / "Running…" line, which is the
   only feedback that a run happened), .pyw-err 11.5px (the traceback when it
   does not), .pyw-hint 12px and .pyw-codeblock-head 10.5px (the "Python ·
   editable · running in browser" strip over the editor). The strip is a flex
   row that already wraps under the Copy button, so it takes a second line at
   13px and nothing is pushed out. The preset labels under each slider are
   handled with the rest of the widget's controls in §6h. */
small, .meta, .caption, figcaption, .footnote,
.sidebar-eyebrow, .toc-num, .chapter-eyebrow, .figure-caption .num,
.box-label, .signin-cta .eyebrow, .chapter-nav .dir,
.pyw-tag, .pyw-hint, .pyw-status, .pyw-err, .pyw-codeblock-head,
.lock-pill { font-size: 13px !important; }
/* Table headers were the same problem at 10.5px; they are handled with the
   cells in §4 so a header and its column stay one size. */
/* .gate-note is the one that is not a label: a full sentence under the
   sign-in button. It is a <div> at 12.5px on /courses/buck-course/ and a <p>
   on /courses/pwm-minicourse/, where the 17px body rule above would catch it
   and make the footnote as loud as the pitch above it. One size for both,
   between the two. */
.gate-note { font-size: 15px !important; line-height: 1.5 !important; }
/* The two notes under the model-download form are the same case, and they are
   <p> too, so the 17px body rule was inflating both to the size of the page's
   own prose. .note is the standing "I will send the file to your inbox shortly
   after you submit." (12px on the site, 324x37 -> 324x53 injected) and
   .capture-note is the line the form writes after a submit — "Sent. Check your
   inbox in a minute…" or the failure text with the fallback email address
   (13px on the site). .capture-note ships [hidden], so it is absent from every
   screenshot ever taken of these pages; unhidden and measured in both classes
   it clears contrast comfortably — ok 4.81:1 light / 8.24:1 dark, err 5.90:1
   light / 5.73:1 dark — so, as with .note, only the size needed handling. */
.email-capture .note, .capture-note { font-size: 15px !important; line-height: 1.5 !important; }

/* Paragraph rhythm: a full line, not a web 8px nudge. */
p { margin: 0 0 18px !important; }

/* ── 4. Images: rescale, never overflow ─────────────────────────────────── */
img, svg, video, canvas, embed {
  max-width: 100% !important;
  height: auto !important;
}
/* Iframes take the width cap but NOT height:auto. An iframe has no intrinsic
   aspect ratio, so "auto" does not rescale it the way it rescales an image —
   it throws away whatever height the page chose and falls back to the UA's
   150px default (or the CSS min-height, if there is one). Two places on the
   site size their own frames and were being overridden:

     #plot-embed (.plot-embed, the simulators' Plot Explorer) is sized from a
     postMessage handshake — sim-core/app.js: f.style.height = d.height+'px'.
     With height:auto winning it fell back to min-height:420px, and the frame
     is overflow:hidden with scrolling="no", so anything taller than 420px —
     a second curve, a stacked subplot — was cut off with no way to scroll to
     the rest. Single-plot output happens to be exactly 420px, which is why
     this looked fine while being one added curve away from losing the plot.

     #pwm-course-frame / #inverter-course-frame are whole web courses that
     declare height:calc(100vh - 70px). Measured at iPhone-13 size: 594px
     without injection, 150px with it — the entire course reduced to a
     sliver. Verified against /var/www/kristianskorpen-com-live/, since those
     routes are auth-gated.

   Height belongs to the page in every one of these cases, so the app only
   caps the width and leaves height alone. */
iframe { max-width: 100% !important; }
figure { margin: 20px 0 !important; width: auto !important; }
img { border-radius: 10px !important; }
/* Diagrams and schematics are often wider than the screen: let them scroll
   inside their own box rather than squashing them illegibly. */
.scroll-x, figure.wide, table {
  display: block !important;
  overflow-x: auto !important;
  -webkit-overflow-scrolling: touch !important;
}
/* A table cell is not a paragraph: it gets a column, not a page width. The
   17px body size of §3 applies to <td> as well, and in a three-column table
   inside the 358px content box that leaves ~110px per column — the notation
   table on /courses/rectifier-course/ broke almost every cell across three
   or four lines ("Charger power (our running example)" over four, the mono
   value column over three) and ran to 1012px tall. At 15px the same table is
   802px and most cells fit in one or two lines. Still comfortably above the
   13px floor, and only inside tables — running prose keeps its 17px. */
table td, table th { font-size: 15px !important; line-height: 1.45 !important; }

/* ── 5. One column ──────────────────────────────────────────────────────── */
[class*="grid"], [class*="cols"], [class*="row"], .hero-grid, .cards {
  display: block !important;
  grid-template-columns: none !important;
}
[class*="grid"] > *, .cards > * { width: auto !important; margin-bottom: 16px !important; }

/* ── 6. Tap targets ─────────────────────────────────────────────────────── */
a.btn, button:not(.pw-toggle):not(.nav-toggle):not(.tts-handle), .btn, .chip,
input[type="submit"], .actions a {
  min-height: 44px !important;
  display: inline-flex !important;
  align-items: center !important;
  justify-content: center !important;
  padding: 12px 18px !important;
  font-size: 17px !important;
  border-radius: 10px !important;
}
/* Nothing calling itself a button may be wider than the screen. */
button, .btn, a.btn, .chip, input[type="submit"] { max-width: 100% !important; }

/* The rule above sets display:inline-flex at a higher specificity than some
   of the page's own display:none, which RESURRECTS controls the site hides
   on purpose. .tts-handle is the read-aloud grab handle, hidden below 1080px
   by course-tts.css; it came back as a 38×44 stub floating over the course
   text, which is why it is excluded above. The [hidden] guard is the same
   failure in its generic form — the HTML attribute must always win. */
[hidden] { display: none !important; }

/* ── 6a. Segmented controls must not outgrow the viewport ──────────────────
   A segmented control is ONE bordered, overflow:hidden box holding N option
   buttons — every simulator's topology switch is .mode-toggle with
   role="tablist" (Standard / Bidirectional half-bridge / Averaged model).
   The generic button padding above is sized for a standalone button and
   inflates the whole row. Measured at iPhone-13 size against the static
   sources in /var/www (the live simulators are auth-gated):

     buck-sim   .mode-toggle 346px bare -> 418px injected, page scrollWidth
                390 -> 434, so "Averaged model" sat off the right edge
     boost-sim  330 -> 397, page scrollWidth 390 -> 413

   Either way the page slides sideways under the thumb and an option is off
   screen. So options inside a segmented control take only the padding that
   fits, and the box may wrap onto a second line rather than run off — which
   means a longer label on some other simulator can never push an option out
   of reach again. The 44px hit height from the rule above is kept; only the
   horizontal padding and the type size give way, and 13px is the floor §3
   already sets for the page. All 13 simulators re-measured: scrollWidth 390
   on every one, no option outside its container. */
.mode-toggle, .seg-toggle, .segmented, .toggle-group, [role="tablist"] {
  max-width: 100% !important;
  flex-wrap: wrap !important;
  /* The site clips this box (overflow:hidden) — with wrapping there is
     nothing left to clip, but auto leaves a scroll escape hatch instead of a
     silent swallow if a future label really is wider than the phone. */
  overflow: auto !important;
}
.mode-toggle > button:not(.pw-toggle):not(.nav-toggle),
.seg-toggle > button:not(.pw-toggle):not(.nav-toggle),
.segmented > button:not(.pw-toggle):not(.nav-toggle),
.toggle-group > button:not(.pw-toggle):not(.nav-toggle),
[role="tablist"] > button:not(.pw-toggle):not(.nav-toggle) {
  padding: 10px !important;
  font-size: 13px !important;
  flex: 1 1 auto !important;
  min-width: 0 !important;
  /* The generic 10px button radius would draw a rounded pill inside the
     container's own rounded box; segments are cells, the box rounds them. */
  border-radius: 0 !important;
}

/* ── 6b. Control rows keep their gap ───────────────────────────────────────
   §5 flattens anything with "row"/"cols"/"grid" in its class name to a
   block, which is right for a content grid and wrong for a row of buttons:
   the flex gap goes with it and the buttons end up touching edge to edge,
   reading as one glued control. Measured on /tools/plot-explorer/: the
   button padding widens "+ Add standard plot" from 135px to 205px, so it and
   "+ Add 3D plot" wrap onto two lines with 0px between them. Control rows
   stay flex, and wrap with a real gap.

   .meta-row is the same accident on a row that holds text rather than
   buttons: the price line on /bidirectional-single-phase-inverter/,
   "39 EUR" (22px serif) beside "14-day money-back guarantee" (14px). It
   matches [class*="row"] too, so its gap collapsed from the site's 10px to a
   4px word space — measured, the second span starts at x=105 bare and x=99
   injected — and the two spans lost the flex-wrap that would give the
   guarantee its own line rather than breaking it mid-phrase if a longer
   price or a longer guarantee ever pushed the pair past 358px.

   .pyw-row is the interactive widget's toolbar (§6h): "▸ Show code", a
   "Re-run on edit" checkbox, "▶ Run" and "↺ Reset code", declared
   display:flex with gap:12px. Flattened to a block the four controls stack
   left-aligned with no gap at all — measured on /courses/pwm-minicourse/, the
   Run button's box starts at x=167 exactly where the checkbox label's ends,
   so the two read as one glued control and there is no visible boundary
   between "what I am toggling" and "what runs the code". */
.add-plot-row, .btn-row, .button-row, .actions-row, .step-controls, .meta-row,
.pyw-row {
  display: flex !important;
  flex-wrap: wrap !important;
  align-items: center !important;
  gap: 10px !important;
}

/* Pills stay pills — only the height and hit area change. */
.chip, .badge { border-radius: 999px !important; }
.chip { padding: 10px 18px !important; font-size: 15px !important; }
/* The password-reveal control sits inside the field, so it gets a square
   44×44 hit area rather than the button padding above. */
.pw-toggle {
  min-width: 44px !important; min-height: 44px !important;
  display: inline-flex !important; align-items: center !important; justify-content: center !important;
}
.actions, .purchase-actions { display: flex !important; flex-direction: column !important; gap: 10px !important; }
.actions a, .purchase-actions a { width: 100% !important; }

/* A link that sits alone on its own line is a control, not prose: "Forgot
   password?", the "All courses →" section links, the contact list on /about/.
   Give those a 44px hit box. Links *inside* a sentence are deliberately left
   at text size — padding them out would wreck the line rhythm of the copy,
   and the surrounding words are not tappable anyway. */
.section-head a,
main p > a:only-child:not(.btn):not(.card),
main li > a:only-child:not(.btn):not(.card) {
  display: inline-flex !important;
  align-items: center !important;
  min-height: 44px !important;
}

/* ── 6c. The course table of contents ─────────────────────────────────────
   ~40 rows measured at 288×26 and 326×32 on /courses/buck-course/, and it is
   the only way to move around a very long read. It lives in
   <aside class="sidebar">, OUTSIDE <main>, so the "a link alone on its line
   is a control" rule above never reached it. Chapter rows are already flex
   (number + label) — only the hit box changes; align-items goes from
   baseline to center so the number stays optically aligned once the row is
   taller. */
.toc-list a, .toc-sublist a, .sidebar-footer a {
  min-height: 44px !important;
  display: flex !important;
  align-items: center !important;
}

/* ── 6d. The syllabus on a course landing page ─────────────────────────────
   Markup on the course landing pages (/buck-converter/, /boost-converter/,
   /single-phase-rectifier/, /grid-forming-vsg/,
   /bidirectional-single-phase-inverter/): a <section class="syllabus">
   holding <ol class="toc">, chapter titles in <strong> with their topics in
   a nested <ul>. It is NOT the in-course sidebar of §6c — different
   component, different classes — so nothing above reached it, and it is the
   bulk of these pages: 1827px of /buck-converter/'s 2996px document.
   /pwm-strategies-for-full-bridge-inverter/ was a sixth until 2026-08-11,
   when it became a 302 to /courses/pwm-minicourse/ — the course reader, not a
   landing page, so it is §6c/§6h territory now and none of this reaches it.

   Two desktop tells, both measured at iPhone-13 width with this stylesheet
   injected:

     Indent. The site nests .toc{padding-left:1.6em} inside
     .toc ul{padding-left:1.3em}, so topic text starts 49px in (16 page + 27
     + 22) and is left 309px of the 390px screen. Over half the topics then
     wrap for want of ~40px — 16 of 31 on /buck-converter/, 13 of 23 on
     /bidirectional-single-phase-inverter/, 9 of 15 on /boost-converter/.
     The outer list is list-style:none, so its indent buys nothing and goes
     to zero; the inner one keeps just enough room to hang its disc.

     Hierarchy. On the web .toc strong is 17px over 14.5px topics, but §3
     floors list text at 17px for reading, which flattens the chapter title
     to exactly the size of the topics under it and leaves the serif face to
     carry the distinction alone. Giving the title back a size step restores
     the ranking the page was designed with, without shrinking anything. */
.toc { padding-left: 0 !important; }
.toc ul { padding-left: 20px !important; }
.toc strong { font-size: 19px !important; line-height: 1.3 !important; }

/* ── 6e. A primary button alone in a paragraph ─────────────────────────────
   Five of those landing pages close with the same call to action they open
   with, but marked up as <p><a class="btn lg">Go to course</a></p> instead
   of the .actions box the hero uses. The site's own mobile rule that
   stretches these — .actions .btn{width:100%} — is scoped to that box, so
   the closing button rendered 153px wide and left-aligned beneath a
   full-width one at the top of the same page: one control, one page, two
   shapes. Only the bare-paragraph case is stretched; a .btn sitting inside a
   sentence has siblings, so :only-child never matches it. */
main p > a.btn:only-child { width: 100% !important; }

/* ── 6f. The course reader's figure captions ───────────────────────────────
   /courses/buck-course/, /boost-course/ and /rectifier-course/ ship a
   .figure-caption that is a flex ROW: "FIGURE 6", the caption sentence, then
   an "Open in simulator ↗" link pushed right with margin-left:auto. Two
   sources for that link — .btn-sim in the markup, and an identical one built
   at runtime by course-chrome.js addSimLinks() with an inline cssText, which
   is why the selector has to catch the class-less case by href.

   The site knows the row does not fit a phone (its own @media 720px sets
   margin-left:0 on the link) but never stops the row being a row, so the
   link stays a flex item beside the text. Measured at iPhone-13 width on
   /courses/rectifier-course/: caption box 326px, link 183px starting at
   x=209, so its right edge lands at 392 in a 390 viewport — the whole
   document could be dragged sideways, and the link's own edge sat under the
   screen edge. Wrapping the row and giving the link its own full-width line
   is what the site's mobile rule was reaching for; page scrollWidth goes
   392 -> 390 on all four course pages. */
.figure-caption { flex-wrap: wrap !important; }
.figure-caption .btn-sim,
.figure-caption a[href*="-simulator/"] {
  margin-left: 0 !important;
  flex: 1 0 100% !important;
  justify-content: center !important;
  min-height: 44px !important;
}

/* ── 6g. The sign-in gates that end a free chapter ─────────────────────────
   Each course closes its free chapter with the same control under a
   different name, and only buck's was styled here:

     buck        .continue-gate .btn-continue   (§7c)
     boost       .signin-cta .btn-primary       198x37 — under the 44pt floor
     minicourse  .signin-button                 background hardcoded #1f8a4c

   All three are the one link that gets a reader into the rest of the course,
   so they are restated together in §7c/§8b below rather than left to
   whichever rule happens to reach them. Here they only get their hit area:
   the boost button measured 37px tall, and a control alone in a centred card
   is the §6e case — a full-width bar rather than a 198px stub floating in
   the middle of a 326px box. */
.signin-cta .btn-primary, .signin-button, .continue-gate .btn-continue {
  width: 100% !important;
  max-width: 100% !important;
  box-sizing: border-box !important;
  justify-content: center !important;
  text-align: center !important;
}
/* Same cap for the in-caption simulator link, which sets no width of its own. */
.btn-sim { max-width: 100% !important; }

/* ── 6h. The in-course Python widget (.pyw) ────────────────────────────────
   New since the last sync: widgets-bundle.js lazy-mounts a React panel into
   <div class="widget-mount" data-widget="...">, which is why nothing here
   matched it before — the mount is EMPTY in the served HTML (it fills in on
   IntersectionObserver, so a screenshot taken without scrolling shows a blank
   320px box and reports no problems at all). It is the interactive part of
   the course: sliders for f_sw / V_in / V_ref, a plot, and a Run button over
   an editable control loop. Two mounts on /courses/pwm-minicourse/ — the page
   /pwm-strategies-for-full-bridge-inverter/ now redirects to — and five on
   /courses/rectifier-course/.

   The parameter grid. widgets-bundle.js writes grid-template-columns inline:

     <div class="pyw-params" style="grid-template-columns: repeat(3, 1fr)">

   and an inline style beats a stylesheet, so the site's OWN mobile rule —
   @media (max-width:700px){ .pyw-params{ grid-template-columns:1fr } } — never
   applies on a phone. Three columns do not fit and .pyw is overflow:hidden, so
   what does not fit is not scrollable, it is gone. Measured at iPhone-13 size:

     bare      .pyw 358 wide, scrollWidth 485 — third parameter clipped
     injected  .pyw 326 wide, scrollWidth 734 — second AND third clipped

   The gap between those two numbers is this stylesheet's own doing: §6 sizes
   every <button> as a standalone control (12px 18px padding, 17px), which
   inflates the .pyw-val readout from 68px to 108px and each preset under the
   slider from ~10px type to 17px, widening the three tracks to 248/228/208.
   Restating the site's mobile intent with a weight an inline style cannot
   outrank fixes both at once, and the presets keep a 44px hit box because at
   one column each parameter has the full 296px to lay them out in.

   Verified after: scrollWidth 326 on /courses/pwm-minicourse/ and 326 on
   /courses/rectifier-course/, zero elements outside the widget box on either.
   The widget's toolbar row is restored to a flex row in §6b. */
.pyw-params { grid-template-columns: 1fr !important; }
.pyw-param  { min-width: 0 !important; }
/* The value readout is a click-to-type field, not a page button: it keeps the
   44px hit box and gives back the padding and type size. The Copy button over
   the code editor is the same shape of thing — a utility control inside a
   strip, 44x10px on the site, blown to 82x17px by the §6 padding. */
.pyw-val {
  padding: 2px 6px !important;
  font-size: 15px !important;
}
.pyw-codeblock-copy {
  padding: 4px 10px !important;
  font-size: 13px !important;
}
/* The min / mid / max presets under each slider are the §6a segmented-control
   case — cells in a row, not standalone buttons. 13px is the §3 floor, up
   from the site's 10px. */
.pyw-param-range button {
  min-height: 44px !important;
  padding: 0 4px !important;
  font-size: 13px !important;
  border-radius: 0 !important;
}

/* ── 6i. Fields you type into ──────────────────────────────────────────────
   §6 sizes every button on the site to Apple's 44pt minimum and never touched
   the text fields beside them, which are the same kind of target. Measured at
   iPhone-13 size:

     .capture-form input  324x41 at 15px   the email box on the 13 model
                                           download pages — the one control
                                           those pages exist for
     .auth-card input     358x40 at 16px   /login/ email + password
     /account/ inputs         40 at 16px   change-password

   Two separate problems at 41px and 15px. The height is just under the hit
   minimum. The type size is the worse one and does not show up in a
   screenshot at all: iOS auto-zooms the page when a field smaller than 16px
   takes focus, and it does not zoom back out, so tapping the email box scales
   the whole page up and leaves the reader scrolled sideways in a layout that
   looks broken. 16px is the documented threshold, so this is a floor rather
   than a restyle — the auth fields already sit at 16px and only gain the 4px
   of height.

   Scoped by INPUT TYPE, not by a wrapper class, because type is exactly the
   "you type into it" semantics wanted here and it structurally cannot reach
   the controls that must keep their own size: the course widget's sliders
   (input[type=range], 250x22) and its "Re-run on edit" checkbox (13x13), and
   the hidden <textarea> CodeMirror mounts inside the simulators' editor. */
input[type="email"], input[type="password"], input[type="text"], input[type="search"] {
  min-height: 44px !important;
  font-size: 16px !important;
  box-sizing: border-box !important;
}

/* ── 7. Touch behaviour ─────────────────────────────────────────────────── */
* { -webkit-tap-highlight-color: rgba(0,0,0,0.06); }
/* Hover-only affordances are invisible on touch; neutralise them. */
@media (hover: none) {
  *:hover { transition: none !important; }
}
body { overscroll-behavior-y: contain; }

/* ── 7b. Two light-mode tokens that miss WCAG AA on the site itself ────────
   These are not caused by anything the app injects — they are the site's own
   values, and they fail on the web too. Measured at iPhone-13 width:

     --accent   #1F8A4C  as text on --bg  #FAFAF6 -> 4.18:1  (needs 4.5)
                         under .btn's white label -> 4.38:1  (needs 4.5)
     --ink-400  #7B847F  as text on --bg  #FAFAF6 -> 3.68:1  (needs 4.5)
                         as .refund on a white card -> 3.85:1

   Both are used *both* as a text colour and as a fill, so nudging the token
   is what fixes every occurrence at once — darkening the accent lifts the
   eyebrow, the "All courses →" links and the white button label together.
   Same hue, one step down the site's own ramp (green-500 -> green-600). */
:root {
  --accent:       #166E3D;   /* was --green-500 */
  --accent-hover: #0E5630;   /* was --green-600 */
  --ink-400:      #656E69;   /* was #7B847F     */
  --fg-muted:     #656E69;
}

/* ── 7c. Filled green controls whose label the page then re-inks ───────────
   /courses/buck-course/ declares its link colour as

     .content a:not(.btn):not(.btn-sim):not(.chapter-nav a) { color: var(--link) }

   — specificity (0,4,1), which beats the button's own
   .continue-gate .btn-continue { color:#fff } at (0,2,0). So the page's
   primary call to action renders LINK INK ON A GREEN FILL, in both schemes:
   #0B5D8A on #1F8A4C = 1.63:1 light, #58A6FF on #30D158 = 1.25:1 dark. It is
   the site's own bug (it fails on the web too), but it is the one control
   that gets a reader into the paid chapters, so the app restates it.

   The fill is the site's --green-500, which even with a white label only
   reaches 4.38:1 in light — the same shortfall §7b fixed for --accent — so
   these controls take the corrected --accent as their fill. Descendants
   inherit, because the label is wrapped in spans (.arrow, .meta) the link
   rule colours individually. Dark mode flips the label in §8b.

   The three course-reader gates of §6g are the same accident in three more
   places, all measured with this stylesheet injected:

     .signin-button (/courses/pwm-minicourse/) hardcodes background:#1f8a4c
       and color:#fff, and sits inside .content, so the same link rule wins:
       #0B5D8A on green = 1.63:1 light, #58A6FF = 1.73:1 dark.
     .signin-cta .btn-primary (/courses/boost-course/) does hold its white
       label (color:#fff !important), but on --accent that is 4.38:1 in light
       and 2.02:1 against the bright dark-mode green.
     The caption link course-chrome.js injects has NO class, and paints
       itself #1F8A4C with a white label inline — 4.38:1 in both schemes, and
       inline styles mean only !important reaches it.

   Routing all of them through --accent (corrected in §7b) makes one control
   one colour across the five courses instead of three near-greens. */
.continue-gate .btn-continue, .btn-sim, .btn-mini.primary, .tts-cta,
.signin-cta .btn-primary, .signin-button,
.figure-caption a[href*="-simulator/"] {
  background: var(--accent) !important;
  border-color: var(--accent) !important;
  color: #FFFFFF !important;
  min-height: 44px !important;
  display: inline-flex !important;
  align-items: center !important;
}
.continue-gate .btn-continue *, .btn-sim *, .btn-mini.primary *, .tts-cta *,
.signin-cta .btn-primary *, .signin-button *,
.figure-caption a[href*="-simulator/"] * {
  color: inherit !important;
}

/* ── 8. Dark mode ───────────────────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {

/* ── 8a. Re-theme by redefining the site's own design tokens ───────────────
   The site's :root declares an "ink" ramp used with a consistent polarity —
   high numbers (ink-900/1000) are always text, low numbers (ink-25/50/100)
   are always surfaces — so inverting the ramp flips every component that
   uses it. The accent/link/status ramps are re-pointed at colours that clear
   4.5:1 against both surfaces the app actually renders (#000 page, #1C1C1E
   card). Nothing here is a per-element patch; the components inherit it.

   Source order does the work: this block is injected after the site's
   stylesheet, and :root vs :root is a specificity tie, so the later
   declaration wins whenever the media query matches. */
:root {
  color-scheme: dark;

  /* Ink ramp, inverted. */
  --ink-1000: #FFFFFF;
  --ink-900:  #F2F3F1;   /* body text          */
  --ink-700:  #D3D7D4;
  --ink-500:  #A9AFAB;   /* secondary text     */
  --ink-400:  #8E948F;   /* muted text         */
  --ink-300:  #6B716D;
  --ink-200:  #3A403C;   /* strong border      */
  --ink-150:  #2C312E;   /* border             */
  --ink-100:  #242826;   /* inset surface      */
  --ink-50:   #1A1E1C;
  --ink-25:   #111214;   /* raised panel       */
  --paper:    #1C1C1E;   /* card surface       */

  /* Green ramp, inverted: the 50/100 end is used as chip and badge FILL,
     the 600/700 end as text ON that fill, so the two ends swap roles. */
  --green-50:  #10301D;
  --green-100: #163E26;
  --green-200: #1E5231;
  --green-300: #2A7A46;
  --green-400: #2FA85B;
  --green-500: #30D158;
  --green-600: #4ADE80;
  --green-700: #7EE8A2;
  --green-800: #A8F0C0;
  --green-900: #CFF7DC;

  /* Status and link ramps: 500 = text/foreground, 100 = tinted fill. */
  --blue-500:   #58A6FF;  --blue-600:   #8CC2FF;  --blue-100:   #16324A;
  --warn-500:   #F0A46B;  --warn-100:   #3A2312;
  --ok-500:     #4ADE80;  --ok-100:     #12331F;
  --info-500:   #8DC2F0;  --info-100:   #14293D;
  --danger-500: #FF7A85;  --danger-100: #3A151E;

  --grid-line: rgba(255,255,255,.06);

  /* Semantic aliases. Restated rather than left pointing at the ramp, so the
     exact contrast of the colours that carry text is pinned here. */
  --fg:         #F2F3F1;
  --fg-soft:    #A9AFAB;
  --fg-muted:   #8E948F;
  --fg-on-dark: #F2F3F1;
  --bg:         #000000;
  --bg-card:    #1C1C1E;
  --bg-paper:   #141416;
  --bg-inset:   #242826;
  --border:        #2C312E;
  --border-strong: #3A403C;
  --accent:       #30D158;
  --accent-hover: #4ADE80;
  --accent-soft:  #10301D;
  --accent-on:    #052912;   /* text ON the accent — dark, not white */
  --link:       #58A6FF;
  --link-hover: #8CC2FF;

  /* Drop shadows are invisible on black; a hairline is what separates a card
     from the page in a dark theme. */
  --shadow-0: none;
  --shadow-1: 0 0 0 1px rgba(255,255,255,.06);
  --shadow-2: 0 0 0 1px rgba(255,255,255,.10);
  --shadow-3: 0 10px 28px -10px rgba(0,0,0,.9), 0 0 0 1px rgba(255,255,255,.10);
  --shadow-inset: inset 0 0 0 1px var(--border);
  --shadow-focus: 0 0 0 3px rgba(48,209,88,.35);
  --shadow: var(--shadow-1);
}

/* ── 8b. The literal colours the site hardcodes outside the token system ───
   Every one of these was found by scanning the built stylesheet and the
   per-page scoped <style> blocks for hex/rgb values; there are no others.
   Without these the token remap leaves exactly the reported failures:
   white-on-green button labels, a near-black "Free" price on a dark card. */

/* .btn hardcodes color:#fff. White on a bright dark-mode green is 2.0:1 —
   this is the "Browse courses" label Kristian reported. Primary buttons take
   dark text on the accent; the transparent variants keep coloured text. */
.btn                { color: var(--accent-on) !important; }
.btn.secondary      { background: transparent !important; color: var(--accent) !important; }
/* .ghost has no fill, so its outline is the only thing that says "button".
   The token border (#3A403C) is nearly invisible on black — step it up to
   --ink-300 so "Create account" / "Sign out" read as controls. */
.btn.ghost          { background: transparent !important; color: var(--fg) !important;
                      border-color: var(--ink-300) !important; }
.chip.active        { color: var(--accent-on) !important; }   /* also hardcodes #fff */
/* Same hardcoded #fff, on the simulators' selected topology segment: white on
   the bright dark-mode accent is 2.02:1. */
.mode-opt.active    { color: var(--accent-on) !important; }

/* Same treatment for the green-filled controls §7c restates: in dark the fill
   is the bright accent (#30D158), so the label goes dark rather than white.
   "Sign in to continue →": 1.25:1 -> 7.9:1. */
.continue-gate .btn-continue, .btn-sim, .btn-mini.primary, .tts-cta,
.signin-cta .btn-primary, .signin-button,
.figure-caption a[href*="-simulator/"],
.continue-gate .btn-continue *, .btn-sim *, .btn-mini.primary *, .tts-cta *,
.signin-cta .btn-primary *, .signin-button *,
.figure-caption a[href*="-simulator/"] * {
  color: var(--accent-on) !important;
}

/* /courses/pwm-minicourse/ paints its whole sign-in gate a literal
   background:#f7faf8 — the one card on the site that does not resolve
   through the token system. In dark that is a white panel holding the
   inverted body ink: the heading "That is the end of the open preview" and
   its paragraph both measured 1.06:1, i.e. invisible, with only the green
   eyebrow and the button readable inside it.

   background-COLOR rather than the background shorthand, deliberately: the
   other courses build their gate out of a gradient (buck:
   radial-gradient(var(--green-50), var(--paper))), which lives in
   background-image and already re-themes with the tokens. Patching the
   colour layer alone fixes the hardcoded card without flattening the ones
   that were done properly. */
.continue-gate { background-color: var(--bg-card) !important; }

/* The same page hardcodes its locked-chapter pill (color:#8a5a00 with an
   --amber-500 fallback border), a light-mode amber that lands at 2.87:1 on
   the dark sidebar card — and it is the only marking that says a chapter
   needs an account. /courses/boost-course/ and /courses/buck-course/ draw
   the identical pill out of --warn-500/--warn-100 and re-theme by
   themselves, so this points the odd one at the same token: 8.5:1. */
.lock-pill { color: var(--warn-500) !important; border-color: var(--warn-500) !important; }

/* .badge.course hardcodes an amber pair (#fef3c7 / #854d0e). */
.badge.course       { background: #3A2E10 !important; color: #F5D67B !important; }

/* /login/ and /account/ scoped styles: a #111 submit button (invisible as a
   shape on black) and a #c00 error message (3.6:1 on black). */
.auth-card button[type="submit"] {
  background: var(--accent) !important;
  color: var(--accent-on) !important;
  border: 1px solid var(--accent) !important;
}
.auth-error         { color: #FF7A85 !important; }
.auth-ok            { color: var(--ok-500) !important; }

/* Form fields: .email-capture hardcodes background:#fff, and the auth inputs
   inherit the UA's light field. */
input, select, textarea {
  background: var(--bg-inset) !important;
  color: var(--fg) !important;
  border-color: var(--border-strong) !important;
}
input::placeholder, textarea::placeholder { color: var(--fg-muted) !important; }

/* ── 8c. CodeMirror: the one component that bypasses the design system ─────
   The simulators' control-loop editor is CodeMirror 5, and sim-core/app.js
   appends /vendor/codemirror-5.65.18/codemirror.min.css to <head> at runtime
   — i.e. AFTER the simulator's own stylesheet. The site does try to theme the
   tokens (buck-sim/style.css: .CodeMirror .cm-keyword { color:#8C2A1F }
   and friends) but those are specificity ties with .cm-s-default .cm-keyword
   and lose on source order, so the shipped LIGHT theme wins. On the dark
   #1C1C1E card that leaves the code Kristian writes his control loop in
   effectively unreadable:

     .cm-keyword  #770088 ("static", "if")      1.73:1
     .cm-def      #0000FF (function names)      1.98:1
     .cm-atom     #221199                       1.68:1
     .cm-string   #AA1111                       2.19:1

   Re-theming the token classes rather than the editor chrome, because the
   chrome (.CodeMirror background/colour, gutters, line numbers, cursor) is
   already declared !important by the site and resolves through the tokens
   §8a remaps. Every colour below clears 4.5:1 on #1C1C1E; !important is
   needed because the vendor sheet can land after this one. */
.CodeMirror .cm-keyword,     .cm-s-default .cm-keyword     { color: #FF7B72 !important; }
.CodeMirror .cm-def,         .cm-s-default .cm-def         { color: #D2A8FF !important; }
.CodeMirror .cm-atom,        .cm-s-default .cm-atom        { color: #D2A8FF !important; }
.CodeMirror .cm-builtin,     .cm-s-default .cm-builtin     { color: #D2A8FF !important; }
.CodeMirror .cm-number,      .cm-s-default .cm-number      { color: #79C0FF !important; }
.CodeMirror .cm-string,      .cm-s-default .cm-string      { color: #A5D6FF !important; }
.CodeMirror .cm-string-2,    .cm-s-default .cm-string-2    { color: #A5D6FF !important; }
.CodeMirror .cm-type,        .cm-s-default .cm-type,
.CodeMirror .cm-variable-3,  .cm-s-default .cm-variable-3  { color: #FFA657 !important; }
.CodeMirror .cm-variable-2,  .cm-s-default .cm-variable-2  { color: #79C0FF !important; }
.CodeMirror .cm-attribute,   .cm-s-default .cm-attribute,
.CodeMirror .cm-tag,         .cm-s-default .cm-tag,
.CodeMirror .cm-link,        .cm-s-default .cm-link,
.CodeMirror .cm-header,      .cm-s-default .cm-header      { color: #7EE8A2 !important; }
.CodeMirror .cm-comment,     .cm-s-default .cm-comment     { color: #A9AFAB !important; }
.CodeMirror .cm-meta,        .cm-s-default .cm-meta,
.CodeMirror .cm-qualifier,   .cm-s-default .cm-qualifier,
.CodeMirror .cm-bracket,     .cm-s-default .cm-bracket,
.CodeMirror .cm-operator,    .cm-s-default .cm-operator,
.CodeMirror .cm-punctuation, .cm-s-default .cm-punctuation { color: #C9D1CC !important; }
.CodeMirror .cm-error,       .cm-s-default .cm-error,
.CodeMirror .cm-invalidchar, .cm-invalidchar               { color: #FF7A85 !important; }
/* Editor chrome the vendor sheet paints white or near-white. */
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { background-color: var(--bg-paper) !important; }
.CodeMirror-selected                                    { background: #2C3A4A !important; }
.CodeMirror-focused .CodeMirror-selected                { background: #3A4E63 !important; }
.CodeMirror-line::selection, .CodeMirror-line > span::selection,
.CodeMirror-line > span > span::selection               { background: #3A4E63 !important; }
.CodeMirror-activeline-background                       { background: rgba(255,255,255,.05) !important; }
.CodeMirror-guttermarker                                { color: var(--fg) !important; }
.CodeMirror-guttermarker-subtle                         { color: var(--fg-muted) !important; }
div.CodeMirror span.CodeMirror-matchingbracket          { color: #4ADE80 !important; }
div.CodeMirror span.CodeMirror-nonmatchingbracket       { color: #FF7A85 !important; }

/* Image plates stay LIGHT on purpose. The site's diagrams are opaque
   light-background rasters plus one transparent SVG drawn in dark strokes
   (/images/single-phase-inverter.svg) — a dark plate would either frame the
   rasters in a mismatched border or erase the SVG outright. A white plate
   reads as a printed figure and keeps every schematic legible.

   .figure-frame is that same plate in the course readers, and it was missed
   because the rule above keys on the <figure> ELEMENT while the courses build
   theirs out of classes: <div class="figure"><div class="figure-frame">. So
   the frame kept var(--paper), which §8a remaps to #1C1C1E, and every
   transparent line-art SVG inside it was drawn in dark ink on near-black:

     /courses/pwm-minicourse/ figures/Bridge.svg and Half-Bridge.svg are
       dvisvgm output carrying no fill or stroke attribute at all, so they
       paint in the initial black: 1.23:1 on the frame. Both full-bridge
       schematics — the two figures the chapter is about — were invisible.
     /courses/rectifier-course/ (29 frames) and /courses/buck-course/ (9) and
       /courses/boost-course/ (5) draw theirs in the site's light-mode ink
       (#0E1413 1.09:1, #5A645F 2.77:1, plus #166E3D and #0B5D8A).

   On white those become 18.6:1, 6.1:1 and 6.3:1. Photographs
   (solar_system.jpg) are opaque and do not care either way — this is the same
   trade the rule above already made for the rest of the site.

   background-COLOR for the frame, for the reason §8b spells out: the plate's
   graph-paper texture lives in background-image (two linear-gradients in
   rgba(14,20,19,0.04) — dark lines, so they read on white exactly as they do
   in light mode), and the shorthand the rule above uses would erase it. The
   selectors above are bare images with nothing in that layer to lose.

   The corner ticks are the one thing that has to follow the plate: they are
   var(--accent), which is the bright #30D158 down here — 2.02:1 on white.
   Pinned to the light-mode accent, which is what the page draws them in when
   the plate is white anyway: 6.3:1. */
figure img, .card .img, .card .img-contain, .account-card .img-contain, .hd-frame {
  background: #FFFFFF !important;
}
.figure-frame { background-color: #FFFFFF !important; }
.figure-corners > span { border-color: #166E3D !important; }
}
