/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */
/* mathviz v0.1.5 -- generated by scripts/build.mjs from src/manifest.mjs; do not edit, edit src/ and rebuild. */

/* ---- src/css/tokens.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* Design tokens. Every other stylesheet in this library, and every chart
 * built through VM.plotting, consumes these --vm-* custom properties and
 * nothing else -- no --bs-* fallback chains, no hardcoded hex -- so a site
 * re-themes the whole library by redeclaring the tokens it wants to change.
 *
 * Both blocks are wrapped in :where(), which has zero specificity: a plain
 * `:root { --vm-accent: ... }` in a consumer's stylesheet always wins over
 * these defaults, whatever order the two files are loaded in. That matters
 * in Quarto, where an extension's stylesheet is injected *before* the
 * project's own `css:` files but *after* the compiled theme SCSS -- without
 * :where(), these defaults would beat a theme's own :root declarations.
 *
 * JS reads the tokens at call time via getComputedStyle(document.body) --
 * not documentElement: the dark block below matches <body> (Quarto's toggle
 * flips a class there), and <html> never does, so reading from <html> would
 * silently return the light value in dark mode. */

:where(:root) {
  /* Chrome */
  --vm-bg: #fdfdfd;
  --vm-surface: #f6f7f9;
  --vm-surface-hover: #ececec;
  --vm-text: #14161a;
  --vm-text-soft: #5f6672;
  --vm-border: rgba(27, 31, 36, 0.15);
  --vm-accent: #2563eb;
  --vm-accent-hover: #1d4ed8;
  /* Text and icons drawn ON --vm-accent, for a filled button or pill. Not
   * white: --vm-accent is a mid blue in light mode but a pale blue in dark
   * mode, and white on the dark accent measures 2.09:1 -- below even the
   * 3:1 large-text floor, which made every Plot button on the site hard to
   * read in dark mode. The page background is the right answer in both
   * themes, because the accent is already chosen to contrast with it
   * (5.08:1 light, 8.21:1 dark). A theme overriding --vm-accent should
   * override this with its own background. */
  --vm-on-accent: #fdfdfd;
  --vm-grid: rgba(20, 22, 26, 0.08);
  --vm-radius: 8px;
  --vm-radius-sm: 6px;
  --vm-shadow: 0 1px 2px rgba(27, 31, 36, 0.08);

  /* Controls. One height for every control a panel holds -- text field,
   * number readout, select, button -- so a row of mixed controls lines up
   * on both edges instead of three heights stepping against each other.
   * --vm-label-size keeps a control's label from inheriting the host page's
   * body-prose size, which on a Quarto page made every label taller than the
   * value it labels. */
  --vm-control-h: 1.75rem;
  --vm-label-size: 0.78rem;
  /* The select's dropdown arrow, drawn to match --vm-text-soft. It has to be
   * a whole url() rather than just a color because a data: URI is opaque to
   * CSS -- var() does not resolve inside one -- so the only way the arrow
   * re-themes with everything else is for each theme to declare its own. */
  --vm-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%235f6672' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
  /* The face chart text is set in. Defaults to the system stack; a site
   * that loads its own font redeclares this so charts match the page. */
  --vm-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  /* Chart palette -- what VM.plotting.colors() returns. Named after what
   * each color means on a chart (the function itself, an alternate/reference
   * trace, a "good"/converged marker, ...), not the hue. `halo` is the ring
   * drawn around a marker to lift it off whatever it sits on: white on
   * light, the page background on dark. */
  --vm-color-fn: #2563eb;
  --vm-color-alt: #dc2626;
  --vm-color-ok: #16a34a;
  --vm-color-muted: #94a3b8;
  --vm-color-ink: #111827;
  --vm-color-warn: #f59e0b;
  --vm-color-accent2: #9333ea;
  --vm-color-accent3: #0d9488;
  --vm-color-halo: #ffffff;
}

/* Dark theme. The selector list here MUST match `darkSelector` in
 * src/js/plotting/chart-theme.js -- that is how the JS decides which theme
 * is active. `body.quarto-dark` is what Quarto's light/dark toggle sets;
 * `html[data-bs-theme="dark"]`/`body[data-bs-theme="dark"]` is Bootstrap
 * 5.3's convention; `html.vm-dark`/`body.vm-dark` is for any site with its
 * own toggle (put it on <html> or <body>, or call
 * VM.plotting.configure({darkSelector}) to name your own, scoped the same
 * way). Deliberately root-scoped rather than a bare attribute/class match --
 * Quarto sets data-bs-theme="dark" directly on its own <nav> element to
 * force a dark navbar variant independent of the page theme, and since
 * custom properties inherit down the DOM, an unscoped selector here leaked
 * the whole dark palette into the navbar on an otherwise-light page. The
 * dark palette lightens every hue: the light values read muddy on a
 * near-black ground. */
:where(body.quarto-dark, html[data-bs-theme="dark"], body[data-bs-theme="dark"], html.vm-dark, body.vm-dark) {
  --vm-bg: #171b29;
  --vm-surface: #1e2436;
  --vm-surface-hover: #2a3145;
  --vm-text: #c9cedb;
  --vm-text-soft: #8b93a7;
  --vm-border: rgba(224, 234, 247, 0.15);
  --vm-accent: #8ab4ff;
  --vm-accent-hover: #a9c7ff;
  --vm-on-accent: #171b29;
  --vm-grid: rgba(201, 206, 219, 0.10);
  --vm-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  --vm-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%238b93a7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");

  --vm-color-fn: #8ab4ff;
  --vm-color-alt: #f87171;
  --vm-color-ok: #4ade80;
  --vm-color-muted: #7d8aa3;
  --vm-color-ink: #c9cedb;
  --vm-color-warn: #fbbf24;
  --vm-color-accent2: #c084fc;
  --vm-color-accent3: #2dd4bf;
  --vm-color-halo: #171b29;
}

/* ---- src/css/panel.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Parameter controls panel ────────────────────────────────────── */
/* Composable primitives: .ojs-panel is chrome only, .ojs-row/.ojs-grid are
 * layout only. Combine them (e.g. class="ojs-panel ojs-row") for a
 * standalone panel, or nest a bare .ojs-row/.ojs-grid inside a .ojs-panel
 * for a control group within a larger panel — nesting two .ojs-panels
 * would double up the border/background. */

.ojs-panel {
  padding: 0.5rem 0.75rem;
  margin-bottom: 0.5rem;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: var(--vm-surface);
}

.ojs-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 0.75rem;
  align-items: flex-end;
}

/* A flex/grid item's automatic minimum size is its content's min-content
 * width, so an item can refuse to shrink below whatever is inside it and
 * push the whole row past its container instead. Every child here wraps one
 * Observable control whose intrinsic width is a fixed 360px (see the width
 * rule on .ojs-panel form[class^="oi-"] below), which is wider than a
 * two-column .ojs-grid track on a narrow viewport -- and a track sized to a
 * min-content contribution that large overflows the page rather than
 * wrapping. Same reason .quarto-layout-cell needs it above. */
.ojs-row > *,
.ojs-grid > * {
  min-width: 0;
}

/* First control grows to fill the row; later ones (e.g. a button) keep their
 * natural width.
 *
 * :first-of-type/:last-of-type rather than :first-child/:last-child, which
 * these were. The child forms mean "a div that is ALSO the first/last child",
 * so any non-div sibling inserted at either end stops them matching anything
 * at all -- which is what happened when js/ui/draggable-overlay.js started
 * appending its <button> grip to the legend panel, silently dropping the
 * `flex: 1 1 200px` that makes a step slider fill its row. Every .ojs-row's
 * children are Quarto `div.cell` wrappers, so the two forms are otherwise
 * equivalent here. */
