/* ═══════════════════════════════════════════════════════════
   Robot URDF Gallery Online
   Palette and design language follow imchong.github.io:
   Notion-inspired warm neutrals, 8px radius, pill tags,
   dark by default with a light theme via [data-theme='light'].
   ═══════════════════════════════════════════════════════════ */

:root {
  color-scheme: dark;

  --bg: #191919;
  --bg-alt: #222222;
  --surface: #2f2f2f;
  --border: #3d3d3d;
  --border-soft: #333333;
  --text: #e8e8e4;
  --text-muted: #9b9a97;
  --text-faint: #7d7c79;
  --accent: #5b9cf6;
  --accent-hover: #7ab3ff;
  --accent-soft: rgba(91, 156, 246, 0.14);
  --accent-line: rgba(91, 156, 246, 0.34);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
  --shadow-lg: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
  /* A scrollbar inside a card. It reads against --surface rather than against
     the page, and sits a shade above a border: a bar the width of a hairline
     still has to be something to aim at. */
  --scroll-thumb: #4f4f4c;
  --scroll-thumb-hover: #6b6a67;
  /* The shade at the edge a scrolling box continues past. */
  --scroll-edge: rgba(0, 0, 0, 0.22);

  /* The studio: the one colour the WebGL scene clears to, so everything the
     stage paints around the render is the same colour as the render's own
     backdrop. Fullscreen is where that matters — the stage covers the window
     and the canvas does not, so the tree column and the toolbar sit on this
     rather than on a shade of their own. It has to stay equal to
     THEMES.dark.background in js/viewer.js: a gradient, or any other value,
     would draw a seam along the edge of the canvas.

     The card wells are a different problem and keep their own gradient: the
     thumbnails are rendered once against transparency, so the same image has
     to sit on both themes' wells. */
  --studio: #0e1116;
  --card-stage-top: #262a30;
  --card-stage-bottom: #191b1f;
  /* The category pill floats over the card well, so it reads against the
     thumbnail rather than the card. */
  --card-cat-bg: rgba(20, 20, 20, 0.66);
  --card-cat-text: #d8d8d4;
  /* Chrome that floats over the stage: it has to read against the render, not
     against the page, so it gets its own set rather than reusing --surface. */
  --stage-chrome-bg: rgba(25, 25, 25, 0.82);
  --stage-chrome-border: #3d3d3d;
  --stage-chrome-text: #b6b5b1;
  --stage-chrome-text-strong: #f0efec;
  --stage-chrome-hover: #333333;
  --stage-veil: rgba(25, 25, 25, 0.76);
  --stage-veil-text: #cfcec9;
  --stage-error-text: #ff9d9d;
  --stage-faint: #8f8e8a;
  --stage-track: #3d3d3d;
  --code-bg: #202020;

  --warn: #ffc857;
  /* The joint tour wears the warn hue rather than the accent: while it runs,
     the row it is on and the row the reader has pinned can both be on screen,
     and two shades of one colour would read as one state told twice. `--warn`
     is amber, which carries on the dark chrome the panel floats over but not on
     the light one — so the ink is a token of its own, and the tint under it is
     the same hue at a sixth of its weight. */
  --warn-ink: #ffc857;
  --warn-soft: rgba(255, 200, 87, 0.16);
  --radius: 8px;
  --radius-sm: 6px;
  /* What the fixed bar occupies: .header-inner is min-height 56px — padding
     included, since the reset makes everything border-box — plus its 1px
     bottom border. The views keep exactly this much clear beneath it, so the
     sticky filter bar meets the header with no gap for cards to show through. */
  --header-h: 57px;
  --header-bg: #222222;
  --header-border: #3d3d3d;
  /* Header, page and footer all share one measure, so the three stay aligned. */
  --content-w: 1320px;
  --font-sans:
    -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans SC',
    'PingFang SC', 'Microsoft YaHei', sans-serif;
  --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
  --transition: 0.2s ease;
}

[data-theme='light'] {
  color-scheme: light;

  --bg: #ffffff;
  --bg-alt: #f7f6f3;
  --surface: #ffffff;
  --border: #e8e8e4;
  --border-soft: #efeeea;
  --text: #37352f;
  --text-muted: #787774;
  --text-faint: #94938f;
  --accent: #2d74da;
  --accent-hover: #1a5bb8;
  --accent-soft: rgba(45, 116, 218, 0.09);
  --accent-line: rgba(45, 116, 218, 0.26);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 12px 28px -14px rgba(0, 0, 0, 0.22);
  --scroll-thumb: #d4d3ce;
  --scroll-thumb-hover: #b3b2ad;
  /* The same shade at a quarter of the weight: on white, the dark theme's
     would read as a smudge rather than as an edge. */
  --scroll-edge: rgba(0, 0, 0, 0.09);
  --code-bg: #f7f6f3;
  --header-bg: #fafafa;
  --header-border: #e8e8e4;

  /* Daylight studio. Kept a touch cooler and darker than the page so the stage
     still reads as a separate surface, and so a white robot has something to
     sit against — and equal to THEMES.light.background in js/viewer.js, for
     the reason the dark studio above gives. */
  --studio: #eceef2;
  --stage-chrome-bg: rgba(255, 255, 255, 0.86);
  --stage-chrome-border: #d9d8d3;
  --stage-chrome-text: #5f5e5b;
  --stage-chrome-text-strong: #37352f;
  --stage-chrome-hover: #ebeae7;
  --stage-veil: rgba(247, 246, 243, 0.84);
  --stage-veil-text: #4b4a47;
  --stage-error-text: #b3261e;
  --stage-faint: #787774;
  --stage-track: #e0dfda;
  /* Barely tinted: the cards read as white wells beside the white card body,
     with just enough of a floor at the bottom of the gradient to keep a pale
     robot from melting into the page. */
  --card-stage-top: #ffffff;
  --card-stage-bottom: #eceef2;
  --card-cat-bg: rgba(255, 255, 255, 0.78);
  --card-cat-text: #4b4a47;
  /* Amber on a white panel is a highlighter, not a word; the same hue burnt
     down to something that can be read as text. */
  --warn-ink: #9a5b00;
  --warn-soft: rgba(154, 91, 0, 0.11);
}

/* Same reset as the reference site: zeroing margins matters here because
 * <figure> and <dl> carry generous user-agent margins that would otherwise
 * inset every card thumbnail. */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/* Several elements below set `display`, which would otherwise beat the user
 * agent's `[hidden] { display: none }` and leave them permanently visible. */
[hidden] { display: none !important; }

html, body { margin: 0; }

body {
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  line-height: 1.7;
  -webkit-font-smoothing: antialiased;
  transition: background var(--transition), color var(--transition);
}

a { color: var(--accent); text-decoration: none; transition: color var(--transition); }
a:hover { color: var(--accent-hover); }
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
.muted { color: var(--text-muted); }

/* Inline SVG icons, painted from js/icons.js. Stroked rather than filled so
 * the whole set carries one weight, and drawn in `currentColor` so each icon
 * picks up the hover and pressed states of whatever holds it. Sized here
 * rather than in `em`, so a 12px button label does not shrink the mark with
 * it. */
.icon {
  display: block;
  width: 16px; height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ── Header ─────────────────────────────────────────────────
   Structure and controls follow Humanoid_Robot_Learning_Paper_Notebooks:
   a fixed bar whose inner row is centred on the content measure, the site
   title bilingual on the left, and a right-hand group of a plain-text
   GitHub link plus 30px bordered toggles. The palette stays this site's. */

.site-header {
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  z-index: 1200;
  background: var(--header-bg);
  border-bottom: 1px solid var(--header-border);
  backdrop-filter: blur(8px);
  padding-top: env(safe-area-inset-top, 0px);
  transition: background var(--transition), border-color var(--transition);
}

.header-inner {
  max-width: var(--content-w);
  margin: 0 auto;
  padding: 8px clamp(16px, 4vw, 40px);
  min-height: 56px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
}

.site-title {
  min-width: 0;
  font-size: 1.15rem;
  font-weight: 700;
  color: var(--text);
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.site-title:hover { color: var(--accent); }

/* Never shrinks: the title beside it is the flexible half of the row. */
.header-right { display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }

/* The search field is this site's own addition; it borrows the toggles'
 * height and radius so the row still reads as one set of controls. */
.search {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 0 10px;
  height: 30px;
  transition: border-color var(--transition), box-shadow var(--transition);
}
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search svg { width: 14px; height: 14px; fill: none; stroke: var(--text-faint); stroke-width: 1.9; flex-shrink: 0; }
.search input {
  background: none; border: 0; outline: 0; color: var(--text); min-width: 0;
  width: clamp(110px, 18vw, 220px); font: inherit; font-size: 0.85rem;
}

.github-link {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 30px;
  padding: 0 2px;
  font-size: 0.85rem;
  color: var(--text-muted);
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--transition), border-color var(--transition);
}
.github-link:hover { color: var(--accent); }
/* Filled, unlike the stroked set in `.icon`: the GitHub mark is a solid glyph
 * and only reads as itself that way. It waits here for the phone width. */
.github-mark { display: none; width: 16px; height: 16px; fill: currentColor; }

.lang-toggle, .theme-toggle {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 36px;
  height: 30px;
  padding: 0 0.6rem;
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  user-select: none;
  transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.lang-toggle { font-size: 0.78rem; font-weight: 700; }
.lang-toggle:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.theme-toggle { font-size: 0.85rem; }
.theme-toggle:hover { border-color: var(--accent); }

.ghost-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  height: 34px; padding: 0 13px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg-alt); color: var(--text-muted);
  font: inherit; font-size: 13px; text-decoration: none; cursor: pointer;
  transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.ghost-btn:hover { color: var(--text); border-color: var(--text-faint); }

/* ── Hero ───────────────────────────────────────────────── */

/* The header is fixed, so every view opens below its full height. */
.view {
  padding: calc(var(--header-h) + env(safe-area-inset-top, 0px)) clamp(16px, 4vw, 40px) 40px;
  max-width: var(--content-w);
  margin: 0 auto;
}

.hero { padding: clamp(32px, 5vw, 60px) 0 22px; max-width: 760px; }
.hero h1 { font-size: clamp(28px, 4.4vw, 40px); line-height: 1.15; }
/* The lede, and only the lede: as `.hero p` this outranked every rule below
 * that styles a paragraph further down the hero by class alone, so the note
 * and the link under the button were both wearing the lede's size and its
 * 14px top margin — and inside a centred flex row that margin is centred
 * with the box, which dropped the link half a margin below the button's
 * middle. The child combinator keeps it to the paragraph it was written for. */
.hero > p { color: var(--text-muted); font-size: clamp(14px, 1.3vw, 15.5px); margin: 14px 0 0; }

/* The counters are a caption for the lede, not a dashboard: on one baseline
 * they read as a sentence — "75 robots, 33 makers" — and the hero no longer
 * spends a band of empty space on two stacked numbers. `order` puts the count
 * first without disturbing the dt-then-dd order of the markup. */
.hero-stats {
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: 4px 15px;
  margin: 20px 0 0;
}
.hero-stats > div { display: flex; align-items: baseline; gap: 7px; }
/* A hairline between neighbours, so the row is one line rather than four. */
.hero-stats > div + div { padding-left: 15px; border-left: 1px solid var(--border); }
.hero-stats dt { order: 2; color: var(--text-faint); font-size: 12.5px; }
.hero-stats dd {
  order: 1; margin: 0;
  font-size: 19px; font-variant-numeric: tabular-nums; font-weight: 700; line-height: 1.4;
}

/* The one call to action on the page. It is filled rather than outlined —
 * everything else in the hero is text — because it is the only thing here that
 * does something instead of going somewhere. */
.primary-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  height: 38px; padding: 0 18px;
  border: 1px solid var(--accent); border-radius: var(--radius);
  background: var(--accent); color: #fff;
  font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
  transition: background var(--transition), border-color var(--transition), opacity var(--transition);
}
.primary-btn:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.primary-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.upload-mark {
  width: 17px; height: 17px; flex: 0 0 auto;
  fill: none; stroke: currentColor; stroke-width: 1.6;
  stroke-linecap: round; stroke-linejoin: round;
}

