/* ═══════════════════════════════════════════════════════════
   Robot URDF Gallery
   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);

  /* 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;
  --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);
  --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;
}

/* 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; }
.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;
}

.hero-custom { margin: 24px 0 0; display: flex; flex-direction: column; align-items: flex-start; gap: 9px; }
/* The promise, in the smallest type on the page but never behind a click: the
 * visitor decides whether to hand over a file while reading this line.
 *
 * No measure of its own. It is one sentence, and at the hero's own 760px it is
 * one line in both languages — narrowing it to a reading measure would only
 * break that line early and leave a two-word stub under a column edge that
 * lines up with nothing. `align-items: flex-start` on the parent already keeps
 * it to its content, so it never runs wider than the lede above it, and on a
 * phone it wraps against the same edge as everything else. */
.hero-custom-note {
  display: flex; gap: 7px; align-items: baseline;
  margin: 0;
  color: var(--text-faint); font-size: 12.5px; line-height: 1.55;
}
.hero-custom-current { margin: 0; font-size: 12.5px; }
.hero-custom-current a { color: var(--accent); text-decoration: none; }
.hero-custom-current a:hover { text-decoration: underline; }

/* ── 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);
}

/* ── 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.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; }
.detail-nav { margin-left: auto; display: flex; gap: 8px; }

/* ── 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; }

/* 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; }

/* 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'] {
  -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: 3px; border-radius: 20px; background: var(--border);
}
.tree-slider input[type='range']::-moz-range-track {
  height: 3px; border-radius: 20px; background: var(--border);
}
.tree-slider input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
  background: var(--accent); cursor: pointer; border: 2px solid var(--surface);
  margin-top: -6.5px; /* centre the 16px thumb on the 3px track */
}
.tree-slider input[type='range']::-moz-range-thumb {
  width: 16px; height: 16px; 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); }

/* ── 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); }

/* 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 { 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, 22vw, 340px));
  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, 22vw, 340px)) + 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, 22vw, 340px)) + 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);
}
.drop-zone:hover, .drop-zone.is-over { 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; }

/* 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;
}

.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)); }

/* ── 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) {
  /* 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; }
  /* 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; }
  /* Wrapped onto two lines the hairlines would lead a row, so narrow screens
   * separate the counters by space alone. */
  .hero-stats { gap: 2px 16px; }
  .hero-stats > div + div { padding-left: 0; border-left: 0; }
  /* Nothing sits beside it at this width, so it may as well be the full row. */
  .hero-custom { align-self: stretch; }
  .hero-custom .primary-btn { width: 100%; }
  .custom-dialog { padding: 18px; }
  .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; }
}

/* 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; }
}