.ojs-row > div { margin: 0; }
.ojs-row > div:first-of-type { flex: 1 1 200px; }
.ojs-row > div:last-of-type { flex: 0 0 auto; }

/* auto-fit so empty tracks collapse instead of pinning real ones to the minmax floor.
 * Floor lowered from 250px to 200px, and the row/column gap widened slightly,
 * now that shrinking the label (see the form[class^="oi-"] label rule below)
 * leaves more compact fields -- four fit one row at normal page width instead
 * of three. */
.ojs-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 0.4rem 0.6rem;
  align-items: end;
}

/* Vertical rhythm between a panel's own top-level rows (e.g. a full-width
 * formula field above a .ojs-grid of scalar fields) now that .ojs-panel no
 * longer relies on each field's own 70px label+gap to create that space.
 * Excluded when .ojs-panel is itself the grid/row (e.g. the Positive
 * Predictive Value page's `ojs-panel ojs-grid`), whose children are grid
 * items spaced by the grid's own `gap`, not stacked block children. */
.ojs-panel:not(.ojs-grid):not(.ojs-row) > * + * {
  margin-top: 0.4rem;
}

/* Skin every native input inside a panel to match its chrome */
.ojs-panel input {
  height: var(--vm-control-h);
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius-sm);
  padding: 0 0.5rem;
  font-size: 0.85rem;
  background: var(--vm-bg);
  color: var(--vm-text);
  box-sizing: border-box;
}

/* !important overrides Observable Inputs' own flex-shrink on the number spinner */
.ojs-panel input[type="number"] {
  width: 5.5em;
  flex: none !important;
}

/* 10em comfortably fits the short values/expressions (0.16, 20*3, pi/4,
 * -5, ...) that the overwhelming majority of text fields hold. The
 * minority that genuinely need to hold a long formula (fText, gText, the
 * y' = f(t, y) field, ...) already opt into .ojs-fill everywhere in this
 * codebase — none of them rely on this default, so it's safe to keep this
 * default compact rather than sized for the exceptional case. */
.ojs-panel input[type="text"] {
  width: 10em;
  max-width: 100%;
  flex: none !important;
}

/* One focus ring for every field in a panel. <select> is skinned separately
 * below (it has a chevron and its own sizing), but its focus state is not a
 * separate design decision -- keeping the two in one rule is what stops them
 * drifting apart. */
.ojs-panel input:focus,

/* Nothing skinned <select> before this -- Observable Inputs' select()
 * renders the bare browser default, so "Try an example" showed up as a
 * square-cornered, full-panel-width box next to rounded 30px inputs.
 * width: auto + min/max-width lets it size to its own option text instead
 * of stretching to fill .ojs-fill's 100%, which is still needed on the
 * wrapping form (see .ojs-fill below) so the field takes its row's full
 * width for layout purposes while the visible control itself stays compact. */
.ojs-panel select {
  height: var(--vm-control-h);
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius-sm);
  padding: 0 1.75rem 0 0.5rem;
  font-size: 0.85rem;
  background-color: var(--vm-bg);
  background-image: var(--vm-select-chevron);
  background-repeat: no-repeat;
  background-position: right 0.5rem center;
  background-size: 0.75rem;
  color: var(--vm-text);
  box-sizing: border-box;
  appearance: none;
  -webkit-appearance: none;
  width: auto;
  min-width: 12em;
  max-width: 100%;
}


.ojs-panel input[type="range"] {
  border: none;
  padding: 0;
  background: none;
  accent-color: var(--vm-accent);
  width: 100%;
}

/* Observable Inputs' button has no class of its own to skin, and its
 * browser-default text color doesn't adapt to the dark theme's panel
 * background — style it explicitly instead of relying on defaults.
 * Scoped to Observable Inputs' generated form (class^="oi-", per
 * quarto-ojs.css) so this doesn't also catch Quarto's own per-cell
 * "Copy to Clipboard" button, which lives in the same panel. */
/* :not(.vm-play-button):not(.vm-play-more) excludes the slider playback
 * button (js/ui/slider-play.js's <button class="vm-play-button">, inside
 * this same panel form) -- sliders.css sizes that one 1.5rem square with a
 * lone class (0,1,0); this selector's three parts (0,3,1) would otherwise
 * beat it and stretch a round icon button into an oval, the same
 * runtime-injection-order trap this file's other rules already guard
 * against. */
.ojs-panel form[class^="oi-"] button:not(.vm-play-button):not(.vm-play-more) {
  height: var(--vm-control-h);
  border: 1px solid var(--vm-accent);
  border-radius: var(--vm-radius-sm);
  padding: 0 0.75rem;
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--vm-on-accent);
  background: var(--vm-accent);
  transition: background-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
  cursor: pointer;
  box-sizing: border-box;
}

.ojs-panel form[class^="oi-"] button:not(.vm-play-button):not(.vm-play-more):hover {
  background: var(--vm-accent-hover);
  border-color: var(--vm-accent-hover);
}

.ojs-panel form[class^="oi-"] button:not(.vm-play-button):not(.vm-play-more):focus {
  outline: none;
  box-shadow: 0 0 0 0.15rem color-mix(in srgb, var(--vm-accent) 35%, transparent);
}

/* Observable Inputs lays out every control as <form class="oi-...">
 * label + input side by side in a flex row (display: flex; align-items:
 * center; flex-direction: row — verified via computed styles, since this
 * rule lives in Observable's own injected stylesheet rather than
 * anything in this repo). When a label's text wraps to more lines than a
 * neighboring field's label (a narrow .ojs-grid column, a long parameter
 * name), each field's input ends up vertically centered in its own
 * differently-tall row, so inputs across a shared .ojs-row/.ojs-grid stop
 * lining up with each other. Stacking label above control instead removes
 * the row-height dependency entirely: the control is always the last
 * (bottom) element of its own field regardless of how many lines the
 * label took, so .ojs-grid's `align-items: end` then bottom-aligns every
 * control in a row exactly, no matter how each label wraps. */
.ojs-panel form[class^="oi-"] {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
  /* Observable gives every form a fixed 360px width. In the row layout it
   * was written for that is a sensible default; in a narrower .ojs-grid
   * track it is a hard floor the item cannot shrink under, so a two-column
   * grid on a ~700px viewport resolved its tracks to 360px each and pushed
   * ~70px off the right of the page. A percentage is the fix rather than a
   * smaller fixed width: percentages don't contribute to intrinsic sizing,
   * so the track goes back to being sized by the grid instead of by its
   * contents. The visible field width is unaffected either way -- that is
   * set on the input (10em, or .ojs-fill for a formula), not on the form. */
  width: 100%;
}

/* Observable also hardcodes every <label> to a fixed width: 120px;
 * flex-shrink: 0 — a leftover of the row layout, where capping every
 * label to the same width keeps inputs beside differently-long labels
 * starting at the same x-position. In the column layout above there's no
 * such row to align, so that fixed 120px just forces labels wider than
 * ~120px (e.g. "Initial condition y(0)") to wrap even when the field has
 * plenty of room. Let each label size to its own content instead. */
/* padding: 0 and margin: 0 override Observable's own runtime-injected
 * `form.oi-<hash>-toggle > label { padding: 5px 0 4px }` -- this selector
 * (class + element/attr + element, i.e. (0,3,1)) beats that injected rule's
 * (0,2,1), so it wins regardless of load order. That padding reset, plus
 * shrinking the font from the inherited 17px body-prose size down to
 * --vm-label-size, is what takes a field from 70px to about 48px: the label
 * was previously taller than the control it labels. */