/* Two actions on one row, each over one line of fine print. Stacked, as two
 * blocks of button-plus-sentence, they cost a third of the first screen and
 * read as a list of features; side by side they read as the two doors they
 * are, and the grid ends a good deal higher up the page.
 *
 * Columns as wide as their content and no wider: the notes are one line each,
 * so equal columns would only pad the shorter one out to a rule that is not
 * there — and the second door still starts at a predictable place, because the
 * first column is as wide as its own longest line. The row wraps rather than
 * overflows if a translation ever makes both columns too wide for the hero. */
.hero-actions {
  display: flex; flex-wrap: wrap;
  gap: 16px 36px;
  margin: 26px 0 0;
}
.hero-action { display: flex; flex-direction: column; align-items: flex-start; gap: 9px; }
/* The promise, and what a comparison is: the smallest type on the page but
 * never behind a click — the visitor decides while reading it. One line each
 * in both languages at this size, so no measure of its own; `align-items:
 * flex-start` on the column already keeps every note to its own content. */
.hero-action-note {
  display: flex; gap: 6px; align-items: baseline;
  margin: 0;
  color: var(--text-faint); font-size: 12px; line-height: 1.55;
}
/* The way back to the last file previewed: a second door to the same dialog,
 * so it takes the line straight under the button rather than sitting after the
 * promise — the two doors stay one block, and the fine print under them reads
 * as covering both. A filename is not a heading; it stays the small type the
 * link around it is. */
.hero-action-current { margin: 0; font-size: 12.5px; }
.hero-action-current a { color: var(--accent); text-decoration: none; }
.hero-action-current a:hover { text-decoration: underline; }

/* A fingertip, not a cursor. These two are the only things in the hero that
 * are aimed at rather than read, and where the pointer aiming at them is a
 * thumb, 38px is a comfortable click and a near miss — so both doors take the
 * 44px a touch target is measured against. By the pointer rather than by the
 * width of the screen: a tablet reading the two-column layout is still being
 * tapped at. */
@media (pointer: coarse) {
  .hero-action .primary-btn, .hero-action .hero-compare-btn { height: 44px; }
}

/* ── Filters ────────────────────────────────────────────── */

.filters {
  position: sticky;
  top: calc(var(--header-h) + env(safe-area-inset-top, 0px));
  z-index: 20;
  display: flex; flex-wrap: wrap; gap: 8px;
  padding: 14px 0;
  background: var(--bg);
}

.filters button {
  display: inline-flex; align-items: center; gap: 7px;
  border: 1px solid var(--border); background: var(--bg-alt); color: var(--text);
  border-radius: 20px; padding: 4px 13px; font: inherit; font-size: 13.5px; cursor: pointer;
  transition: background var(--transition), border-color var(--transition), color var(--transition);
}
.filters button:hover { border-color: var(--text-faint); }
.filters button[aria-current='true'] {
  background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600;
}
.filters button .count { font-family: var(--font-mono); font-size: 11px; opacity: 0.65; }

/* ── Category sections ──────────────────────────────────── */

/* The gallery is read top to bottom — humanoids, quadrupeds, arms — and the
 * chips above scroll between these. `scroll-margin-top` is for the anchor
 * navigation a browser does on its own; when js/gallery.js scrolls it measures
 * the bar instead, which wraps to a second row at middling widths. */
.sections { display: flex; flex-direction: column; gap: 34px; }

.cat-section { scroll-margin-top: calc(var(--header-h) + 62px); }

.cat-heading {
  display: flex; align-items: baseline; gap: 9px;
  font-size: 17px; line-height: 1.3;
  padding-bottom: 9px; margin-bottom: 15px;
  border-bottom: 1px solid var(--border-soft);
}
.cat-heading .count {
  font-family: var(--font-mono); font-size: 11.5px; font-weight: 400; color: var(--text-faint);
}

/* Every robot kind is split once more by maker. First-seen order preserves the
 * registry's editorial order, while robots from the same maker stay together
 * even when their upstream entries were added at different times.
 *
 * Short groups share a row: each occupies one parent column per robot, capped
 * at the number of columns currently available. js/gallery.js writes
 * --maker-span; grid auto-placement moves a group that will not fit in the
 * remaining columns to the next row as a whole. */
.maker-groups {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(224px, 1fr));
  gap: 24px 16px;
  align-items: start;
}
.maker-group {
  grid-column: span var(--maker-span, 1);
  min-width: 0;
}
.maker-heading {
  display: flex; align-items: baseline; gap: 8px;
  margin: 0 0 10px;
  color: var(--text-muted); font-size: 13px; line-height: 1.35; font-weight: 650;
}
.maker-heading .count {
  font-family: var(--font-mono); font-size: 10.5px; font-weight: 400; color: var(--text-faint);
}

/* ── Card grid ──────────────────────────────────────────── */

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(224px, 1fr));
  gap: 16px;
}

.card {
  display: flex; flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  text-align: left; text-decoration: none; color: inherit; cursor: pointer;
  box-shadow: var(--shadow-sm);
  transition: box-shadow var(--transition), transform var(--transition), border-color var(--transition);
}
.card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); border-color: var(--text-faint); color: inherit; }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Square, because the thumbnails are cropped to the robot: a humanoid arrives
 * tall and narrow, a quadruped wide and short, and a square box gives both the
 * most room while keeping every card the same size. */
.card-figure {
  position: relative;
  aspect-ratio: 1 / 1;
  background: radial-gradient(115% 85% at 50% 10%, var(--card-stage-top) 0%, var(--card-stage-bottom) 74%);
  border-bottom: 1px solid var(--border);
  display: grid; place-items: center;
  overflow: hidden;
}
/* Absolutely positioned, not just `width/height: 100%`: the figure's height
 * comes from `aspect-ratio`, which browsers treat as an indefinite height, so a
 * percentage height on an in-flow child collapses to `auto`. A humanoid card
 * image (roughly 1:3) then laid itself out three cards tall and `object-fit`
 * never applied — the robot was cropped to whatever band of the image happened
 * to sit in the square. Against `inset: 0` the box is definite, so tall and wide
 * thumbnails alike are contained and centred. */
.card-figure img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  padding: 9%;
  transition: transform 0.35s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.card:hover .card-figure img { transform: scale(1.04); }
.card-figure .placeholder { font-size: 32px; opacity: 0.3; }

.card-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 2px; }
.card-name { font-size: 14.5px; font-weight: 700; line-height: 1.35; }
.card-maker { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
.card-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }

.tag {
  display: inline-block;
  font-family: var(--font-mono); font-size: 10.5px; line-height: 1.6;
  padding: 2px 9px; border-radius: 20px;
  background: var(--bg-alt); color: var(--text-muted); border: 1px solid var(--border);
  white-space: nowrap;
}
.tag.dof { color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); }
.tag.versions { color: var(--text); border-style: dashed; }
.tag.mjcf { color: var(--warn); border-color: rgba(255, 200, 87, 0.3); background: rgba(255, 200, 87, 0.1); }

.card-cat {
  position: absolute; top: 9px; left: 9px;
  font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase;
  padding: 3px 9px; border-radius: 20px;
  background: var(--card-cat-bg); color: var(--card-cat-text);
  backdrop-filter: blur(6px);
}

.empty { text-align: center; color: var(--text-muted); padding: 56px 0; }

/* ── Footer ─────────────────────────────────────────────────
   Same shape as the reference site: a full-width rule, then centred
   small print on the content measure, in the reader's language. */

.site-footer {
  margin-top: 1rem;
  padding: 24px 0 20px;
  padding-bottom: max(20px, env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
}

.footer-inner {
  max-width: var(--content-w);
  margin: 0 auto;
  padding: 0 clamp(16px, 4vw, 40px);
  text-align: center;
  font-size: 0.8rem;
  line-height: 1.7;
  color: var(--text-muted);
}

.footer-text { margin: 0; }

/* The name is a link whether or not you are pointing at it, so it carries its
 * underline at rest instead of growing one on hover. currentColor keeps the
 * rule exactly as loud as the text it sits under, through the hover too. */
.footer-author-link {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  transition: color var(--transition);
}
.footer-author-link:hover { color: var(--accent); }

/* One paragraph per language, switched by the <html lang> the language
 * toggle writes — the credit lines carry links, so they cannot go through
 * the textContent-based i18n pass. */
.footer-text-zh { display: none; }
html[lang^='zh'] .footer-text-en { display: none; }
html[lang^='zh'] .footer-text-zh { display: block; }

/* ── Detail header ──────────────────────────────────────── */

.detail-head { display: flex; align-items: center; gap: 14px; padding: 20px 0 16px; }

.back-btn {
  display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
  height: 34px; padding: 0 14px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg-alt); color: var(--text-muted);
  font: inherit; font-size: 13px; cursor: pointer;
  transition: color var(--transition), border-color var(--transition);
}
.back-btn:hover { color: var(--text); border-color: var(--text-faint); }
.detail-title h2 { font-size: clamp(19px, 2.6vw, 25px); line-height: 1.3; }
.detail-title p { margin: 0; font-size: 13px; }
/* The buttons are the fixed part of this row and the title is the elastic one.
 * Without that, a flex line too full shrank whatever it could reach, and what
 * it reached first was the button: "加入对比" is four characters with nowhere
 * to break, so it came out a column one character wide, spilling out the
 * bottom of a 34px button. A label is a floor, not a suggestion. */
.detail-nav { margin-left: auto; display: flex; gap: 8px; flex-shrink: 0; }
.detail-nav .ghost-btn { white-space: nowrap; }
/* The way out to MuJoCo Live, which is a link rather than a button and so needs
 * telling not to look like body text. */
.detail-nav .live-btn { display: inline-flex; align-items: center; text-decoration: none; }

/* Under about 560px the title has no room left to give either — the maker
 * starts stacking a word to a line beside a name squeezed to nothing — so the
 * row stops being a row. Not at the 720px the rest of the phone layout turns
 * at: at 720 all five still sit on one line with room to spare, and breaking
 * them apart there would only leave a gap in the middle of the header.
 *
 * Navigation stays on the first line: the way back, the optional MuJoCo Live
 * link, and the two arrows. The title and comparison action share the second
 * line, so MuJoCo Live has an explicit place instead of falling into an
 * implicit third grid row. */
@media (max-width: 560px) {
  #view-detail .detail-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    grid-template-areas:
      'back  live  prev next'
      'title title cmp  cmp';
    gap: 12px 8px;
    padding: 14px 0 12px;
  }
  /* The four navigation actions place themselves in the grid, so the box that grouped
   * them has nothing left to group. With the arrows hidden — a model read off
   * the visitor's own disk is in no category to walk — their columns collapse
   * and the compare button still lands on the right edge. */
  #view-detail .detail-nav { display: contents; }
  #back-btn { grid-area: back; justify-self: start; }
  #mujoco-live { grid-area: live; justify-self: start; }
  #prev-robot { grid-area: prev; }
  #next-robot { grid-area: next; }
  #view-detail .detail-title { grid-area: title; min-width: 0; }
  /* Top of the block, not its middle: a long maker takes a second line under
   * the name, and a centred button would drift down to sit beside it. The name
   * is what the button acts on, so it stays level with the name. */
  #add-compare { grid-area: cmp; align-self: start; }
}

/* At the 320px floor, the four translated labels are wider than the content
 * column even though each has its own grid area. Keep the conventional back
 * arrow and its accessible name, but drop the repeated visible word. */
@media (max-width: 360px) {
  #back-btn [data-i18n='detail.back'] { display: none; }
}

/* A fingertip's target rather than a pointer's — the 44px the hero's doors
 * take, and by the pointer rather than the width of the screen for the same
 * reason they are. */
@media (pointer: coarse) {
  .detail-head .back-btn, .detail-nav .ghost-btn { height: 44px; }
}

/* ── Stage ──────────────────────────────────────────────── */

.stage {
  position: relative;
  background: var(--studio);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  min-height: 400px;
  height: min(74vh, 720px);
  box-shadow: var(--shadow-sm);
}
.canvas-host { position: absolute; inset: 0; }
.canvas-host canvas { display: block; }

/* One column. The pose used to need a panel beside the stage, and the stage
 * gave up a third of the page for it; the tree and its sliders are a fullscreen
 * tool now, so the render takes the width back and everything that describes
 * the file reads underneath it. */
.detail-body { display: block; }

/* ── Which URDF of this machine ──────────────────────────────
 *
 * Above the stage rather than beside the specs: it decides what the whole page
 * is about — the render, the joint tree, the downloads — so it reads before
 * any of them rather than as one more field among the numbers. Only the
 * handful of machines upstream publishes as several files ever show it.
 */
.version-bar {
  display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px;
  margin-bottom: 14px; padding: 10px 12px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg-alt);
}
.version-pick { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1 1 320px; }
.version-pick > span { flex-shrink: 0; color: var(--text-muted); font-size: 12.5px; }
.version-pick select {
  flex: 1 1 auto; min-width: 0; max-width: 100%;
  height: 32px; padding: 0 8px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--bg); color: var(--text);
  font: inherit; font-family: var(--font-mono); font-size: 12.5px;
}
.version-pick select:focus-visible { border-color: var(--accent); outline: 0; box-shadow: 0 0 0 3px var(--accent-soft); }

/* Same box as the header's search, because it does the same job to a longer
 * list: a native select's type-ahead only matches the start of an option, and
 * these names differ in the middle. */
.version-filter {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  height: 32px; padding: 0 10px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--bg);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.version-filter:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.version-filter svg { width: 14px; height: 14px; flex-shrink: 0; fill: none; stroke: var(--text-faint); stroke-width: 1.9; }
.version-filter input {
  background: none; border: 0; outline: 0; color: var(--text);
  min-width: 0; width: clamp(90px, 14vw, 160px); font: inherit; font-size: 12.5px;
}
.version-count { margin: 0; color: var(--text-faint); font-size: 12px; }
/* A filter that matches nothing leaves the select holding only the version on
 * the stage, which would otherwise look like a list that lost its contents. */
.version-bar[data-empty='true'] .version-count { color: var(--warn); }
.version-bar[data-empty='true'] .version-filter { border-color: var(--warn); }

/* On a phone this row is spent out of the stage, and it was spending three
 * lines on it: a label that indented the select past the filter box under it,
 * so neither box shared an edge with anything. The word goes — the select
 * carries its own aria-label — and the filter collapses to its icon the way
 * the header's search does at this width, which leaves one row of controls
 * over one line of type. The filter takes a row of its own while it is being
 * used, and keeps it while it still holds something: collapsed over a filter
 * nobody can see, a list that had quietly dropped half its options would have
 * nothing on screen to explain itself. */
@media (max-width: 720px) {
  .version-bar { gap: 8px; padding: 9px 10px; }
  /* Basis zero, not auto: the widest option is a 46-character file name, and a
   * flex line sized from that wraps the icon beside it onto a row of its own
   * before it ever thinks to shrink the select. */
  .version-pick { flex: 1 1 0; }
  .version-pick > span { display: none; }
  /* A fingertip's target rather than a pointer's. */
  .version-pick select, .version-filter { height: 38px; }
  .version-filter { flex: 0 0 auto; padding: 0 9px; gap: 0; }
  .version-filter:not(:focus-within) { justify-content: center; min-width: 38px; }
  .version-filter input { width: 0; transition: width var(--transition); }
  .version-bar[data-filtering='true'] .version-filter,
  .version-filter:focus-within { flex: 1 1 100%; gap: 8px; justify-content: flex-start; }
  .version-bar[data-filtering='true'] .version-filter input,
  .version-filter:focus-within input { width: 100%; }
  .version-count { flex: 1 1 100%; }
}

/* Under 16px a text field is a page zoom on iOS the moment it takes focus, and
 * this one is only ever focused on the row it has to itself. */
@media (pointer: coarse) {
  .version-filter input { font-size: 16px; }
}

/* The viewer and everything that belongs to it rather than to the pose. Only
 * the specs and the joint sliders stay beside the stage; the reference cards
 * sit underneath, where there is width to spare and the side column no longer
 * runs a screen and a half long. */
.stage-col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
/* Three cards across, with the wide ones spanning the row: specs, downloads and
 * links are each a short list, and the snippet and the fullscreen note are the
 * two that want the whole width. */
.stage-extra {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 14px;
  /* Cards in a row share its height, so specs, downloads and links line up
   * along the bottom as well as the top — three panels of one height read as
   * one band under the stage, where three ragged ones read as leftovers. */
  align-items: stretch;
}
.stage-extra > .span-full { grid-column: 1 / -1; }

.stage-toolbar {
  position: absolute; left: 12px; bottom: 12px; right: 12px;
  display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end;
  pointer-events: none;
}
.toggle-group {
  display: flex; gap: 2px; padding: 3px;
  background: var(--stage-chrome-bg);
  border: 1px solid var(--stage-chrome-border);
  border-radius: var(--radius);
  backdrop-filter: blur(10px);
  pointer-events: auto;
}
.toggle-group button {
  border: 0; background: none; color: var(--stage-chrome-text);
  font: inherit; font-size: 12px; padding: 5px 10px; border-radius: var(--radius-sm);
  cursor: pointer; white-space: nowrap;
  transition: background var(--transition), color var(--transition);
}
.toggle-group button:hover { color: var(--stage-chrome-text-strong); background: var(--stage-chrome-hover); }
.toggle-group button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* The icon-only half of the row: a square target rather than a text label,
 * kept the height of the labelled buttons in the group beside it. */
.toggle-group button.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 30px; padding: 0;
}
.toggle-group button[aria-pressed='true'] { background: var(--accent); color: #fff; font-weight: 600; }
.toggle-group button .swatch {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px;
  vertical-align: middle; background: currentColor;
}

.stage-hint {
  position: absolute; top: 12px; right: 14px; margin: 0;
  font-size: 11.5px; color: var(--stage-faint);
  pointer-events: none;
}

.stage-loading, .stage-error {
  position: absolute; inset: 0;
  display: grid; place-content: center; justify-items: center; gap: 12px;
  background: var(--stage-veil);
  color: var(--stage-veil-text); font-size: 13px; text-align: center; padding: 24px;
}
.stage-error { color: var(--stage-error-text); }
.stage-error code { display: block; margin-top: 8px; font-size: 11.5px; color: var(--stage-faint); max-width: 42ch; word-break: break-all; }
.spinner {
  width: 26px; height: 26px; border-radius: 50%;
  border: 2px solid var(--stage-track); border-top-color: var(--accent);
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress { width: 190px; height: 3px; background: var(--stage-track); border-radius: 20px; overflow: hidden; }
.progress i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.2s; }

/* ── Side panel ─────────────────────────────────────────── */

.panel-block {
  flex: 0 0 auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px 16px 16px;
  box-shadow: var(--shadow-sm);
}
.panel-block h3 {
  font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--text-faint); font-weight: 600;
}
/* Wraps rather than crushes: on a narrow card the counts and the fold controls
 * fall to a second line instead of breaking mid-label. */