.ojs-panel form[class^="oi-"] label {
  width: auto;
  padding: 0;
  margin: 0;
  font-size: var(--vm-label-size);
  line-height: 1.25;
  font-weight: 500;
  color: var(--vm-text-soft);
}

/* Attach to an Inputs.* wrapper div to make it fill the row, overriding
 * Observable Inputs' own default max-width: 360px cap on its form wrapper. */
.ojs-fill { width: 100% !important; max-width: none !important; }

/* Lets the user drag an expression field's right edge wider -- for fields
 * like fText/gText/"y' = f(t, y)" where a formula can outgrow the fixed
 * 10em default and the user wants to see the whole thing at once. This is
 * automatic, not opt-in: every expression field already gets .ojs-fill
 * (see above) and no numeric field does (10em is plenty for a guess,
 * endpoint, or seed) -- see the comment on the 10em rule below -- so
 * `form.ojs-fill` containing a text input reliably means "this is an
 * expression field," with no extra class or JS needed at the call site.
 * :has() targets Observable Inputs' own wrapper <div> around the actual
 * <input> (form > div > input), which is what needs `resize` -- browsers
 * don't support resizing an <input> directly, only elements that establish
 * their own scrollable box. Horizontal only: these are single-line fields,
 * so a taller box never helps.
 * Scoped under .ojs-panel with three selector parts (class + element +
 * class) so this reliably beats two same-specificity competitors that
 * would otherwise win purely on load order: Observable Inputs' own
 * dynamically-injected `.oi-xxxxxx-input { width: 100% }` rule (appended to
 * <head> at runtime, after this stylesheet) and this file's own
 * `.ojs-panel input[type="text"] { width: 10em }` default below. */
.ojs-panel form.ojs-fill > div:has(> input[type="text"]) {
  display: inline-block;
  width: 20em;
  min-width: 10em;
  max-width: 100%;
  resize: horizontal;
  overflow: hidden;
}

.ojs-panel form.ojs-fill > div:has(> input[type="text"]) > input[type="text"] {
  width: 100%;
}

/* ...except when the expression field shares a .ojs-row with the page's
 * Plot button, where 20em would strand the button out at the row's right
 * edge with a few hundred pixels of dead panel between the two. Here the
 * field takes whatever width the button leaves instead, so the row reads as
 * one unit and fills edge to edge. The `resize: horizontal` affordance
 * above still applies -- it just starts from the full width rather than
 * from 20em.
 *
 * Four selector parts against the base rule's three, so this wins on
 * specificity wherever both match, independent of source order. */
.ojs-panel .ojs-row form.ojs-fill > div:has(> input[type="text"]) {
  width: 100%;
}

/* Attach to an Inputs.* wrapper div to keep it at its natural width instead
 * of Observable Inputs' 100%-wide default, which would otherwise force the
 * row to wrap. */
.ojs-auto { width: auto !important; flex: none !important; }

/* Attach to an Inputs.* wrapper div to push it to the right edge of its
 * container -- e.g. every page's "Plot" button, so it lands in the same
 * bottom-right corner everywhere regardless of whether it shares a .ojs-row
 * with a growing sibling field or sits alone below a .ojs-grid. A plain
 * block-level div with width:auto (e.g. what .ojs-auto sets) actually
 * stretches to fill its container in normal block flow -- unlike a flex
 * item, "auto" here does NOT mean "shrink to content" -- so there's no
 * free space left for margin-left:auto to consume. Forcing fit-content
 * first is what makes the auto margin actually push it right, in both
 * plain-block (a lone button under a .ojs-grid) and flex (a button beside
 * a growing sibling in a .ojs-row) contexts. */
.ojs-end { width: fit-content !important; margin-left: auto !important; }