.panel-block-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 2px 10px; }

.panel-block-head .head-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }

.link-btn {
  border: 0; background: none; color: var(--accent); font: inherit; font-size: 12px;
  cursor: pointer; padding: 0;
}
.link-btn:hover { color: var(--accent-hover); text-decoration: underline; }
/* One of these is a flex box, which outranks the user agent's own rule for
 * `hidden`; the group is hidden a row at a time, so it is said here once. */
.link-btn[hidden] { display: none; }

/* The joint tour, beside the other three links: it reads as one of them, and
 * carries a mark rather than a label saying which of its two states it is in.
 * A mode, so it stays lit for as long as it runs — the row is otherwise all
 * actions, and an action that is still happening has to look different from
 * one waiting to be asked for. */
.play-btn { display: inline-flex; align-items: center; gap: 5px; }
/* A triangle pointing the way the tour will go; a square while it is going,
 * which is the shape every player in the world stops with. Borders rather than
 * glyphs, so both marks sit on the same baseline at the same weight whatever
 * the font underneath has to say about ▶ and ■. */
.play-mark {
  flex: 0 0 auto; width: 0; height: 0;
  border-style: solid;
  border-width: 4.5px 0 4.5px 7px;
  border-color: transparent transparent transparent currentColor;
}
.play-btn[aria-pressed='true'] .play-mark {
  width: 8px; height: 8px;
  border-width: 0;
  background: currentColor;
}
/* Running: the tour's own colour, and a pulse on top of it — the panel is
 * scrolling itself past sixty joints at the time, and a colour alone is easy to
 * miss in a column that is already moving. */
.play-btn[aria-pressed='true'] { color: var(--warn-ink); animation: play-pulse 1.6s ease-in-out infinite; }
.play-btn[aria-pressed='true']:hover { color: var(--warn-ink); }
@keyframes play-pulse { 50% { opacity: 0.55; } }

/* Degrees or radians for the joint readouts. A two-button segmented control
 * rather than one toggle: both units stay on screen, so the switch says what
 * it will do without having to be tried. */
.unit-toggle {
  display: inline-flex;
  border: 1px solid var(--border); border-radius: 999px;
  background: var(--bg-alt); overflow: hidden;
}
.unit-toggle[hidden] { display: none; }
.unit-toggle button {
  border: 0; background: none; color: var(--text-muted);
  font: inherit; font-family: var(--font-mono); font-size: 10.5px; line-height: 1;
  padding: 4px 9px; cursor: pointer;
  transition: background var(--transition), color var(--transition);
}
.unit-toggle button:hover { color: var(--text); }
/* Same "this one is on" as the overlay toggles over the stage. */
.unit-toggle button[aria-pressed='true'] { background: var(--accent); color: #fff; font-weight: 600; }
.unit-toggle button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

.specs { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 12px 0 0; font-size: 13px; line-height: 1.5; }
.specs dt { color: var(--text-muted); }
.specs dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.specs dd .sub { color: var(--text-faint); font-size: 11.5px; }
.specs .full { grid-column: 1 / -1; }
.specs abbr { color: var(--warn); cursor: help; text-decoration: none; }

/* ── Downloads ──────────────────────────────────────────── */

.downloads { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }

.dl-btn {
  position: relative;
  display: flex; align-items: center; gap: 10px;
  width: 100%; padding: 10px 13px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg-alt); color: var(--text);
  font: inherit; font-size: 13px; text-align: left; cursor: pointer;
  overflow: hidden;
  transition: border-color var(--transition), background var(--transition);
}
.dl-btn:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); }
.dl-btn:disabled { cursor: progress; }
.dl-btn.primary { border-color: var(--accent-line); background: var(--accent-soft); }
/* All three rows carry the same download mark — they differ in what comes
 * down, not in what the button does — so the icon stays quiet until the row
 * is hovered. */
.dl-btn .dl-icon { display: flex; color: var(--text-muted); flex-shrink: 0; }
.dl-btn:hover:not(:disabled) .dl-icon { color: var(--accent); }
.dl-btn .dl-icon .icon { width: 17px; height: 17px; }
.dl-btn .dl-text { display: flex; flex-direction: column; line-height: 1.35; min-width: 0; }
.dl-btn .dl-sub { color: var(--text-faint); font-size: 11.5px; }
.dl-btn .dl-size { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
/* Fill bar for the bundle download, which can take a while on big robots. */
.dl-btn .dl-fill {
  position: absolute; left: 0; top: 0; bottom: 0; width: 0;
  background: var(--accent-soft);
  pointer-events: none;
  transition: width 0.2s;
}
.dl-btn > *:not(.dl-fill) { position: relative; }

/* ── Joint sliders ──────────────────────────────────── */

/* Every slider sits under the joint it turns, in the tree. Indented past the
 * twisty so it starts where the joint's name does, and quiet enough that a
 * branch of thirty still reads as a list of joints rather than of controls. */
.tree-slider { margin: 0 2px 5px 19px; }
/* The slider takes the width, the angle it is at holds a fixed column at the
 * end of it — so the numbers down a branch line up and none of them moves while
 * a thumb is being dragged. */
.slider-line { display: flex; align-items: center; gap: 8px; }
.slider-line input[type='range'] { flex: 1 1 auto; min-width: 0; }

/* A joint that mimics another has no slider — its value is written by the joint
 * it follows. What stands in that place is the name of that joint, quiet enough
 * that the branch still reads as the row of controls it mostly is, while the
 * readout keeps the column the sliders keep theirs in. */
.tree-slider.is-follow .slider-line { min-height: 24px; cursor: help; }
.tree-follow {
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: var(--font-mono); font-size: 10.5px; color: var(--text-faint);
}
.tree-follow i { font-style: normal; margin-right: 5px; color: var(--text-muted); }
.tree-slider.is-follow .tree-value { color: var(--text-muted); }

/* What the URDF declares for the joint above: travel, and the speed and effort
 * ceilings. Three short chips rather than a table — they sit under every
 * slider, so they have to stay quiet. */
.joint-limits {
  display: flex; flex-wrap: wrap; gap: 2px 10px;
  font-family: var(--font-mono); font-size: 10.5px; line-height: 1.5;
  color: var(--text-faint); font-variant-numeric: tabular-nums;
}
.joint-limits .lim { display: inline-flex; align-items: baseline; gap: 4px; cursor: help; white-space: nowrap; }
.joint-limits .lim i {
  font-family: var(--font-sans); font-style: normal; font-size: 9.5px;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); opacity: 0.8;
}

/* The groove reads as 3px, but a 3px-tall <input> is a 3px-tall hit area — a
 * fingertip cannot land on it, and even a mouse has to be precise. So the input
 * itself is a finger-sized transparent strip and the groove is painted on the
 * track pseudo-element instead. `touch-action: pan-y` completes it: a sideways
 * drag belongs to the thumb, while a vertical swipe still scrolls the tree
 * rather than being swallowed by the slider. */
.tree-slider input[type='range'] {
  /* The groove and the thumb are read by js/detail.js as well as drawn here —
   * it puts a joint where a finger lands, which means knowing how much of the
   * strip the thumb takes — so both are declared once, as variables, and the
   * coarse-pointer block below is free to grow them. */
  --tree-track: 3px;
  --tree-thumb: 16px;
  -webkit-appearance: none; appearance: none;
  display: block; width: 100%; height: 24px; margin: 0;
  background: transparent; border-radius: 20px; outline: none;
  touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
}
.tree-slider input[type='range']::-webkit-slider-runnable-track {
  height: var(--tree-track); border-radius: 20px; background: var(--border);
}
.tree-slider input[type='range']::-moz-range-track {
  height: var(--tree-track); border-radius: 20px; background: var(--border);
}
.tree-slider input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none; width: var(--tree-thumb); height: var(--tree-thumb); border-radius: 50%;
  background: var(--accent); cursor: pointer; border: 2px solid var(--surface);
  /* Centre the thumb on the track, whichever size the two are. */
  margin-top: calc((var(--tree-track) - var(--tree-thumb)) / 2);
}
.tree-slider input[type='range']::-moz-range-thumb {
  width: var(--tree-thumb); height: var(--tree-thumb); border-radius: 50%; background: var(--accent);
  cursor: pointer; border: 2px solid var(--surface);
}
.tree-slider input[type='range']:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.tree-slider input[type='range']:active::-webkit-slider-thumb { background: var(--accent-hover); }
.tree-slider input[type='range']:active::-moz-range-thumb { background: var(--accent-hover); }

/* The same slider under a thumb. A 16px thumb on a 24px strip is a fingernail's
 * worth of target, and it is the whole target there is: a touch that lands on
 * the groove beside it moves nothing — that is the browser's own behaviour for
 * a range input, on iOS and on Android alike — so posing a joint on a phone
 * means hitting the thumb exactly, over and over, down a leg of four. Half the
 * answer is here: a wider strip to land on and a thumb big enough to see and to
 * hold. The other half is in js/detail.js, which makes the groove itself live,
 * so that landing anywhere along it is an instruction rather than a miss.
 *
 * 40px rather than the 44 the hero's two doors take: a strip that spans its row
 * is only ever aimed at in one direction — the width is the easy half — and a
 * humanoid stacks sixty of these inside a panel a phone gives 140px of. Every
 * pixel of height here is paid for sixty times over, so it buys what it has to
 * and stops. The gap under it closes to match: at this height the strip is its
 * own separation from the row below. */
@media (pointer: coarse) {
  .tree-slider { margin-bottom: 3px; }
  .tree-slider input[type='range'] {
    --tree-track: 4px;
    --tree-thumb: 22px;
    height: 40px;
  }
}

/* ── Joint tree ─────────────────────────────────────────── */

/* The tree is a fullscreen tool: on the page this card is not rendered at all,
 * and the fullscreen rules below lift the same markup — pose, scroll position
 * and every listener intact — onto the render as a floating column. */
.tree-block { display: none; }

/* What stands in its place: where the tree and the sliders went, and the door
 * to them. Wide, because it is the one thing under the stage that is about the
 * viewer rather than about the file. */