@media (max-width: 768px) {
  .ojs-grid { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 480px) {
  .ojs-grid { grid-template-columns: 1fr; }
}

/* ---- src/css/chart-block.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Output area ─────────────────────────────────────────────────── */

/* Let layout-ncol cells shrink below the Plotly chart's intrinsic width */
.quarto-layout-cell {
  min-width: 0;
}

/* 72vh rather than the 78vh this was while the step bar floated over the
 * chart: the bar now sits above it and adds its own ~53px (plus margin), so the
 * chart gives back about what the bar takes and a chart block occupies close
 * to the height it always did. The visible plot area is a wash -- 78vh with
 * a slider bar covering its bottom strip showed about as much curve as 72vh
 * with nothing over it -- but none of it is obscured any more. */
.plotly-box-large {
  width: 100%;
  height: 72vh;
  min-height: 500px;
}

/* Anchor for .ojs-legend-overlay below -- wrap a page's mainPlot cell and
 * its viewof VM.ui.legendOverlay(...) cell together in this so the legend
 * positions itself relative to the plot rather than the page.
 *
 * A column, so the slider bar can be ordered above the chart rather than
 * below it: every other control on the page sits in the .ojs-panel above the
 * chart, so a bar underneath left the reader's eye travelling past the chart
 * and back again. Reordering here rather than in the 14 pages' markup keeps
 * it one edit -- see the order values on the two rules below. */
.ojs-chart-block {
  position: relative;
  display: flex;
  flex-direction: column;
  /* A named query container, so rules about "is this chart wide enough for
   * X" can ask the chart's own width (legend-controls.css hides the floating
   * legend on a narrow chart this way) rather than the viewport's. The two
   * are not the same thing: in an <iframe> the viewport is the frame, and a
   * width="100%" frame in an ordinary content column is 700-800px wide on a
   * desktop with room to spare -- a viewport breakpoint meant for phones read
   * every such embed as a phone. Inline-size only: a size container would
   * need an explicit height, and this block's height comes from its
   * content. The layout containment that comes with it is harmless here --
   * every floating thing inside the block is already positioned against a
   * box inside the block, and the playback popover is top-layer -- and its
   * zero min-content contribution is what already lets a block in a
   * layout-ncol column shrink with the column. Fullscreen's letterbox math
   * queries a separate, unnamed size container further down the chain and
   * is not affected. */
  container: vm-chart / inline-size;
}

/* The Fullscreen API paints a black backdrop behind the fullscreened
 * element; without an explicit background, that shows through anywhere the
 * Plotly chart itself doesn't paint (e.g. around a narrower aspect ratio).
 * plotly-fullscreen-button.js records the chart's on-page width/height
 * ratio into --vm-plot-aspect-ratio right before requesting fullscreen,
 * since the div's normal sizing (width: 100%; height: 72vh) is relative to
 * the *fullscreen* viewport once entered, not its original box, and would
 * otherwise stretch the plot to a different ratio than on the page. The
 * existing per-page ResizeObserver on the graph div (see e.g.
 * apps/newton-method/index.qmd's mainPlot cell) already calls
 * Plotly.Plots.resize whenever this box resizes, so Plotly's own redraw
 * picks up the letterboxed size with no extra JS here. */
.ojs-chart-block:fullscreen {
  background: var(--vm-bg);
  /* The controls bar is a normal-flow sibling above the chart (see
   * .ojs-chart-controls), so fullscreen has to divide the viewport between the
   * two rather than letting the chart claim all of it -- hence a column here
   * and the flex sizing on each part below. */
  display: flex;
  flex-direction: column;
}

/* Quarto/Observable sits several plain wrapper divs between .ojs-chart-block
 * and the actual .js-plotly-plot graph div (.cell > .cell-output-display >
 * #ojs-cell-N > div.observablehq > .js-plotly-plot) -- each one shrinks to
 * the chart's on-page size by default, which would otherwise cap the
 * percentages below at the old (pre-fullscreen) box instead of the
 * fullscreen viewport. `:has()` targets every one of them regardless of
 * how many levels deep or what Quarto/Observable happens to name them,
 * making each fill and center its child in turn -- the chain bottoms out
 * at .js-plotly-plot itself, which doesn't contain a nested
 * .js-plotly-plot so this rule doesn't match it. The legend overlay and
 * the controls bar are unaffected since they're siblings of this chain,
 * not ancestors of the graph div. */
.ojs-chart-block:fullscreen div:has(.js-plotly-plot) {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
}

/* The chain above is `height: 100%` all the way down, which is right for
 * every link except the outermost one: as a flex item of the column above it
 * has to take the height the step bar leaves rather than the container's
 * full height, or the bar is pushed off the bottom of the screen. Same
 * specificity as the rule above (a child combinator adds none), so this has
 * to stay *after* it to win. */
.ojs-chart-block:fullscreen > div:has(.js-plotly-plot) {
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
  /* Makes this the size container the letterbox math below measures against.
   * Its cqh is the height left after the step bar takes its share, which is
   * the number that box actually has to fit the chart into; 100vh is the
   * whole screen and overstates it by the height of the bar, which showed up
   * as a chart stretched wider than its on-page aspect ratio. */
  container-type: size;
}

/* The legend's cell is what its top/left are measured from (see the ordering
 * rules further down), so in fullscreen it has to be the chart's box rather
 * than the screen's -- otherwise the legend hangs in the letterbox margin to
 * the left of a chart it is supposed to be annotating. Same measured width
 * the controls bar uses. */
.ojs-chart-block:fullscreen > div:has(.ojs-legend-overlay) {
  width: var(--vm-fs-plot-width, 100%);
  margin-inline: auto;
}

/* Never shrink the controls to make room for the chart -- the chart is the
 * part with flexible height here.
 *
 * The chart is letterboxed to its on-page aspect ratio and so is narrower
 * than the screen; a full-width bar under it reads as a control strip for
 * something bigger than the chart it drives, and stops matching the
 * chart-width bar the reader saw a moment earlier on the page. Width comes
 * from js/plotting/plotly-fullscreen-button.js, which measures the chart
 * once the browser has laid it out -- the relationship is circular (bar
 * width follows chart width follows leftover height follows bar height
 * follows bar width) so there is no expressing it here. The 100% fallback is
 * what applies if that script hasn't measured yet, i.e. the old behaviour
 * for one frame rather than a collapsed bar. */
.ojs-chart-block:fullscreen > .ojs-chart-controls {
  flex: 0 0 auto;
  width: var(--vm-fs-plot-width, 100%);
  margin-inline: auto;
}

.ojs-chart-block:fullscreen .js-plotly-plot {
  /* Each candidate is "fit to the full height" vs. "fit to the full width"
   * at the recorded ratio; min() picks whichever one doesn't overflow the
   * other axis, which is exactly contain-style letterboxing. */
  width: min(100%, calc(100cqh * var(--vm-plot-aspect-ratio, 1.6)));
  height: min(100%, calc(100cqw / var(--vm-plot-aspect-ratio, 1.6)));
  min-height: 0;
}

/* ── SVG chart blocks ────────────────────────────────────────────── */
/* A block that draws inline SVG (Observable Plot, a hand-built <svg>)
 * instead of a Plotly chart: `<div class="ojs-chart-block ojs-svg-block">`.
 * It has no modebar, so js/plotting/svg-fullscreen-button.js gives it a
 * fullscreen button of its own -- one <button class="vm-fullscreen-toggle">
 * as a direct child of the block, positioned by that script at the figure's
 * top-right corner (top/right are set inline from a measured rect, since
 * the figure is not the block: a fixed-width Plot SVG sits left-aligned in
 * a wider column, and in fullscreen it is letterboxed into the middle).
 * Same pill as the Plotly modebar, and hidden at rest the same way, so
 * nothing sits over the picture until the reader hovers the block. The
 * hover trigger is the block, not the picture's own cell -- the cell is
 * rebuilt on every slider move, and a pill fading in from nothing on each
 * step of a playback sweep flickers. */
.vm-fullscreen-toggle {
  position: absolute;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: color-mix(in srgb, var(--vm-bg) 88%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: var(--vm-shadow);
  color: var(--vm-text-soft);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.2s ease, background-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
}

.ojs-svg-block:hover > .vm-fullscreen-toggle,
.vm-fullscreen-toggle:focus-visible {
  opacity: 1;
}

.vm-fullscreen-toggle:hover {
  background: var(--vm-surface-hover);
  color: var(--vm-text);
}

.vm-fullscreen-toggle:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: -2px;
}

/* A touch device never fires hover, so a hover-only button would be
 * permanently invisible there. */
@media (hover: none) {
  .vm-fullscreen-toggle {
    opacity: 1;
  }
}

/* On the page, a figure is centered in the block. Observable Plot's SVG is
 * a fixed `width` (800 on every Sperner's lemma page) with its own
 * `max-width: 100%; height: auto`, so it already shrinks on a narrow
 * viewport but otherwise sits flush left in the wider content column,
 * looking off-center against the centered controls bar above it. The
 * selector mirrors findFigures in js/plotting/svg-fullscreen-button.js
 * (any SVG not in the controls bar, the legend or a button) rather than
 * keying on the .vm-fs-figure class that script adds, so the figure is
 * centered from its first paint, not from the frame the script gets to it.
 * A full-width SVG (the PPV page's grid) has no side margin to distribute,
 * so this is a no-op there. */
.ojs-svg-block svg:not(.ojs-chart-controls svg, .ojs-legend-overlay svg, button svg) {
  display: block;
  margin-inline: auto;
}

/* Fullscreen: the same letterbox chain as the Plotly block above, keyed on
 * the figure SVG (tagged .vm-fs-figure by the script) instead of the graph
 * div. Every wrapper between the block and the figure fills and centers its
 * child; the outermost takes the height the controls bar leaves and is the
 * size container the figure measures against. A column rather than a row,
 * for a page that captions each figure with a <p> above it (the geometric
 * proof's side-by-side pair): the caption then stays above, and the figure
 * shrinks by the caption's height instead of running off the bottom. */
.ojs-svg-block:fullscreen div:has(.vm-fs-figure) {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  /* Two figures side by side are each `width: 100%` of a row; min-width:
   * auto would refuse to shrink them to share it. */
  min-width: 0;
}

.ojs-svg-block:fullscreen > div:has(.vm-fs-figure) {
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
  container-type: size;
}

/* The ratio comes from the SVG's viewBox (or width/height attributes),
 * written as --vm-svg-aspect by the script; a viewBox'd SVG also centers its
 * own drawing inside whatever box it gets, so a slightly-off ratio costs a
 * sliver of margin, not a distorted picture. max-width/max-height are
 * cleared because Observable Plot sets `max-width: 100%; height: auto` on
 * its own SVG. */
.ojs-svg-block:fullscreen .vm-fs-figure {
  width: min(100%, calc(100cqh * var(--vm-svg-aspect, 1)));
  height: min(100%, calc(100cqw / var(--vm-svg-aspect, 1)));
  max-width: none;
  max-height: none;
  min-height: 0;
  flex: 0 1 auto;
}


@media (max-width: 768px) {
  .plotly-box-large { height: 65vh; min-height: 400px; }
}

/* ---- src/css/swatch.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Prose color swatches ────────────────────────────────────────── */
/* A word in a sentence tinted with the color it names -- "the
 * <span class="vm-swatch vm-swatch-alt">red</span> vertex" -- on the
 * Sperner's lemma pages, where the prose keeps referring to what the
 * diagram draws. The modifier picks a chart-palette token (the same
 * --vm-color-* custom properties VM.plotting.colors() reads, declared in
 * tokens.css), so the swatch is the same hue as the dot or fill it
 * describes and follows a theme toggle with it. A tint with
 * the body text color on top rather than white-on-solid: white on amber
 * is unreadable, and dark mode lightens every hue anyway.
 *
 * One modifier per palette token, plus -accent for the UI accent
 * (--vm-accent, a chrome token, not a chart color -- for a word that
 * refers to a button or a focus ring rather than a trace). No -halo: it
 * is the page background, and a tint of the background is invisible. */
.vm-swatch {
  padding: 0.05em 0.35em;
  border-radius: var(--vm-radius-sm);
  background: color-mix(in srgb, var(--vm-swatch) 40%, var(--vm-bg));
  color: var(--vm-text);
  white-space: nowrap;
}

.vm-swatch-fn      { --vm-swatch: var(--vm-color-fn); }
.vm-swatch-alt     { --vm-swatch: var(--vm-color-alt); }
.vm-swatch-ok      { --vm-swatch: var(--vm-color-ok); }
.vm-swatch-muted   { --vm-swatch: var(--vm-color-muted); }
.vm-swatch-ink     { --vm-swatch: var(--vm-color-ink); }
.vm-swatch-warn    { --vm-swatch: var(--vm-color-warn); }
.vm-swatch-accent2 { --vm-swatch: var(--vm-color-accent2); }
.vm-swatch-accent3 { --vm-swatch: var(--vm-color-accent3); }
.vm-swatch-accent  { --vm-swatch: var(--vm-accent); }


/* ---- src/css/legend-controls.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* Floating legend/checkbox hybrid, positioned atop a Plotly chart by
 * VM.ui.legendOverlay (js/ui/legend-overlay.js) in place of a separate
 * checkbox row -- each item is both this trace's legend entry and its
 * visibility toggle. */
.ojs-legend-overlay {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: color-mix(in srgb, var(--vm-bg) 88%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: var(--vm-shadow);
  font-size: 0.85rem;
  max-width: min(220px, 45vw);
}

.ojs-legend-overlay .ojs-legend-item {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  cursor: pointer;
  user-select: none;
  color: var(--vm-text);
}

.ojs-legend-overlay .ojs-legend-item:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: 2px;
}

.ojs-legend-overlay .ojs-legend-swatch {
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 0.2rem;
  flex: 0 0 auto;
}

.ojs-legend-overlay .ojs-legend-item[aria-checked="false"] {
  color: var(--vm-text-soft);
}

.ojs-legend-overlay .ojs-legend-item[aria-checked="false"] .ojs-legend-swatch {
  background: var(--vm-border) !important;
}

.ojs-legend-overlay .ojs-legend-item[aria-checked="false"] span:last-child {
  text-decoration: line-through;
}

/* Step/parameter slider bar -- move a page's viewof stepControl inside
 * .ojs-chart-block and wrap it in this to sit it directly above the
 * chart, spanning its width. It is a normal-flow block, NOT a floating card:
 * a bar pinned over the chart's bottom edge covers the part of the curve a
 * reader is usually scrubbing toward, and no amount of translucency or
 * collapsing changes the fact that it is in the way. Reserving the space
 * instead means the chart is never obscured at all.
 *
 * Staying inside .ojs-chart-block is still what keeps it visible when the
 * chart goes fullscreen (see plotly-fullscreen-button.js), which is why this
 * is not simply a sibling of that wrapper. It takes .ojs-panel's chrome -- a
 * solid surface rather than the blurred translucent card the legend uses --
 * because there is no longer any curve behind it to show through. */
.ojs-chart-controls {
  /* Ordered ahead of both the chart and the legend cell below, so the page
   * reads controls-panel, sliders, chart from top to bottom. */
  order: -2;
  margin-bottom: 0.5rem;
  padding: 0.4rem 0.75rem;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: var(--vm-surface);
  /* No box-shadow: this bar sits directly under the .ojs-panel above it,
   * which already carries a border of its own -- a second shadowed card
   * right below the first read as two stacked slabs rather than one quiet
   * control strip. */
}

/* The legend is absolutely positioned, and its containing block used to be
 * .ojs-chart-block -- whose top edge was the chart's top edge, so top:
 * 0.75rem put it just inside the chart's corner. With the bar ordered above
 * the chart that is no longer the same point, and the legend would sit on
 * the sliders. Ordering the legend's own cell between the two puts a
 * zero-height box exactly at the chart's top edge, and making it the
 * containing block restores what top/left are measured from -- no JS, and
 * nothing to keep in sync with the bar's height.
 *
 * The overflow reset is not optional: Quarto gives .cell-output-display
 * overflow: auto (see CLAUDE.md), which clips any descendant escaping its
 * box. That does nothing while the legend's containing block is outside
 * that wrapper, but the moment the cell becomes the containing block the
 * wrapper starts clipping the legend to a zero-height box -- i.e. to
 * nothing. Same trap the playback popover hit, solved there with the top
 * layer; here the clip is one known ancestor, so switching it off is
 * narrower than reaching for [popover]. */
.ojs-chart-block > div:has(.ojs-legend-overlay) {
  order: -1;
  position: relative;
  height: 0;
}

.ojs-chart-block > div:has(.ojs-legend-overlay) .cell-output-display,
.ojs-chart-block > div:has(.ojs-legend-overlay) [id^="ojs-cell-"] {
  overflow: visible;
}

/* The legend is the one panel still floating on the chart, and on a narrow
 * chart it has nowhere to go but on top of the curve it annotates -- so it's
 * hidden when the chart is under 600px wide, except when the chart is
 * actually fullscreened. The Fullscreen API applies straight to
 * .ojs-chart-block (see plotly-fullscreen-button.js), so :fullscreen needs
 * no JS-toggled class here, and there's room for it once it is.
 *
 * A container query on the chart block (chart-block.css names it vm-chart),
 * not a viewport breakpoint, because what matters is how much of *the chart*
 * the legend covers, and the viewport is a poor proxy for that: inside an
 * <iframe> the viewport is the frame, and this rule as a 992px media query
 * hid the legend in every embed of ordinary column width on a desktop with
 * room to spare. 600px is where a 220px legend starts covering more than a
 * third of the chart, which is the phone problem again whatever the screen
 * -- a phone's chart is ~360px and stays hidden, a tablet's ~700px now shows
 * it (it is draggable, so it can be moved off the curve).
 *
 * The step bar needs nothing here: it reflows as a plain block above the
 * chart at every width. */
@container vm-chart (max-width: 599.98px) {
  .ojs-chart-block:not(:fullscreen) .ojs-legend-overlay {
    display: none;
  }
}

/* Observable Inputs sizes every range input to a fixed ~180px track of its
 * own, which strands a slider at a fraction of the bar it sits in
 * -- and leaves it that same fixed size when the chart is fullscreened,
 * instead of growing with the bar. The .ojs-panel rule above already gives
 * panel sliders a fluid track; the chart-controls bar needs the same. */
.ojs-chart-controls input[type="range"] {
  accent-color: var(--vm-accent);
  width: 100%;
}

/* Skin every native input inside the bar to match .ojs-panel's chrome --
 * previously only input[type=range] and input[type=text] (below) were
 * covered, so the number readout beside a slider rendered as a bare,
 * square-cornered browser spinner next to a rounded, bordered track. */
.ojs-chart-controls input {
  height: var(--vm-control-h);
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius-sm);
  padding: 0 0.5rem;
  font-size: 0.85rem;
  background: var(--vm-bg);
  color: var(--vm-text);
  box-sizing: border-box;
}