.pose-block { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
/* No measure of its own: the card is one row wide and two sentences long, so a
 * reading width would only break the line early and leave half the card empty.
 * The column is the measure — the text wraps where the card ends. */
.pose-hint { margin: 0; font-size: 13px; line-height: 1.6; color: var(--text-muted); }

/* The shape of the description, under the stage where a deep chain has the
 * width to stay on one line. The card takes whatever height the tree asks for,
 * all sixty rows of a humanoid included: the whole chain is the thing worth
 * reading here, so it opens out and the page below it moves down. Only a branch
 * wider than the card scrolls, and only sideways. */
.tree-host {
  margin-top: 10px;
  overflow-x: hidden;
}
.tree-host::-webkit-scrollbar { width: 8px; height: 8px; }
.tree-host::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }

/* The tree fits the column: every row carries a slider now, and a tree wide
 * enough to scroll sideways would take the far end of each one off screen. A
 * long link name gives way instead — it is the one part of a row that can be
 * read from its first half. */
.tree { min-width: 0; }
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
/* One guide line per level of nesting, so a branch can be followed by eye. A
 * narrow step, because a six-joint arm is six steps deep and every one of them
 * comes off the width its rows and its sliders have left. */
.tree ul { margin-left: 5px; padding-left: 9px; border-left: 1px solid var(--border-soft); }
.tree-node[aria-expanded='false'] > ul { display: none; }
.tree-node:focus { outline: none; }
.tree-node:focus-visible > .tree-row { outline: 2px solid var(--accent); outline-offset: -2px; }

.tree-row {
  display: flex; align-items: baseline; gap: 6px;
  padding: 2px 6px 2px 0; border-radius: var(--radius-sm);
  font-size: 12px; line-height: 1.7; white-space: nowrap; cursor: pointer;
  overflow: hidden;
}
.tree-row:hover { background: var(--bg-alt); }
.tree-node[aria-selected='true'] > .tree-row { background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-line); }
/* The joint the tour is on. A different colour from the pinned row on purpose:
 * a pin is the reader's, this is the panel's, and while the tour runs both can
 * be on screen at once. */
.tree-node.is-sweeping > .tree-row { background: var(--warn-soft); box-shadow: inset 0 0 0 1px var(--warn-ink); }
.tree-node.is-sweeping > .tree-slider input[type='range']::-webkit-slider-thumb { background: var(--warn-ink); }
.tree-node.is-sweeping > .tree-slider input[type='range']::-moz-range-thumb { background: var(--warn-ink); }

/* A disclosure arrow that keeps its column when there is nothing to fold, so
 * the names down a branch stay aligned. */
.tree-twisty {
  flex: 0 0 auto; width: 13px; text-align: center;
  color: var(--text-faint); font-size: 9px; user-select: none;
  transition: transform var(--transition);
}
.tree-node[aria-expanded] > .tree-row > .tree-twisty::before { content: '▾'; }
.tree-node[aria-expanded='false'] > .tree-row > .tree-twisty { transform: rotate(-90deg); }
.tree-twisty:hover { color: var(--text); }

/* The row reads as one phrase — joint, type, link — so the names take the width
 * they need and no more: the chip and the link follow the joint name wherever it
 * ends, and stay there while the column is dragged wider.
 *
 * They still have to fit the column, and give when a generated URDF names a
 * joint `face_front_to_depth_camera_front_lower_camera_parent_joint`. They give
 * together, in proportion to how long they are, and each keeps a few characters
 * and an ellipsis — a name clipped to nothing says less than a clipped name
 * does, and either one alone can identify the row. */
.tree-joint, .tree-link {
  flex: 0 1 auto; min-width: 3em;
  overflow: hidden; text-overflow: ellipsis;
  font-family: var(--font-mono); font-size: 11.5px;
}
.tree-joint { color: var(--text); }
.tree-link { color: var(--text-muted); }
.tree-to { flex: 0 0 auto; color: var(--text-faint); font-size: 10px; }
.tree-bare { color: var(--text-faint); font-style: normal; margin-left: 4px; }
.tree-value {
  flex: 0 0 auto; min-width: 4.6em; text-align: right;
  font-family: var(--font-mono); font-size: 11px; color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* Joint type, as a chip the eye can sort on: the ones that move carry the
 * accent, `fixed` recedes — a third of a humanoid's tree is fixed joints. */
.jt {
  flex: 0 0 auto;
  padding: 0 5px; border-radius: 4px;
  border: 1px solid var(--border); background: var(--bg-alt);
  font-family: var(--font-sans); font-size: 9.5px; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--text-muted);
}
.jt[data-jt='revolute'], .jt[data-jt='continuous'], .jt[data-jt='prismatic'] {
  color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft);
}
.jt[data-jt='fixed'] { color: var(--text-faint); border-color: var(--border-soft); background: none; }
.jt.mimic, .jt.loop { color: var(--warn); border-color: var(--border); }
.jt.root { color: var(--text-faint); }

.tree-none { margin: 8px 0 0; font-size: 12.5px; color: var(--text-muted); }

.tree-summary {
  font-family: var(--font-mono); font-size: 11px; color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}
.tree-hint { margin: 9px 0 0; font-size: 11px; color: var(--text-faint); }

/* ── Fullscreen stage ───────────────────────────────────── */

/* The stage on its own screen. Nothing is moved in the DOM: the joint tree
 * stays exactly where the markup puts it — pose, scroll position and every
 * listener with it — and this lifts it out of the page grid onto the render as
 * a floating column, while the cards that describe the file rather than the
 * pose step aside for the duration.
 *
 * js/detail.js also asks for native fullscreen, which takes the browser's own
 * chrome with it. These rules are what makes the mode work when that request is
 * refused — an iPhone has no element fullscreen at all — so they cover the
 * viewport on their own rather than relying on `:fullscreen`. */

/* The name over the render, which only fullscreen has a use for: on the page
 * the heading two lines above the stage already says whose robot this is. */
.stage-title { display: none; }

/* The studio colour, not the page's: fullscreen is one surface with the render
 * in the middle of it, and the view behind the stage shows through wherever the
 * stage cannot reach — the strip an iOS toolbar animates over, the overscroll
 * above a native-fullscreen element's backdrop. */
#view-detail.stage-fullscreen { padding: 0; max-width: none; background: var(--studio); }
#view-detail.stage-fullscreen::backdrop { background: var(--studio); }

/* Everything that is not the render or a control for the pose. */
#view-detail.stage-fullscreen .detail-head,
#view-detail.stage-fullscreen .stage-extra > .panel-block:not(.tree-block) { display: none; }

/* Every child still on screen is taken out of flow below, so the grids have
 * nothing left to lay out — as tracks they would go on reserving the 340px
 * side column and the gaps between the cards. */
#view-detail.stage-fullscreen .stage-col,
#view-detail.stage-fullscreen .stage-extra { display: block; gap: 0; }

#view-detail.stage-fullscreen .stage {
  position: fixed; inset: 0; z-index: 1400;
  height: auto; min-height: 0; max-height: none;
  border: 0; border-radius: 0; box-shadow: none;
}

/* The one panel that drives the pose, as a floating column on the left of the
 * render: the tree, with every slider under the joint it turns. Following a
 * chain is what wants the height, and reading down it is what a reader does
 * while posing. Same chrome as the toolbar — it sits on the render, so it has
 * to read against it rather than against the page. */
#view-detail.stage-fullscreen .tree-block {
  position: fixed; z-index: 1410;
  top: calc(52px + env(safe-area-inset-top, 0px));
  bottom: calc(70px + env(safe-area-inset-bottom, 0px));
  left: calc(14px + env(safe-area-inset-left, 0px));
  width: var(--fs-tree-w, clamp(240px, 33.3333vw, 50vw));
  display: flex; flex-direction: column; min-height: 0;
  background: var(--stage-chrome-bg);
  border-color: var(--stage-chrome-border);
  box-shadow: var(--shadow-lg);
  backdrop-filter: blur(10px);
}

/* What is left of the screen once the column has its width: that is the render,
 * and the robot is centred in it rather than behind the panel. The renderer
 * follows its host, so insetting the host is all it takes — widening the column
 * narrows the render and the robot re-centres in what remains.
 *
 * The floor clears the toolbar rather than running under it: the bar is below
 * the URDF, not on top of it. Both insets are read from variables the layout
 * itself writes — the column's width, and the bar's measured height — so the
 * render can never disagree with either about where it ends. The bar's height
 * is not a number a stylesheet can know: it is one row on a wide screen and two
 * wherever the legend outgrows the width beside the icons, so js/detail.js
 * measures it. The fallback is one row, and 8px of air above the bar. */
#view-detail.stage-fullscreen .canvas-host {
  left: calc(var(--fs-tree-w, clamp(240px, 33.3333vw, 50vw)) + 28px + env(safe-area-inset-left, 0px));
  bottom: calc(var(--fs-bar-h, 40px) + 20px + env(safe-area-inset-bottom, 0px));
}
/* The handle itself, on the edge that faces the render: a strip straddling the
 * border, so the target is a comfortable width without the panel having to
 * leave a gutter for it. It exists only in fullscreen — on the page these two
 * are cards in the grid, sized by the grid — and only where there is a pointer
 * precise enough to catch it; the responsive block below takes it back on
 * phones and touch screens. */
.panel-resize { display: none; }
#view-detail.stage-fullscreen .panel-resize {
  display: block;
  position: absolute; top: 0; bottom: 0; z-index: 1;
  width: 12px;
  cursor: ew-resize;
  /* The drag is the handle's own; without this a touch pointer would scroll
   * the panel behind it instead. */
  touch-action: none;
}
#view-detail.stage-fullscreen .tree-block .panel-resize { right: -6px; }
/* A grip down the middle of that strip: faint enough to sit on the render
 * unasked, lit while the pointer is on it or the drag is running, so the
 * column edge says it can be moved before it is tried. */
.panel-resize::before {
  content: '';
  position: absolute; top: 50%; left: 50%;
  width: 3px; height: 36px; margin: -18px 0 0 -1.5px;
  border-radius: 3px; background: var(--stage-chrome-border);
  transition: background var(--transition), height var(--transition);
}
.panel-resize:hover::before,
.panel-resize:focus-visible::before,
.panel-resize.is-dragging::before { background: var(--accent); height: 52px; margin-top: -26px; }
.panel-resize:focus { outline: none; }
.panel-resize:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); }
/* While a column is being dragged the pointer belongs to the drag, wherever it
 * has got to — not to the text it happens to be over. */
body.panel-resizing { cursor: ew-resize; user-select: none; }