.ojs-chart-controls input:focus {
  outline: none;
  border-color: var(--vm-accent);
  box-shadow: 0 0 0 0.15rem color-mix(in srgb, var(--vm-accent) 25%, transparent);
}

/* ...and with the track now fluid, cap the number readout beside it the way
 * .ojs-panel already does, so the pair splits the bar sensibly instead of
 * the spinner eating a third of it. */
.ojs-chart-controls input[type="number"] {
  width: 5.5em;
  flex: none !important;
}

/* .ojs-panel input[type="text"] (below) caps every text field on the site at
 * 10em -- except this one never got the equivalent, so the one text-type
 * field that lives in the chart-controls bar (every page's "Max"/"Max steps"/"Max
 * depth" field) had nothing capping it. Without a width, it falls through to
 * `width: inherit` from Observable's own wrapper div, which in turn inherits
 * from the form's own natural size -- and since that form is .ojs-auto
 * (width: auto, sized toward Observable's ~360px form max-width rather than
 * shrinking to its actual "20"/"2000" content), the input rendered at 273px:
 * three times the width of the number spinner (94px, capped just above) it
 * sits right next to in the same bar. */
.ojs-chart-controls input[type="text"] {
  width: 10em;
  max-width: 100%;
  flex: none !important;
}

/* Inputs.radio()/checkbox() option groups render one block-level row per
 * option by default (Observable Inputs' own stylesheet, not this project's)
 * -- fine in a roomy .ojs-panel, but a tight chart-controls bar shared with
 * sliders reads better as one horizontal row for a short option list.
 * Observable appends a "-checkbox" suffix to the form's class for both
 * radio and checkbox groups, alongside its hashed "oi-<hash>" prefix (the
 * same hash-prefix convention this file already relies on elsewhere via
 * `form[class^="oi-"]`) -- matching on that stable suffix instead of the
 * hash keeps this working across Observable Inputs versions. */
.ojs-chart-controls form[class$="-checkbox"] > div {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1rem;
}

/* Observable Inputs hardcodes every <label> to width: 120px; flex-shrink: 0
 * (see the .ojs-panel rule above for the full explanation). A step-overlay
 * slider's label is normally short enough ("Step", "n", "α") that this never
 * shows -- but "Tolerance exponent e (ε = 10⁻ᵉ)" (adaptive-methods,
 * adaptive-integration) wraps to 3 lines at 120px, making that field 93px
 * tall against its "Max steps" neighbor's 37px and centering the slider
 * roughly mid-label instead of level with it.
 *
 * Unlike the .ojs-panel fix, this does NOT also switch to flex-direction:
 * column -- .ojs-panel has room to spare and stacking there is the established
 * look, but the chart-controls bar is deliberately compact with the label beside the
 * control (see every other page's Step/Max), and column-stacking here would
 * grow that bar's height on every page, not just this one. Letting the label
 * size to its content on a single line is enough: short labels were already
 * narrower than 120px so this is a no-op for them, and the long one no longer
 * wraps.
 *
 * :not([class$="-checkbox"]) excludes Inputs.radio()/checkbox() groups (e.g.
 * k-means' "Algorithm" picker), which already get their own row-wrap
 * treatment immediately above and would otherwise also match
 * `form[class^="oi-"]`.
 *
 * No `white-space: nowrap` here (an earlier version of this rule had it,
 * to force the long label onto one line): at 390px this bar has nowhere
 * near enough room for an un-wrapping ~280px label, and since a flex item's
 * default min-width is its content's min-content size, nowrap text can't
 * shrink to fit -- it just overflows the viewport instead of wrapping,
 * scrollWidth measured 360px past the viewport. Left free to wrap, the label
 * shrinks back to multiple lines exactly like it did before this rule, but
 * `.ojs-row`'s own `flex-wrap: wrap` has already dropped this field onto its
 * own line below "Max steps" by the width this matters at, so the two no
 * longer need to share a row's height in the first place. */
.ojs-chart-controls form[class^="oi-"]:not([class$="-checkbox"]) label {
  width: auto;
  padding: 0;
  margin: 0;
  font-size: var(--vm-label-size);
  line-height: 1.25;
  font-weight: 500;
  color: var(--vm-text-soft);
  /* Observable Inputs injects `align-self: start` on every label. In this
   * bar the form is a flex ROW (label beside the control), so that pins the
   * label to the top of a ~30px line while the track, the number readout and
   * the play button all sit centered in it -- the label rode about 6px high.
   * It was invisible until the padding above was zeroed: Observable's own
   * `padding: 5px 0 4px` had bulked the label box out to nearly the row's
   * full height, so there was no slack for start-alignment to show.
   *
   * Deliberately NOT applied to .ojs-panel's labels: those forms are a flex
   * COLUMN, where the cross axis is horizontal and `start` is what keeps a
   * label left-aligned over its field. Centering there would center every
   * label above its own control.
   *
   * Inert in the .ojs-chart-controls.ojs-grid case below, where the label
   * takes `flex: 0 0 100%` and so occupies a flex line of its own whose
   * cross size is just the label's height. */
  align-self: center;
}

/* The [class$="-checkbox"] groups (Inputs.radio()/checkbox(), e.g. the
 * Positive Predictive Value page's Shuffled/Sorted toggle) are excluded from
 * the selector above because they get their own row-wrap treatment, but they
 * still need the same typography -- without this their label kept the
 * inherited 17px body-prose size while every other control in the bar had
 * already shrunk. */
.ojs-chart-controls form[class$="-checkbox"] > label {
  padding: 0;
  margin: 0;
  font-size: var(--vm-label-size);
  line-height: 1.25;
  font-weight: 500;
  color: var(--vm-text-soft);
}

/* A multi-slider chart-controls bar (e.g. recursive-filters-1d' four filter
 * parameters) uses .ojs-chart-controls.ojs-grid instead of the plain bar --
 * see the .ojs-grid rule above for the auto-fit track. Inside a single-line
 * step bar the label-beside-control layout above is right: there's exactly
 * one control and plenty of width. Inside a grid cell it isn't: each
 * ~250px-wide cell still packed a label (four different widths across the
 * four sliders), a track, and a number readout into one flex row, leaving
 * the track itself only a few dozen pixels wide with nothing lining up
 * between cells. Stacking label above control, the same fix the .ojs-panel
 * rule already uses, hands the whole cell width to the track; .ojs-grid's
 * own `align-items: end` then bottom-aligns every control across the row
 * exactly as it does in a panel. */
/* Done by giving the label a full-width flex basis rather than by switching
 * the form to flex-direction: column -- the same mechanism .ojs-panel uses
 * for its own play-button forms (see .ojs-panel .vm-has-play > label below).
 * Column-stacking would put the play button on a line of its own between the
 * label and the track, since it lays out all three children vertically; a
 * wrapped row instead breaks only after the label, leaving play button,
 * readout and track together on the second line. */