/* The panel is as tall as the screen allows rather than as tall as its
 * content, so the tree inside it is what absorbs the difference — sixty rows of
 * a humanoid, each with a slider, scroll within the column. */
#view-detail.stage-fullscreen .tree-host {
  flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;
}

/* The bar under the URDF: it takes the render's own span — the same left edge
 * the canvas has, the same right edge — and centres its two rows in it, so the
 * legend and the way back out of fullscreen sit on the robot's own centre line.
 * Widen the column and the render, the robot and the bar all move together.
 *
 * It is below the render rather than on it: the canvas floor stops short of
 * this band, so nothing here overlaps the robot. */
#view-detail.stage-fullscreen .stage-toolbar {
  justify-content: center;
  left: calc(var(--fs-tree-w, clamp(240px, 33.3333vw, 50vw)) + 28px + env(safe-area-inset-left, 0px));
  right: env(safe-area-inset-right, 0px);
}
/* A render narrow enough for the legend to outgrow it: the legend scrolls
 * inside its own row rather than wrapping the icon row out of reach — the same
 * answer the phone layout gives it. */
#view-detail.stage-fullscreen #overlay-toggles {
  max-width: 100%; overflow-x: auto; scrollbar-width: none;
}
#view-detail.stage-fullscreen #overlay-toggles::-webkit-scrollbar { display: none; }

#view-detail.stage-fullscreen .stage-title {
  display: block;
  position: absolute; z-index: 2;
  top: calc(12px + env(safe-area-inset-top, 0px));
  left: calc(14px + env(safe-area-inset-left, 0px));
  padding: 3px 10px;
  border: 1px solid var(--stage-chrome-border); border-radius: var(--radius);
  background: var(--stage-chrome-bg); backdrop-filter: blur(10px);
  font-size: 12.5px; font-weight: 600; line-height: 1.6;
  color: var(--stage-chrome-text-strong);
  pointer-events: none;
}

/* The page behind the overlay must not scroll under it — it is still there
 * whenever the native request was refused — and what little of it a phone can
 * still expose, at the top of an overscroll or under a retracting browser bar,
 * is the studio rather than the page. */
body.stage-fullscreen-open { overflow: hidden; background: var(--studio); }

/* ── Resources & snippets ───────────────────────────────── */

.resources { list-style: none; margin: 10px 0 0; padding: 0; }
.resources a {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 8px 0; font-size: 13px; text-decoration: none; color: var(--text);
  border-bottom: 1px solid var(--border-soft);
}
.resources li:last-child a { border-bottom: 0; }
.resources a:hover { color: var(--accent); }
.resources .res-label { white-space: nowrap; }
/* The link says what it is; the upstream path after it is the detail, so that
 * is what gets clipped when the column is narrow — in full on hover. */
.resources .kind {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--text-faint);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.snippet { margin-top: 10px; position: relative; }
.snippet-tabs { display: flex; gap: 4px; margin-bottom: 9px; flex-wrap: wrap; }
.snippet-tabs button {
  border: 1px solid var(--border); background: var(--bg-alt); color: var(--text-muted);
  border-radius: 20px; padding: 3px 10px; font-family: var(--font-mono); font-size: 11px; cursor: pointer;
  transition: color var(--transition), border-color var(--transition), background var(--transition);
}
.snippet-tabs button[aria-pressed='true'] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); }
.snippet pre {
  margin: 0; padding: 11px 12px; background: var(--code-bg);
  border: 1px solid var(--border); border-radius: var(--radius-sm); overflow-x: auto;
}
.snippet code { font-family: var(--font-mono); font-size: 11.5px; line-height: 1.65; color: var(--text); white-space: pre; }
#snippet-copy { position: absolute; top: 1px; right: 0; }

/* ── Custom URDF picker ─────────────────────────────────── */

/* A <dialog>, so the browser owns the modality: the backdrop, the focus trap
 * and Escape all come for free, and the page behind it keeps its scroll. */
.custom-dialog {
  /* Centred on both axes. A modal <dialog> is laid out against `inset: 0`, so
     `margin: auto` is what centres it horizontally *and* vertically — and the
     reset at the top of this file zeroes every margin, which is what would
     otherwise park it in the top-left corner. The max-height keeps a dialog
     taller than a short window inside it, scrolling its own content rather
     than growing off both edges. */
  margin: auto;
  width: min(560px, calc(100vw - 32px));
  max-height: calc(100vh - 32px);
  overflow-y: auto;
  padding: 22px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); color: var(--text);
  box-shadow: var(--shadow-lg);
}
.custom-dialog[open] { display: flex; flex-direction: column; gap: 14px; }
.custom-dialog::backdrop { background: rgba(0, 0, 0, 0.55); }

.custom-head { display: flex; align-items: flex-start; gap: 12px; }
.custom-head h2 { font-size: 17px; line-height: 1.35; }
.dialog-close {
  margin-left: auto; flex: 0 0 auto;
  width: 28px; height: 28px;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: none; color: var(--text-faint);
  font: inherit; font-size: 13px; line-height: 1; cursor: pointer;
  transition: color var(--transition), border-color var(--transition);
}
.dialog-close:hover { color: var(--text); border-color: var(--border); }

/* The point of the whole dialog, so it is a panel rather than a footnote:
 * tinted, in body-sized type, above the control that asks for the files. */
.custom-privacy {
  display: flex; gap: 9px; align-items: flex-start;
  padding: 11px 13px;
  border: 1px solid var(--accent-line); border-radius: var(--radius-sm);
  background: var(--accent-soft);
  font-size: 13px; line-height: 1.6;
}
.custom-privacy .lock { flex: 0 0 auto; }

.drop-zone {
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding: 24px 18px;
  border: 1.5px dashed var(--border); border-radius: var(--radius);
  background: var(--bg-alt);
  text-align: center; cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
/* `is-over` is a file being dragged over the zone; hover is a pointer resting
 * on it. They light it the same way, but hover is asked for only where a
 * pointer can rest — a tap leaves the hover state stuck on a touch screen, and
 * a zone that stays lit reads as a zone that has already been given a file. */
.drop-zone.is-over { border-color: var(--accent); background: var(--accent-soft); }
@media (hover: hover) {
  .drop-zone:hover { border-color: var(--accent); background: var(--accent-soft); }
}
.drop-line { font-size: 13.5px; }
.drop-or { color: var(--text-faint); font-size: 12px; }
.drop-picks { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }

.custom-source-divider {
  display: flex; align-items: center; gap: 10px;
  color: var(--text-faint); font-size: 12px;
}
.custom-source-divider::before, .custom-source-divider::after {
  content: ''; height: 1px; flex: 1 1 auto; background: var(--border-soft);
}
.custom-github { display: flex; flex-direction: column; gap: 7px; }
.custom-github > label { color: var(--text-muted); font-size: 12.5px; }
.custom-github-row { display: flex; align-items: stretch; gap: 8px; }
.custom-github-row input {
  min-width: 0; flex: 1 1 auto; height: 36px; padding: 0 10px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--bg-alt); color: var(--text);
  font: inherit; font-family: var(--font-mono); font-size: 11.5px;
  outline: none;
  transition: border-color var(--transition), box-shadow var(--transition);
}
.custom-github-row input:focus {
  border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.custom-github-row .ghost-btn { flex: 0 0 auto; }
.custom-github > p { margin: 0; color: var(--text-faint); font-size: 11.5px; line-height: 1.55; }

/* The same fingertip the hero's two doors are sized for: on a touch screen
 * this dialog is the second half of that flow, and its pickers, its preview
 * button and the way out of it are what gets tapped. A 28px ✕ beside the
 * heading is a miss on a phone. */
@media (pointer: coarse) {
  .drop-picks .ghost-btn { height: 44px; }
  .custom-github-row input, .custom-github-row .ghost-btn { height: 44px; }
  .custom-actions .primary-btn { height: 44px; }
  .dialog-close { width: 40px; height: 40px; font-size: 15px; }
}

/* What the picked files add up to, before anything is rendered — the count of
 * meshes found is what tells a visitor they picked one folder too deep. */
.custom-report {
  display: flex; flex-direction: column; gap: 5px;
  padding: 12px 13px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--bg-alt);
  font-size: 13px; line-height: 1.55;
}
.custom-report p { margin: 0; color: var(--text-muted); }
.custom-good { color: var(--text) !important; }
.custom-warn { color: var(--warn) !important; }
.custom-bad { color: var(--stage-error-text) !important; }
.custom-missing {
  color: var(--text-faint); font-family: var(--font-mono); font-size: 11.5px;
  word-break: break-all;
}
/* Which language the picked file turned out to be written in — the one thing
   about it the reader cannot tell from its name. */
.custom-kind {
  margin-left: 8px; padding: 1px 6px; border-radius: 4px;
  border: 1px solid var(--border); background: var(--bg);
  color: var(--text-muted); font-family: var(--font-mono); font-size: 11px;
  vertical-align: 1px;
}

.custom-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.custom-choice {
  display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1 1 240px;
  color: var(--text-muted); font-size: 12.5px;
}
.custom-choice select {
  flex: 1 1 auto; min-width: 0; max-width: 100%;
  height: 32px; padding: 0 8px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--bg-alt); color: var(--text);
  font: inherit; font-size: 12.5px;
}
.custom-actions .primary-btn { margin-left: auto; }
.custom-hint { margin: 0; color: var(--text-faint); font-size: 12px; line-height: 1.6; }

/* ── A model read off the visitor's own disk ─────────────── */

/* Under the title, where a gallery robot carries its maker: the same promise
 * the picker made, now that the file is on the stage. */
.local-badge {
  display: flex; align-items: baseline; gap: 6px;
  margin: 4px 0 0;
  color: var(--text-faint); font-size: 12px;
}
.local-privacy {
  display: flex; gap: 7px; align-items: flex-start;
  margin: 12px 0 0;
  color: var(--text-faint); font-size: 11.5px; line-height: 1.6;
}
.specs dd .sub.warn { color: var(--warn); }
/* Two cards, not three: the local model has no downloads and no upstream, and
 * a lone card in a three-column band reads as two cards missing. */
.stage-extra[data-mode='local'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }

/* ── Side-by-side comparison ─────────────────────────────── */

/* The way in, beside the hero's own button (see `.hero-actions`): a second
 * thing this gallery can do, given the same weight as previewing your own file
 * but not the same colour — one filled button per screen is enough. It carries
 * the primary button's height rather than the ghost row's 34px, so the two
 * doors sit on one baseline, each over its own fine print. */