.ojs-chart-controls.ojs-grid form[class^="oi-"].vm-has-play {
  /* Restated here purely to win on specificity. `.vm-has-play` already asks
   * for wrap, but that is a lone class (0,1,0) and Observable Inputs injects
   * its own `.oi-<hash>` rule into <head> at runtime -- same specificity,
   * later in the cascade, so it wins and the row computes to nowrap. With
   * nowrap the full-width label below simply consumes the line and squeezes
   * the track to zero instead of pushing it onto a second row. (This is the
   * same runtime-injection trap the .ojs-fill text-field rule documents.) */
  flex-wrap: wrap;
}

.ojs-chart-controls.ojs-grid form[class^="oi-"] label {
  width: auto;
  flex: 0 0 100%;
}

/* Raised from the site-wide .ojs-grid minimum (250px) for this case only: at
 * 250px a typical chart width fits four cells per row, which is exactly the
 * cramped layout being fixed above. A wider floor lays the four out as a
 * clean 2x2 with room for the play button next to each track. */
.ojs-chart-controls.ojs-grid {
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
}

/* ── Draggable legend panel ──────────────────────────────────────── */
/* The legend floats over the chart, pinned to a corner by default, which is
 * sometimes exactly where the interesting part of the curve is. A grip fades
 * in at its right edge; js/ui/draggable-overlay.js does the actual dragging
 * (and remembers where it was put), this is only its chrome. The step bar
 * above the chart is not draggable -- it covers nothing, so there is nowhere
 * to drag it to. */

/* The gutter the grip sits in, so it can never overlap a legend label. The
 * step bar is in normal flow and has nothing to be dragged clear of, so it
 * gets no grip and needs no gutter -- 1.5rem of dead space at the end of a
 * full-width slider bar is worth reclaiming. */
.ojs-legend-overlay {
  padding-right: 1.5rem;
}

.vm-overlay-grip {
  position: absolute;
  right: 0.1rem;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.15rem;
  border: 0;
  border-radius: var(--vm-radius-sm);
  background: none;
  color: var(--vm-text-soft);
  cursor: grab;
  opacity: 0;
  transition: opacity 0.15s ease;
  /* Without this, a touch drag scrolls the page instead of moving the panel. */
  touch-action: none;
  /* Stops a press-and-drag from selecting the panel's text. The JS can't do
   * this by cancelling pointerdown, which would also suppress the synthesized
   * click/dblclick and break double-click-to-reset. */
  user-select: none;
  -webkit-user-select: none;
}

/* Revealed on hover, matching the modebar pill below rather than adding a
 * third piece of always-on chrome to a chart. */
.ojs-legend-overlay:hover .vm-overlay-grip,
.vm-overlay-grip:focus-visible {
  opacity: 1;
}

.vm-overlay-grip:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: 1px;
}

.vm-overlay-grip:active,
.vm-overlay-dragging .vm-overlay-grip {
  cursor: grabbing;
}

/* A touch device never fires hover, so a hover-only affordance would be
 * permanently invisible there. */
@media (hover: none) {
  .vm-overlay-grip {
    opacity: 1;
  }
}


/* ---- src/css/modebar.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Plotly modebar ──────────────────────────────────────────────── */
/* Plotly ships its modebar as a bare row of gray icon buttons with no
 * container chrome -- stock #999 icons on a transparent strip, which reads
 * as a foreign toolbar dropped onto the page (and as a light-gray bar on
 * the dark theme). Plotly already fades it in on hover by itself, via the
 * .modebar--hover class it sets; these rules only change how it looks, not
 * when it appears. The icon colors themselves come from layout.modebar in
 * js/plotting/chart-theme.js -- only Plotly can set those, since it paints
 * the SVG fills inline. Everything here is the surrounding chrome, matching
 * .ojs-legend-overlay -- the other thing that floats over the chart, and so
 * the other thing wearing a blurred translucent card. (.ojs-chart-controls
 * is in normal flow above the chart and takes .ojs-panel's solid surface
 * instead; it is not what this is matching.) */

.js-plotly-plot .modebar {
  display: flex;
  align-items: center;
  padding: 0.15rem 0.2rem;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: color-mix(in srgb, var(--vm-bg) 88%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: var(--vm-shadow);
}

/* Plotly's own .modebar--hover fades the BUTTONS in on hover but leaves the
 * bar itself always present -- which, once the bar has chrome of its own,
 * shows as an empty floating pill over the chart at rest. Fade the whole
 * pill on the same trigger and timing instead, so at rest there is nothing
 * but the chart. */
.js-plotly-plot .modebar--hover {
  opacity: 0;
  transition: opacity 0.2s ease;
}

.js-plotly-plot:hover .modebar--hover,
.js-plotly-plot .modebar--hover:focus-within {
  opacity: 1;
}

/* Plotly splits the buttons into four .modebar-group blocks (see the
 * modeBarButtons list in js/plotting/plotly-fullscreen-button.js) and
 * spaces each group out; collapse that into one continuous bar so the pill
 * reads as a single control rather than four floating clusters. */
.js-plotly-plot .modebar-group {
  display: flex;
  align-items: center;
  margin: 0 !important;
  padding: 0 !important;
  background: none !important;
}

.js-plotly-plot .modebar-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  border-radius: var(--vm-radius-sm);
  transition: background-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
}

.js-plotly-plot .modebar-btn:hover {
  background: var(--vm-surface-hover);
}

.js-plotly-plot .modebar-btn:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: -2px;
}


/* ---- src/css/sliders.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Range sliders ───────────────────────────────────────────────── */
/* Every other control in a panel is fully styled (border, radius, focus
 * ring); the sliders were the one holdout, left at browser-default geometry
 * with only accent-color set. --sx is the filled fraction of the track,
 * written by js/ui/range-progress.js on every input event -- WebKit has no
 * native equivalent of Firefox's ::-moz-range-progress, so the fill has to
 * be painted as a gradient on the track itself. */

.ojs-panel input[type="range"],
.ojs-chart-controls input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 1.25rem;
  border: none;
  padding: 0;
  background: none;
  cursor: pointer;
}

.ojs-panel input[type="range"]::-webkit-slider-runnable-track,
.ojs-chart-controls input[type="range"]::-webkit-slider-runnable-track {
  height: 0.3rem;
  border-radius: 999px;
  background:
    linear-gradient(var(--vm-accent), var(--vm-accent)) 0 / var(--sx, 0%) 100% no-repeat,
    var(--vm-border);
}

.ojs-panel input[type="range"]::-webkit-slider-thumb,
.ojs-chart-controls input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 0.95rem;
  height: 0.95rem;
  /* centers the thumb on a 0.3rem track: (track - thumb) / 2 */
  margin-top: -0.325rem;
  border: none;
  border-radius: 50%;
  background: var(--vm-accent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--vm-bg) 70%, transparent);
  transition: transform 80ms cubic-bezier(0.33, 1, 0.68, 1);
}

.ojs-panel input[type="range"]:active::-webkit-slider-thumb,
.ojs-chart-controls input[type="range"]:active::-webkit-slider-thumb {
  transform: scale(1.15);
}

/* Firefox paints the filled portion itself via ::-moz-range-progress, so it
 * needs no --sx and gets a plain track underneath. */
.ojs-panel input[type="range"]::-moz-range-track,
.ojs-chart-controls input[type="range"]::-moz-range-track {
  height: 0.3rem;
  border-radius: 999px;
  background: var(--vm-border);
}

.ojs-panel input[type="range"]::-moz-range-progress,
.ojs-chart-controls input[type="range"]::-moz-range-progress {
  height: 0.3rem;
  border-radius: 999px;
  background: var(--vm-accent);
}