.hero-compare-btn {
  display: inline-flex; align-items: center; gap: 9px;
  height: 38px; padding: 0 16px; border-radius: var(--radius);
  font-size: 14px; font-weight: 600; text-decoration: none;
}
.hero-compare-btn:hover { border-color: var(--accent); color: var(--accent); }
.compare-mark { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }

#view-compare .detail-title p { margin: 4px 0 0; font-size: 13px; line-height: 1.6; max-width: 78ch; }

.compare-body { display: flex; flex-direction: column; gap: 14px; }
.cmp-picker { margin-bottom: 14px; }
.cmp-picker .picker-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.cmp-picker .version-pick { flex: 0 1 260px; }
.cmp-picker .version-filter { flex: 1 1 200px; max-width: 320px; }

/* What is selected, and — where upstream ships a machine as several URDFs —
 * which of them. The version box is on the chip rather than in the list: it is
 * a property of a robot already chosen, not a way of choosing one. */
.pick-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.pick-chips:empty { display: none; }
.pick-chip {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 5px 6px 5px 11px;
  border: 1px solid var(--accent-line); background: var(--accent-soft);
  border-radius: 20px; font-size: 12.5px; max-width: 100%;
}
.pick-chip .chip-name { font-weight: 600; white-space: nowrap; }
.pick-chip select {
  max-width: 190px; padding: 2px 4px;
  background: var(--bg-alt); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  font-family: var(--font-mono); font-size: 11px;
}
.pick-chip button {
  display: flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; border-radius: 50%;
  border: 0; background: none; color: var(--text-muted); cursor: pointer; font-size: 11px;
}
.pick-chip button:hover { background: var(--border); color: var(--text); }

.pick-list {
  display: grid; gap: 8px; margin-top: 14px;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
}
.pick-list button {
  display: grid; align-items: center; gap: 1px 10px; text-align: left;
  grid-template-columns: 40px minmax(0, 1fr);
  padding: 7px 9px;
  background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
.pick-list button img { grid-row: 1 / 4; width: 40px; height: 40px; object-fit: contain; }
.pick-list button:hover:not(:disabled) { border-color: var(--text-faint); }
.pick-list button[aria-pressed='true'] { border-color: var(--accent); background: var(--accent-soft); }
.pick-list button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Where a thumbnail goes on every other card. The visitor's own model has
   none — nothing has rendered it — so the slot keeps its width and holds a
   mark instead, and the row of cards stays a row. */
.pick-list .pick-local, .col-head .col-local {
  display: grid; place-items: center;
  border: 1px dashed var(--border); border-radius: var(--radius-sm);
  color: var(--text-faint);
}
.pick-list .pick-local { grid-row: 1 / 4; width: 40px; height: 40px; font-size: 15px; }
.col-head .col-local { grid-row: 1 / 4; width: 34px; height: 34px; font-size: 13px; }
.pick-list .pick-name { font-weight: 600; line-height: 1.3; }
.pick-list .pick-sub { color: var(--text-muted); font-size: 11px; line-height: 1.3; }
.pick-list .pick-dof { color: var(--text-faint); font-family: var(--font-mono); font-size: 10.5px; }

.cmp-local-note {
  margin: 10px 0 0;
  color: var(--text-faint); font-size: 11.5px; line-height: 1.6; max-width: 92ch;
}

.cmp-status { margin: 0 0 12px; color: var(--text-muted); font-size: 13px; }
.warn-line { margin: 0 0 8px; color: var(--warn); font-size: 12.5px; }
.warn-line code { font-family: var(--font-mono); font-size: 11px; }
.cmp-export { display: flex; justify-content: flex-end; gap: 14px; }

/* The tables themselves. Six columns of numbers do not fit a phone and should
 * not try to: the table scrolls sideways inside its own card, with the row
 * labels and the column heads pinned so a number is never orphaned from what
 * it is about. */
/* The table scrolls inside its own card rather than taking the page sideways
 * with it, and it is capped at the height of a screen so that the pinning
 * below has something to pin against: a sticky cell can only stay put inside
 * the box that scrolls, and until this cap the box was the whole table. A
 * table shorter than the cap is untouched. */
.table-scroll {
  margin-top: 12px;
  max-height: calc(100vh - var(--header-h) - 40px);
  overflow: auto;
  overscroll-behavior-x: contain;
  /* A lane of its own for the bar, rather than the bar over the numbers.
     Where the platform draws scrollbars as an overlay — macOS everywhere, and
     Chrome's own thin bar — a table tall enough to scroll had its last column
     sitting under the thumb. The gutter is reserved whether this table scrolls
     or not, so the three tables of the page still end on one edge. */
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: var(--scroll-thumb) transparent;
  /* A shadow at whichever edge the table continues past, and none at the edge
     it has reached: the first two layers travel with the content and cover the
     other two when the ends come into view. Without it a capped table reads as
     a table that simply stops. */
  background-image:
    linear-gradient(to bottom, var(--surface), rgba(0, 0, 0, 0)),
    linear-gradient(to top, var(--surface), rgba(0, 0, 0, 0)),
    linear-gradient(to bottom, var(--scroll-edge), rgba(0, 0, 0, 0)),
    linear-gradient(to top, var(--scroll-edge), rgba(0, 0, 0, 0));
  background-position: 0 0, 0 100%, 0 0, 0 100%;
  background-size: 100% 16px, 100% 16px, 100% 7px, 100% 7px;
  background-repeat: no-repeat;
  background-attachment: local, local, scroll, scroll;
}
/* The same bar, drawn by hand, for the browsers that came before
   scrollbar-width. Where both are understood the standard property wins and
   every rule below it is ignored, so the two have to agree: a thumb of the
   same colour, inset from its lane by a transparent border rather than filling
   it, and no track or corner of its own to draw a box around the table. */
.table-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.table-scroll::-webkit-scrollbar-track,
.table-scroll::-webkit-scrollbar-corner { background: transparent; }
.table-scroll::-webkit-scrollbar-thumb {
  border: 2px solid transparent;
  border-radius: 999px;
  background: var(--scroll-thumb);
  background-clip: padding-box;
}
.table-scroll::-webkit-scrollbar-thumb:hover {
  background: var(--scroll-thumb-hover);
  background-clip: padding-box;
}

.cmp-table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 12.5px; }
.cmp-table th, .cmp-table td { text-align: left; vertical-align: top; padding: 8px 12px; }
.cmp-table tbody tr + tr th, .cmp-table tbody tr + tr td { border-top: 1px solid var(--border-soft); }
.cmp-table thead th {
  position: sticky; top: 0;
  z-index: 3; background: var(--surface);
  box-shadow: inset 0 -1px 0 var(--border);
}
/* The pinned column carries an edge of its own. Without one, a cell scrolling
 * sideways under it reads as a cell being cut off rather than as a column
 * staying where it is — and the corner keeps the head's underline as well as
 * the column's edge, being both. */
.cmp-table tbody th[scope='row'] {
  position: sticky; left: 0; z-index: 2;
  background: var(--surface);
  box-shadow: 1px 0 0 var(--border-soft);
  min-width: 132px; max-width: 220px;
  color: var(--text-muted); font-weight: 500; cursor: help;
}
.cmp-table thead th.corner {
  left: 0; z-index: 4;
  box-shadow: inset 0 -1px 0 var(--border), 1px 0 0 var(--border-soft);
  color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
}
.cmp-table td { min-width: 148px; font-variant-numeric: tabular-nums; }
.cmp-table td.is-absent { color: var(--text-faint); }

/* One column head: the thumbnail, so the columns can be told apart at a
 * glance, and a way back to the robot itself. */
.col-head { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 0 9px; color: inherit; text-decoration: none; }
.col-head img { grid-row: 1 / 4; width: 34px; height: 34px; object-fit: contain; }
.col-head .col-name { font-size: 13px; font-weight: 700; line-height: 1.3; }
.col-head .col-sub { color: var(--text-muted); font-size: 10.5px; font-weight: 400; line-height: 1.3; }
.col-head .col-variant {
  grid-column: 1 / -1; margin-top: 3px;
  color: var(--text-faint); font-family: var(--font-mono); font-size: 10px; font-weight: 400;
  overflow-wrap: anywhere;
}
.col-head:hover .col-name { color: var(--accent); }

/* A heading that spans every column cannot be pinned by pinning the cell: a
 * sticky box only shifts inside its containing block, and this one already is
 * the whole row, so a table scrolled sideways used to carry "Thumb" off the
 * left edge and leave a band with nothing written on it. It is the label that
 * stays, inside the band — pinned to where the row labels below it begin, so
 * scrolled or not it sits in the same column of text. */
.cmp-table .group-row th {
  background: var(--bg-alt); color: var(--text-faint);
  font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.09em;
  padding: 6px 12px;
}
.cmp-table .group-row th > span { position: sticky; left: 12px; display: inline-block; }

.cell-value { display: block; line-height: 1.45; }
.cell-value .sub, .cell-sub { display: block; color: var(--text-faint); font-size: 10.5px; line-height: 1.4; overflow-wrap: anywhere; }
.cmp-table td.is-max .cell-value { color: var(--accent); font-weight: 700; }
.cell-bar { display: block; height: 3px; margin: 5px 0 3px; border-radius: 2px; background: var(--border-soft); }
.cell-bar i { display: block; height: 100%; border-radius: 2px; background: var(--accent-line); }
.cmp-table td.is-max .cell-bar i { background: var(--accent); }

/* A travel drawn where it lies. The whole row shares one scale, so two joints
 * with the same span are not drawn the same when one of them is centred on
 * zero and the other is not; the upright is where zero falls. */
.range-bar { position: relative; display: block; height: 6px; margin: 6px 0 4px; border-radius: 3px; background: var(--border-soft); }
.range-bar i { position: absolute; top: 0; height: 100%; min-width: 2px; border-radius: 3px; background: var(--accent-line); }
.cmp-table td.is-max .range-bar i { background: var(--accent); }
.range-bar b { position: absolute; top: -2px; width: 1px; height: 10px; background: var(--text-faint); }

.spread-col { min-width: 56px; color: var(--text-faint); font-family: var(--font-mono); font-size: 11px; }
.cell-sub .guess { color: var(--warn); font-style: normal; cursor: help; }

/* On a chain the row says where along it a joint is, not what it does, so the
 * cell says which way that joint turns — and marks the machine whose chain
 * stops here while the others carry on. */