.ojs-panel input[type="range"]::-moz-range-thumb,
.ojs-chart-controls input[type="range"]::-moz-range-thumb {
  width: 0.95rem;
  height: 0.95rem;
  border: none;
  border-radius: 50%;
  background: var(--vm-accent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--vm-bg) 70%, transparent);
}

.ojs-panel input[type="range"]:focus-visible,
.ojs-chart-controls input[type="range"]:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: 2px;
}

/* ── Slider playback button ──────────────────────────────────────── */
/* js/ui/slider-play.js gives every slider a play/pause button plus a small
 * caret that opens a speed/mode popover, wrapped together in
 * <span class="vm-play"> and inserted immediately before the range track
 * inside Observable Inputs' own <form>.
 *
 * Laid out as a wrapping flex row so it works with both form layouts the
 * site already has: in a .ojs-chart-controls the label stays beside the
 * control (see the chart-controls label rule above) and everything sits on one
 * line, while in a .ojs-panel the label is given a full-width basis below
 * so it keeps the stacked look the panel rule established.
 *
 * Repeated at the panel rule's own specificity, because that rule
 * (`.ojs-panel form[class^="oi-"]`, a class plus an attribute selector) sets
 * flex-direction: column and outranks a lone class -- which left the button
 * stacked on a line of its own between the label and the track. */
.vm-has-play,
.ojs-panel form[class^="oi-"].vm-has-play {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  column-gap: 0.4rem;
}

.ojs-panel .vm-has-play > label {
  flex: 0 0 100%;
}

/* Whatever holds the track -- Observable Inputs puts the range and its
 * number readout either directly in the form or inside a wrapper div,
 * depending on version -- takes the leftover width, so the button never
 * shortens the track by its own size. The number readout opts back out
 * via its own `flex: none !important` rules above. */
/* flex-basis 0, not auto: the track inside carries `width: 100%`, so sizing
 * this off its content asks for a full line's width and wraps the button
 * group -- a couple of dozen pixels wide -- onto a line of its own above it.
 * From zero, flex-grow hands it every pixel the group doesn't use. */
.vm-has-play > *:not(label):not(.vm-play) {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
}

/* The play button and its speed/mode caret, grouped so the flex rules above
 * treat the pair as one unit. The popover itself is not in here -- see the
 * .vm-play-panel note below for why it is mounted at the top level. */
.vm-play {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  gap: 0.1rem;
}

/* A quiet icon button, like .vm-overlay-grip rather than the filled accent
 * button Observable Inputs' own <button> gets -- it sits inside a control,
 * not at the end of a form, and a filled button there would read as the
 * page's primary action. The !important overrides are aimed at the
 * `.ojs-panel form[class^="oi-"] button` skin above, which is written for
 * the Plot/Regenerate button and would otherwise paint this solid accent. */
.vm-play-button,
.vm-play-more {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.2rem !important;
  border: 0 !important;
  border-radius: 999px;
  color: var(--vm-text-soft) !important;
  background: none !important;
  cursor: pointer;
  transition: color 80ms cubic-bezier(0.33, 1, 0.68, 1), background-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
}

.vm-play-button {
  width: 1.5rem;
  height: 1.5rem;
}

.vm-play-more {
  width: 1rem;
  /* Narrower hit target than the play button on purpose -- it's the
   * secondary action of the pair -- but never below the ~24px a touch target
   * should be, via the padding above. */
  opacity: 0.7;
}

.vm-play-button:hover,
.vm-play-more:hover {
  background: var(--vm-surface-hover) !important;
  color: var(--vm-text) !important;
}

.vm-play-button.vm-playing {
  color: var(--vm-accent) !important;
}

/* The speed/mode popover -- same floating-panel chrome as .ojs-legend-overlay
 * (border, blur, shadow), since both are "a small panel of choices floating
 * over a chart."
 *
 * This is a native popover ([popover] in js/ui/slider-play.js), so it renders
 * in the TOP LAYER. That is load-bearing, not a stylistic choice: Quarto puts
 * `overflow: auto` on `.cell-output-display`, the wrapper around every OJS
 * cell output, which makes it a scroll container -- and a scroll container
 * clips any descendant that escapes its box. This panel opens well above its
 * own cell, so as an ordinary absolutely-positioned child it was clipped to
 * near-invisibility no matter what z-index it carried. The top layer is
 * outside every ancestor's clip, so it is also immune to the same trap being
 * reintroduced later via `overflow`, `transform`, `filter` or `contain` on
 * any wrapper between here and the page.
 *
 * Position is set from script (slider-play.js) because CSS anchor positioning
 * is not cross-browser yet; `inset: auto` and `margin: 0` undo the UA's
 * default centering of a top-layer box so those coordinates apply. */
.vm-play-panel {
  position: fixed;
  inset: auto;
  margin: 0;
  /* Deliberately no `display` here -- the UA hides a closed popover with
   * `display: none`, and an author `display` on the base rule would beat it
   * and leave the panel permanently visible. It belongs on :popover-open. */
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--vm-border);
  border-radius: var(--vm-radius);
  background: color-mix(in srgb, var(--vm-bg) 94%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  box-shadow: var(--vm-shadow);
  color: var(--vm-text);
  font-size: 0.8rem;
  white-space: nowrap;
}

.vm-play-panel:popover-open {
  display: flex;
}

.vm-play-group {
  color: var(--vm-text-soft);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.vm-play-pills {
  display: flex;
  gap: 0.25rem;
}

.vm-play-pill {
  border: 1px solid var(--vm-border);
  border-radius: 999px;
  padding: 0.1rem 0.5rem;
  font-size: 0.8rem;
  color: var(--vm-text);
  background: var(--vm-bg);
  cursor: pointer;
  transition: background-color 80ms cubic-bezier(0.33, 1, 0.68, 1), border-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
}

.vm-play-pill:hover {
  border-color: var(--vm-accent);
}

.vm-play-pill-on {
  border-color: var(--vm-accent);
  background: var(--vm-accent);
  color: var(--vm-on-accent);
}

/* The play button, its popover's disclosure arrow, and the speed pills
 * inside that popover are one control family, so they share one focus ring
 * rather than three copies of it that can drift. */
.vm-play-button:focus-visible,
.vm-play-more:focus-visible,
.vm-play-pill:focus-visible {
  outline: 2px solid var(--vm-accent);
  outline-offset: 1px;
}

/* ---- src/css/table.css ---- */
/*
 * Copyright (c) 2026 Apurva Nakade. All rights reserved.
 * Released under Apache 2.0 license as described in the file LICENSE.
 * Authors: Apurva Nakade
 */

/* ── Iteration table ─────────────────────────────────────────────── */

.ojs-table-container {
  overflow-x: auto;
}

.ojs-table-toolbar {
  display: flex;
  justify-content: flex-end;
  margin-bottom: 0.5rem;
}


/* ── Invalid input ───────────────────────────────────────────────── */
/* Promoted from the page-local .bt-invalid in
 * apps/butcher-tableau/index.qmd, which was the only
 * error affordance anywhere on the site. Pairs with the in-chart empty
 * state (VM.plotting.emptyState) so a formula that won't parse is flagged
 * both at the field and on the chart. */

.ojs-panel input.vm-invalid,
input.vm-invalid {
  border-color: var(--vm-color-alt) !important;
  background: color-mix(in srgb, var(--vm-color-alt) 7%, transparent);
}

.ojs-panel input.vm-invalid:focus,
input.vm-invalid:focus {
  box-shadow: 0 0 0 0.15rem color-mix(in srgb, var(--vm-color-alt) 25%, transparent);
}