.cell-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 5px 0 0; }
.cell-tags i {
  border-radius: 3px; padding: 1px 4px;
  background: var(--bg-alt); color: var(--text-faint);
  font-size: 9.5px; font-style: normal; letter-spacing: 0.05em; line-height: 1.5;
  text-transform: uppercase;
}
.cell-tags .tip-tag { background: transparent; color: var(--warn); box-shadow: inset 0 0 0 1px currentColor; cursor: help; }

.cmp-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 12px; }
.cmp-tools:empty { display: none; }
.tools-label { color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
.cmp-tools .link-btn[aria-pressed='true'] { color: var(--accent); }

.cmp-note { margin: 10px 0 0; color: var(--text-faint); font-size: 11.5px; line-height: 1.65; max-width: 96ch; }
.leftovers { margin-top: 12px; font-size: 12px; }
.leftovers summary { color: var(--text-muted); cursor: pointer; }
.leftovers p { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: 8px 0 0; }
.leftover-name { font-weight: 600; }
.leftover-list { display: flex; flex-wrap: wrap; gap: 4px; }
.leftovers code { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-faint); }

/* ── Responsive ─────────────────────────────────────────── */

@media (max-width: 1000px) {
  /* The row of three is all three or none: two of them across leaves the third
   * alone on a row of its own, half a card wide with a half card of nothing
   * beside it. Stacked, every card has the width and none is an odd one out. */
  .stage-extra, .stage-extra[data-mode='local'] { grid-template-columns: minmax(0, 1fr); }
  .stage { height: min(56vh, 500px); }
}

@media (max-width: 720px) {
  /* A comparison is a wide thing on a narrow screen: the cards give up their
   * side padding to the table, and the pinned row labels give up half their
   * width, so that two columns of numbers still fit beside them. */
  .pick-list { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
  .cmp-table tbody th[scope='row'] { min-width: 96px; }
  .cmp-table td { min-width: 128px; }
  .cmp-table th, .cmp-table td { padding: 7px 9px; }

  /* The bar stays one row at every width — a wrapping header would no longer
   * match the space `.view` reserves for the fixed bar. The title truncates to
   * make room. */
  .header-inner { gap: 0.6rem; padding-left: 16px; padding-right: 16px; }
  .site-title { font-size: 1rem; }
  .header-right { gap: 0.4rem; }
  /* The word "GitHub" is what costs the room here, not the link, so the label
   * gives way to the mark and the link stays in the row — in the toggles' own
   * box, so the three controls beside the search read as one set. */
  .github-link {
    min-width: 32px;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
  }
  .github-link:hover { border-color: var(--accent); }
  .github-label { display: none; }
  .github-mark { display: block; }
  /* A search field wide enough to type in leaves the bilingual title with a
   * few characters, so on phones it collapses to its icon — the whole label
   * is the hit area — and expands again while it has focus. */
  .search { padding: 0 7px; gap: 0; }
  .search input { width: 0; transition: width var(--transition); }
  .search:focus-within { gap: 7px; }
  .search:focus-within input { width: clamp(110px, 42vw, 200px); }
  .lang-toggle, .theme-toggle { min-width: 32px; padding: 0 0.45rem; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 12px; }
  .sections { gap: 24px; }
  .cat-heading { font-size: 15.5px; margin-bottom: 12px; }
  /* Match the card grid's column width so short maker groups can share a row
   * on phones too — the desktop minmax(224px) only ever yields one column here. */
  .maker-groups {
    grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
    gap: 18px 12px;
  }
  .maker-heading { margin-bottom: 8px; }
  /* The chips are navigation now and the bar is sticky, so it stays one row:
   * wrapped, seven categories cost a third of a phone screen for the whole
   * scroll. js/gallery.js keeps the section being read scrolled into it. */
  .filters { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
  .filters::-webkit-scrollbar { display: none; }
  .filters button { flex: 0 0 auto; }
  /* Above the fold a phone has about 700px to spend, and the header, the
   * heading, the lede and the counters were spending 300 of them before the
   * first button: every band in the hero closes up a little so that the doors,
   * and the first row of robots under them, arrive higher up the screen. */
  .hero { padding: 24px 0 14px; }
  .hero > p { margin-top: 10px; line-height: 1.6; }
  /* Wrapped onto two lines the hairlines would lead a row, so narrow screens
   * separate the counters by space alone. */
  .hero-stats { gap: 2px 16px; margin-top: 15px; }
  .hero-stats > div + div { padding-left: 0; border-left: 0; }
  /* Two buttons never fit beside each other at this width, so the row becomes
   * a stack of the same two blocks — each button the full width of the screen,
   * its own note under it. Proximity does the grouping that the columns did:
   * a note sits tight under the button it belongs to, and twice that space
   * separates one door from the other, so the four lines still read as two
   * things rather than as a list. */
  .hero-actions { flex-direction: column; align-items: stretch; gap: 15px; margin-top: 18px; }
  .hero-action { align-self: stretch; gap: 7px; }
  .hero-action .primary-btn, .hero-action .hero-compare-btn { width: 100%; }
  /* A filename can be longer than the screen; it wraps rather than running out
   * of it, and breaks mid-word if that is what it takes. */
  .hero-action-current { overflow-wrap: anywhere; }
  .custom-dialog { padding: 18px; }
  .custom-github-row { flex-direction: column; }
  .custom-github-row .ghost-btn { width: 100%; }
  .custom-actions .primary-btn { margin-left: 0; width: 100%; }
  .hero-stats dd { font-size: 17px; }
  .stage-hint { display: none; }
  /* The overlay toggles do not fit on one narrow line; let them scroll rather
   * than pushing the rest of the toolbar off screen. Stacked, the legend takes
   * the full width — it is scrolling, so it always has more to show — and the
   * icon row is the narrow one, centred under it rather than hanging off the
   * left edge with the width of the stage empty beside it. */
  .stage-toolbar { flex-direction: column; align-items: center; }
  #overlay-toggles { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
  #overlay-toggles::-webkit-scrollbar { display: none; }

  /* Two stacked rows of chrome is a fifth of a phone's stage, and floating them
   * on the render puts that fifth over the robot's feet — which is where a
   * humanoid stands. So on the page they come off the render altogether and
   * stand under it, on the page's own background: the frame closes around the
   * picture alone, and the controls read as controls for it rather than as
   * something painted over it. Nothing is drawn to divide them — a gap does
   * that, and the groups already carry their own outline. The fullscreen
   * layout has the height to float them and keeps doing so. */
  #view-detail:not(.stage-fullscreen) .stage {
    --stage-canvas-h: min(52vh, 440px);
    display: flex; flex-direction: column; gap: 10px;
    height: auto; min-height: 0;
    /* The frame moves to the render below; what is left is the column that
     * holds it and the bar, and it has no surface of its own. */
    background: none; border: 0; border-radius: 0; box-shadow: none;
    overflow: visible;
  }
  /* Out of the corner it was pinned to and into the column, wearing the frame
   * the stage used to wear. The renderer follows its host, so this is all it
   * takes for the robot to re-centre in what is left. */
  #view-detail:not(.stage-fullscreen) .canvas-host {
    position: relative; inset: auto;
    height: var(--stage-canvas-h);
    background: var(--studio);
    border: 1px solid var(--border); border-radius: var(--radius);
    overflow: hidden;
    box-shadow: var(--shadow-sm);
  }
  /* The veil belongs to the picture, so it stops where the picture does —
   * corners and all — and leaves the controls under it alone. */
  #view-detail:not(.stage-fullscreen) .stage-loading,
  #view-detail:not(.stage-fullscreen) .stage-error {
    bottom: auto; height: var(--stage-canvas-h);
    border-radius: var(--radius);
  }
  #view-detail:not(.stage-fullscreen) .stage-toolbar { position: static; }
  /* A full-height column would leave a phone a sliver of render beside it, so
   * in fullscreen it becomes a bottom row instead: the tree across the foot of
   * the screen, the robot centred in the space above it, and the toolbar in
   * between — the same order as the desktop, stacked instead of side by side.
   *
   * Three bands, stacked from the bottom up, each measured off the one below
   * it: the tree's own height and the bar's measured one are what the render's
   * floor is made of, so the robot stands clear of both however tall the bar
   * turns out to be — two rows on a phone, where the legend takes a line of its
   * own and the icons another. */
  #view-detail.stage-fullscreen { --fs-tree-h: min(38vh, 320px); }
  #view-detail.stage-fullscreen .tree-block {
    top: auto;
    height: var(--fs-tree-h);
    bottom: calc(12px + env(safe-area-inset-bottom, 0px));
    left: 12px; right: 12px; width: auto;
  }
  /* The row is the whole width there is, and it is below the render rather than
   * beside it, so there is nothing left for a width handle to win. */
  #view-detail.stage-fullscreen .panel-resize { display: none; }
  #view-detail.stage-fullscreen .stage-toolbar {
    left: 12px; right: 12px;
    bottom: calc(var(--fs-tree-h) + 20px + env(safe-area-inset-bottom, 0px));
  }
  /* Everything above the bar. The fallback is the two rows a phone always
   * gives it, so the render clears them even before the measurement lands. */
  #view-detail.stage-fullscreen .canvas-host {
    left: 0;
    bottom: calc(var(--fs-tree-h) + var(--fs-bar-h, 86px) + 30px + env(safe-area-inset-bottom, 0px));
  }
  /* Three lines of instructions in a panel this narrow cost more rows of tree
   * than they are worth — and hovering, which is half of what they describe,
   * is not what a phone does anyway. */
  #view-detail.stage-fullscreen .tree-hint { display: none; }
  /* Every level of nesting comes off the far end of the slider under it, and a
   * leg is four levels deep: on a 390px phone the deepest sliders had 117px
   * left to travel in, where the shallow ones had 222. The guides step half as
   * far here and the sliders start where the rows do rather than past the
   * twisty — a knee is no easier to find for the 37px of indentation that
   * showed it was a knee, and much harder to move. */
  .tree ul { margin-left: 2px; padding-left: 6px; }
  .tree-slider { margin-left: 6px; }
}

/* Dragging a 12px edge is a mouse's trick. Where the pointer is a fingertip the
 * columns keep the width the stylesheet gives them — the handle would only be
 * one more thing to hit by accident while scrolling the list beside it. */
@media (pointer: coarse) {
  #view-detail.stage-fullscreen .panel-resize { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
