/* ==========================================================================
   LatticeInfer — design system
   --------------------------------------------------------------------------
   Every colour in a component rule resolves to a token declared below. The only
   literal hex values in this file live inside the three token blocks
   (`:root`, the dark media-query override, and the `data-theme` override).
   Colours that belong to the *data* (node fills, edge strokes, legend ramps)
   are not CSS at all: they arrive in the payload and are applied by app.js as
   SVG presentation attributes or CSSOM `background` values.
   ========================================================================== */

:root {
  color-scheme: light dark;

  /* -- raw palette: light ------------------------------------------------- */
  --p-bg:            #eef1f6;
  --p-surface:       #ffffff;
  --p-surface-sunk:  #f5f7fb;
  --p-surface-alt:   #eaeef5;
  --p-border:        #cfd6e3;
  --p-border-strong: #aab4c6;
  --p-text:          #16202f;
  --p-text-muted:    #59657c;
  --p-text-faint:    #606f86;
  --p-accent:        #2a5ddb;
  --p-accent-strong: #1b47b4;
  --p-accent-ink:    #ffffff;
  --p-accent-soft:   #e6edfd;
  --p-danger:        #b4231c;
  --p-danger-soft:   #fdeceb;
  --p-warn:          #8a5a06;
  --p-warn-soft:     #fdf3e0;
  --p-success:       #186a45;
  --p-success-soft:  #e5f5ec;
  --p-info-soft:     #e8f1fb;
  --p-viewer:        #fbfcfe;
  --p-node-ring:     #16202f;
  --p-focus:         #2a5ddb;
  --p-shadow-1:      rgb(16 24 40 / 0.06);
  --p-shadow-2:      rgb(16 24 40 / 0.16);
  /* The scrim behind a modal. A token rather than a literal in the
     `::backdrop` rule, because component rules may not carry colour
     literals -- the test that enforces that would reject `rgba(...)`. */
  --p-scrim:         rgb(16 24 40 / 0.42);

  /* -- semantic tokens: single-sourced, never overridden ------------------ */
  --bg:            var(--p-bg);
  --surface:       var(--p-surface);
  --surface-sunk:  var(--p-surface-sunk);
  --surface-alt:   var(--p-surface-alt);
  --border:        var(--p-border);
  --border-strong: var(--p-border-strong);
  --text:          var(--p-text);
  --text-muted:    var(--p-text-muted);
  --text-faint:    var(--p-text-faint);
  --accent:        var(--p-accent);
  --accent-strong: var(--p-accent-strong);
  --accent-ink:    var(--p-accent-ink);
  --accent-soft:   var(--p-accent-soft);
  --danger:        var(--p-danger);
  --danger-soft:   var(--p-danger-soft);
  --warn:          var(--p-warn);
  --warn-soft:     var(--p-warn-soft);
  --success:       var(--p-success);
  --success-soft:  var(--p-success-soft);
  --info-soft:     var(--p-info-soft);
  --viewer-bg:     var(--p-viewer);
  --node-ring:     var(--p-node-ring);
  --focus-ring:    var(--p-focus);
  --shadow-1:      var(--p-shadow-1);
  --shadow-2:      var(--p-shadow-2);
  --scrim:         var(--p-scrim);

  /* -- spacing scale: 4px base -------------------------------------------- */
  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-8: 32px;
  --space-10: 40px;

  /* -- radius scale -------------------------------------------------------- */
  --radius-2xs: 2px;
  --radius-xs: 3px;
  --radius-sm: 5px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-pill: 999px;

  /* -- type scale ---------------------------------------------------------- */
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
               "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
  --font-mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas,
               "DejaVu Sans Mono", monospace;
  --text-2xs: 10.5px;
  --text-xs:  11.5px;
  --text-sm:  12.5px;
  --text-md:  13.5px;
  --text-lg:  15px;
  --text-xl:  18px;
  --text-2xl: 22px;
  --leading-tight: 1.25;
  --leading-base:  1.5;

  /* -- z-index scale ------------------------------------------------------- */
  --z-base: 0;
  --z-raised: 10;
  --z-sticky: 20;
  --z-tooltip: 40;
  --z-modal: 80;

  /* -- raw palette: dark --------------------------------------------------
     Declared once, here, and aliased by both dark entry points below. Those two
     blocks used to carry the same 24 literals each, byte-identical apart from
     their indentation, and tests/test_design_system.py compared only their key
     *sets* -- so a colour tuned in one copy and forgotten in the other stayed
     green. One source means the second copy cannot drift. */
  --dark-bg:            #08090a;
  --dark-surface:       #0f1011;
  --dark-surface-sunk:  #0a0b0c;
  --dark-surface-alt:   #17181a;
  --dark-border:        #1f2021;
  --dark-border-strong: #303134;
  --dark-text:          #f7f8f8;
  --dark-text-muted:    #a4adbe;
  --dark-text-faint:    #778191;
  --dark-accent:        #8a91f0;
  --dark-accent-strong: #a5aaf5;
  --dark-accent-ink:    #0b0b0d;
  --dark-accent-soft:   #1a1b2e;
  --dark-danger:        #f0837a;
  --dark-danger-soft:   #2b1616;
  --dark-warn:          #e0b062;
  --dark-warn-soft:     #2a2013;
  --dark-success:       #6cc79a;
  --dark-success-soft:  #13251c;
  --dark-info-soft:     #141a24;
  --dark-viewer:        #0b0c0d;
  --dark-node-ring:     #e8e9ea;
  --dark-focus:         #a5aaf5;
  --dark-shadow-1:      rgb(0 0 0 / 0.5);
  --dark-shadow-2:      rgb(0 0 0 / 0.7);
  --dark-scrim:         rgb(0 0 0 / 0.62);

  /* -- control metrics ----------------------------------------------------- */
  --control-height: 30px;
  --panel-radius: var(--radius-lg);

  /* -- motion -------------------------------------------------------------- */
  --duration-fast: 120ms;
  --duration-base: 180ms;
  --duration-slow: 280ms;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --transition: var(--duration-fast) var(--ease-out);
}

/* Dark mode: override the raw palette only, so the semantic layer above stays
   single-sourced. The ramp is Linear's — a near-black that is very slightly
   warm rather than blue, hairlines lifted only a little above the surface, and
   their violet as the accent, so hierarchy is carried by spacing and weight
   instead of by outlines.
   `--surface` is deliberately not pure black (#000): a plot surface that dark
   destroys the perceived contrast of the viridis/twilight ramps drawn on it.
   Every pair below is measured against WCAG AA by tests/test_design_system.py,
   which fails if a colour is tuned for looks at the cost of legibility. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --p-bg:            var(--dark-bg);
    --p-surface:       var(--dark-surface);
    --p-surface-sunk:  var(--dark-surface-sunk);
    --p-surface-alt:   var(--dark-surface-alt);
    --p-border:        var(--dark-border);
    --p-border-strong: var(--dark-border-strong);
    --p-text:          var(--dark-text);
    --p-text-muted:    var(--dark-text-muted);
    --p-text-faint:    var(--dark-text-faint);
    --p-accent:        var(--dark-accent);
    --p-accent-strong: var(--dark-accent-strong);
    --p-accent-ink:    var(--dark-accent-ink);
    --p-accent-soft:   var(--dark-accent-soft);
    --p-danger:        var(--dark-danger);
    --p-danger-soft:   var(--dark-danger-soft);
    --p-warn:          var(--dark-warn);
    --p-warn-soft:     var(--dark-warn-soft);
    --p-success:       var(--dark-success);
    --p-success-soft:  var(--dark-success-soft);
    --p-info-soft:     var(--dark-info-soft);
    --p-viewer:        var(--dark-viewer);
    --p-node-ring:     var(--dark-node-ring);
    --p-focus:         var(--dark-focus);
    --p-shadow-1:      var(--dark-shadow-1);
    --p-shadow-2:      var(--dark-shadow-2);
    --p-scrim:         var(--dark-scrim);
  }
}

/* Explicit override hook, so an embedder can pin a theme without a JS
   colour-scheme listener. */
:root[data-theme="dark"] {
  --p-bg:            var(--dark-bg);
  --p-surface:       var(--dark-surface);
  --p-surface-sunk:  var(--dark-surface-sunk);
  --p-surface-alt:   var(--dark-surface-alt);
  --p-border:        var(--dark-border);
  --p-border-strong: var(--dark-border-strong);
  --p-text:          var(--dark-text);
  --p-text-muted:    var(--dark-text-muted);
  --p-text-faint:    var(--dark-text-faint);
  --p-accent:        var(--dark-accent);
  --p-accent-strong: var(--dark-accent-strong);
  --p-accent-ink:    var(--dark-accent-ink);
  --p-accent-soft:   var(--dark-accent-soft);
  --p-danger:        var(--dark-danger);
  --p-danger-soft:   var(--dark-danger-soft);
  --p-warn:          var(--dark-warn);
  --p-warn-soft:     var(--dark-warn-soft);
  --p-success:       var(--dark-success);
  --p-success-soft:  var(--dark-success-soft);
  --p-info-soft:     var(--dark-info-soft);
  --p-viewer:        var(--dark-viewer);
  --p-node-ring:     var(--dark-node-ring);
  --p-focus:         var(--dark-focus);
  --p-shadow-1:      var(--dark-shadow-1);
  --p-shadow-2:      var(--dark-shadow-2);
  --p-scrim:         var(--dark-scrim);
}

/* ==========================================================================
   reset
   ========================================================================== */

*,
*::before,
*::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  /* dvh, not a magic px offset: the header wraps freely at narrow widths and
     the grid below still receives exactly the remaining height. */
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--text-md);
  line-height: var(--leading-base);
  /* tabular numerals everywhere; falls back gracefully on fonts without the
     feature. Keeps the right-aligned kv columns in lockstep. */
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1, "cv11" 1;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

[hidden] { display: none !important; }

h1, h2, h3, p, dl, dd, figure, ul, fieldset, legend, table { margin: 0; }
ul { padding: 0; list-style: none; }
fieldset { border: 0; padding: 0; min-width: 0; }

code, kbd, pre, .mono, .num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

code {
  padding: 1px var(--space-1);
  border-radius: var(--radius-2xs);
  background: var(--surface-sunk);
  font-size: 0.92em;
}

kbd {
  padding: 1px var(--space-1);
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: var(--radius-xs);
  background: var(--surface-alt);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: 600;
}

/* The small bilingual suffix on field labels: a label is now "维度 dim"
   with the English half visually demoted to a quiet sub-label, so the
   Chinese reads as the primary identifier and the English rides along for
   free. */
.field__sub {
  margin-left: var(--space-1);
  color: var(--text-faint);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: 0.02em;
}

::selection {
  background: var(--accent-soft);
  color: var(--accent-strong);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  z-index: var(--z-modal);
  top: var(--space-2);
  left: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  background: var(--accent);
  color: var(--accent-ink);
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-200%);
}
.skip-link:focus-visible { transform: none; }

/* ==========================================================================
   scrollbars -- these apply page-wide (the panels, the dialog, the raw
   summary, the document itself), not only to the panel scroll surfaces the
   original comment promised.
   ========================================================================== */

/* Firefox has no ::-webkit-scrollbar; these are its thin equivalents, same
   thumb colour as the WebKit rules below. */
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--border-strong);
  border-radius: var(--radius-pill);
  border: 2px solid transparent;
  background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
::-webkit-scrollbar-corner { background: transparent; }

/* ==========================================================================
   focus
   ========================================================================== */

:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  /* No border-radius here on purpose: it restyled the *element* while it was
     focused -- a 8px card snapping to 5px for the length of the focus --
     when the ring alone is the signal. Outlines already follow the element's
     own radius. */
}

/* ==========================================================================
   header
   ========================================================================== */

.app-header {
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--border);
  background: linear-gradient(
    180deg,
    var(--surface) 0%,
    var(--surface-sunk) 100%
  );
  position: relative;
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 0;
  /* 品牌区现在是指向首页的链接；去掉链接默认样式，让它与纯文本时一致。 */
  color: inherit;
  text-decoration: none;
}
/* 顶栏品牌图标与 favicon 同一张图（icon.jpg）；还没推断时结构视图正中另有
   六格点阵的放大版（.viewer-empty__mark 下方），与此图标无关。 */
.brand__mark {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  border-radius: var(--radius-md);
  box-shadow: 0 0 0 1px var(--border);
  object-fit: cover;
}
.brand__text { min-width: 0; }
/* 品牌副标题与标题同行，用 --text-muted 小字；作为可收缩的 flex 项，
   宽度不够时先省略号截断，480px 以下整条隐藏（见文末媒体查询）。 */
.brand__tagline {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.app-header h1 {
  font-size: var(--text-xl);
  line-height: var(--leading-tight);
  letter-spacing: -0.012em;
  background: linear-gradient(
    90deg,
    var(--text) 0%,
    var(--accent-strong) 100%
  );
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: var(--text);
  font-weight: 700;
}

.app-header__meta {
  margin-left: auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* --------------------------------------------------------------------------
   About dialog: the one place that explains what the tool actually does and
   lists every shortcut. A native <dialog> rather than a hand-rolled overlay,
   so the focus trap, the Escape key and the inert background are the
   browser's, not ours.
   -------------------------------------------------------------------------- */
.about-trigger {
  display: inline-flex;
  align-items: center;
  /* Was a literal 30px: the 1800/2560 steps raise --control-height to 34/38
     for every other control, and the header trio alone stayed small. */
  height: var(--control-height);
  padding: 0 var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 500;
  cursor: pointer;
  transition: background var(--transition), color var(--transition),
              border-color var(--transition);
}
.about-trigger:hover {
  background: var(--surface);
  color: var(--text);
  border-color: var(--border-strong);
}

.about {
  /* The dialog's single text column's padding. The sticky head takes it as a
     negative margin to reach the dialog's edges, and the 480px band trims
     both through the same variable. */
  --about-pad: var(--space-6);
  width: min(680px, calc(100vw - var(--space-8)));
  /* dvh with a vh fallback, same as the body: on a phone in landscape the
     visual viewport is shorter than 100vh and a vh-tall dialog loses its
     close button under the browser chrome. */
  max-height: calc(100vh - var(--space-10));
  max-height: calc(100dvh - var(--space-10));
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--panel-radius);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-2);
  /* The dialog itself is the scroll container. An inner scroller was tried
     first, and the browser's own focus step scrolled *it* down past the
     title and the lede the moment the dialog opened -- so the explanation
     started mid-sentence. One scroller, and the one the browser focuses. */
  overflow: auto;
}
.about::backdrop { background: var(--scrim); }
.about__body {
  padding: 0 var(--about-pad) var(--about-pad);
}
.about__head {
  /* Sticky, so the title and the close button stay reachable while the
     explanation scrolls under them. The negative margin pulls the head out
     to the body's own edges, so its background covers the text it scrolls
     over; the padding puts the text back where it was. */
  position: sticky;
  top: 0;
  z-index: var(--z-raised);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin: 0 calc(-1 * var(--about-pad)) var(--space-4);
  padding: var(--space-5) var(--about-pad) var(--space-3);
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}
.about__title {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: 700;
}
.about__close { flex: none; }
.about__lede {
  margin: 0;
  color: var(--text);
  font-size: var(--text-md);
  line-height: 1.7;
}
/* Developer line: part of "what this is", but one tier quieter than the
   lede it sits under. */
.about__byline {
  margin: var(--space-2) 0 0;
  color: var(--text-muted);
  font-size: var(--text-xs);
}
/* No global anchor colour exists (the page had no links until here), so
   links inside the dialog are styled where they appear. */
.about a {
  color: var(--accent);
  text-underline-offset: 2px;
  /* The visible text is the full URL: an unbroken token that would otherwise
     overflow the dialog at narrow widths. */
  overflow-wrap: anywhere;
}
.about a:hover { color: var(--accent-strong); }
.about__section { margin-top: var(--space-5); }
/* Hairlines between the usage sections: the fold already separated itself
   this way, so the whole dialog now reads with one rhythm -- lede, each
   section, the principle, each under its own rule. */
.about__section + .about__section {
  border-top: 1px solid var(--border);
  padding-top: var(--space-4);
}
.about__h {
  margin: 0 0 var(--space-2);
  font-size: var(--text-md);
  font-weight: 700;
}
.about__text {
  margin: 0 0 var(--space-2);
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.7;
}
.about__steps,
.about__list {
  margin: 0;
  padding-left: var(--space-5);
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.7;
}
.about__steps li + li,
.about__list li + li { margin-top: var(--space-1); }
/* Bodies stay quiet and the scanning is done by the leads: step numbers take
   the lane accent, list bullets recede, and a <b> lead-in reads at full
   strength against the muted text around it. */
.about__steps li::marker { color: var(--accent); font-weight: 600; }
.about__list li::marker { color: var(--text-faint); }
.about__steps b,
.about__list b,
.about__text b { color: var(--text); }
.about__table {
  width: 100%;
  margin: 0 0 var(--space-2);
  border-collapse: collapse;
  font-size: var(--text-sm);
}
.about__table th,
.about__table td {
  padding: var(--space-1) var(--space-2);
  border-bottom: 1px solid var(--border);
  text-align: left;
  vertical-align: middle;
}
/* Everything but the label column is short -- keys, 是/否, small integers --
   so centring it gives both tables one grid to scan down. */
.about__table th:not(:first-child),
.about__table td:not(:first-child) { text-align: center; }
.about__table th {
  color: var(--text-muted);
  font-weight: 600;
}
.about__table td {
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
/* Zebra bands carry the row separation, so the last row drops its rule and
   the table ends flush with its band. */
.about__table tbody tr:nth-child(even) { background: var(--surface-sunk); }
.about__table tbody tr:last-child td { border-bottom: 0; }
.about__note {
  margin: var(--space-2) 0 0;
}
/* `.callout` is declared later in this file and would win the line-height on
   equal specificity, so the override is qualified. */
.about__body .about__note { line-height: 1.7; }
.about code {
  font-family: var(--font-mono);
  font-size: 0.92em;
}
/* The principle is the "why" tier of the dialog -- worth reading, not needed
   to operate the tool -- so it starts folded, behind a rule that separates
   it from the usage sections. The ▸/▾ swap is the .adv__summary idiom. */
.about__fold {
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border);
}
.about__summary {
  display: inline-flex;
  align-items: baseline;
  gap: var(--space-2);
  margin-bottom: var(--space-1);
  font-size: var(--text-sm);
  font-weight: 700;
  color: var(--text);
  cursor: pointer;
  user-select: none;
  list-style: none;
  transition: color var(--transition);
}
.about__summary::-webkit-details-marker { display: none; }
.about__summary::before {
  content: "▸";
  color: var(--text-muted);
  font-weight: 400;
}
.about__fold[open] .about__summary { margin-bottom: var(--space-2); }
.about__fold[open] .about__summary::before { content: "▾"; }
.about__summary:hover { color: var(--accent-strong); }
/* Third-level heading inside the fold: the quietest of the dialog's three
   heading tiers (h3 md > fold summary sm > sub xs). */
.about__sub {
  margin: var(--space-4) 0 var(--space-2);
  font-size: var(--text-xs);
  font-weight: 700;
}

/* ==========================================================================
   layout
   ========================================================================== */

.layout {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  /* Anchor for the two absolutely-positioned .splitter bars, which track the
     column boundaries through the same --left-w / --right-w variables the
     grid itself reads. */
  position: relative;
  /* The centre column owns the viewer and grows. The side panels are fluid
     rather than fixed: clamp() lets each track a viewport-relative width
     between a readable floor and a measure ceiling, so the columns keep
     their proportions from 1240px laptops up to 2560px desktops without a
     breakpoint per step. The centre column takes everything left over and
     the canvas re-fits to it on every resize.
     Dragging a splitter overwrites --left-w/--right-w with a px value on
     this element's style attribute; 「恢复栏宽」removes those inline values
     and the tracks fall back to the clamp() defaults stated here. */
  --layout-gap: var(--space-4);
  --layout-pad: var(--space-4);
  --left-w: clamp(19rem, 12vw + 7rem, 26rem);
  --right-w: clamp(20rem, 14vw + 7rem, 30rem);
  grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
  grid-template-rows: minmax(0, 1fr);
  gap: var(--layout-gap);
  padding: var(--layout-pad);
}

/* Side-panel drag handles. They live in the grid's inter-column gap, centred
   by the same calc() the tracks are made of, so they stay glued to the column
   boundary whether the width comes from clamp() or from a drag. The 1.5rem
   band is a hit area, mostly transparent; the grip inside it fades up on
   hover/focus/drag. */
.splitter {
  position: absolute;
  top: var(--layout-pad);
  bottom: var(--layout-pad);
  width: 1.5rem;
  transform: translateX(-50%);
  border: 0;
  padding: 0;
  background: transparent;
  cursor: col-resize;
  /* The pointer can be pressed exactly on the 1px grip line: keep the whole
     band drag-first. */
  touch-action: none;
  z-index: var(--z-raised);
}
.splitter::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 3px;
  height: 44px;
  transform: translate(-50%, -50%);
  border-radius: var(--radius-2xs);
  background: var(--border-strong);
  opacity: 0.35;
  transition: opacity var(--transition);
}
.splitter:hover::before,
.splitter:focus-visible::before,
.splitter.is-dragging::before { opacity: 1; }
.splitter--left  { left: calc(var(--layout-pad) + var(--left-w) + var(--layout-gap) / 2); }
.splitter--right { left: calc(100% - var(--layout-pad) - var(--right-w) - var(--layout-gap) / 2); }
/* During a drag the pointer may leave the handle; the body-level class keeps
   the cursor and text-selection behaviour uniform across the window. */
body.is-resizing { cursor: col-resize; user-select: none; }
body.is-resizing * { cursor: col-resize; }

.panel {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--panel-radius);
  background: var(--surface);
  box-shadow: var(--shadow-1);
}

.panel__title {
  flex: 0 0 auto;
  margin-bottom: var(--space-3);
  color: var(--text);
  font-size: var(--text-lg);
  line-height: var(--leading-tight);
  font-weight: 700;
  letter-spacing: -0.005em;
}

.controls, .results { gap: var(--space-3); overflow: auto; overscroll-behavior: contain; }
.controls { display: flex; flex-direction: column; }
.controls > .panel__title { margin-bottom: 0; }


/* ==========================================================================
   fields
   ========================================================================== */

.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field + .field { margin-top: var(--space-3); }

.field__label {
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.02em;
}
/* An explicit token, not `opacity`: opacity attenuates a colour the palette
   has already been measured against, so the de-emphasis is invisible to
   test_design_system.py while still costing real contrast. 0.8 over
   --p-text-muted put this at 3.59:1 on white, under AA's 4.5:1 for text this
   size; --text-faint is the dimmest step that still measures 4.5:1 on every
   surface it can land on (5.10/4.51 light, 4.84/4.51 dark), and both values
   are pinned by that file. */
.field__label .unit { font-weight: 500; color: var(--text-faint); }
.field__label .req {
  margin-left: var(--space-1);
  padding: 0 var(--space-1);
  border-radius: var(--radius-xs);
  background: var(--accent-soft);
  color: var(--accent-strong);
  font-size: var(--text-2xs);
  font-weight: 700;
}

.field-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-3);
  margin-top: var(--space-3);
}
.field-grid .field + .field { margin-top: 0; }
.field--span { grid-column: 1 / -1; }

/* Advanced / secondary parameters collapsed by default. The summary is a
   quiet one-line pill so it does not compete with the primary input above. */
.adv {
  margin-top: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  overflow: hidden;
}
.adv__summary {
  padding: var(--space-1) var(--space-2);
  color: var(--text-faint);
  font-size: var(--text-xs);
  font-weight: 600;
  cursor: pointer;
  user-select: none;
  list-style: none;
  transition: color var(--transition);
}
.adv__summary::-webkit-details-marker { display: none; }
/* The echo is the collapsed panel's only visible state: it names whichever
   advanced values differ from the defaults, so a preset that writes dim or
   cell_shape behind the fold still announces itself. Quieter than the
   summary label so it reads as a status, not a heading. */
.adv__echo {
  margin-left: var(--space-2);
  color: var(--text-muted);
  font-weight: 500;
}
.adv__summary::before {
  content: "▸ ";
  display: inline-block;
  margin-right: 2px;
  transition: transform var(--transition);
}
.adv[open] .adv__summary::before { content: "▾ "; }
.adv__summary:hover { color: var(--text-muted); }
.adv > .field-grid {
  padding: var(--space-2) var(--space-3) var(--space-3);
  margin-top: 0;
  border-top: 1px solid var(--border);
}

input[type="text"],
input[type="number"],
select,
textarea {
  width: 100%;
  min-width: 0;
  padding: 6px var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  color: var(--text);
  font: inherit;
  font-size: var(--text-sm);
  line-height: 1.35;
  transition: border-color var(--transition), box-shadow var(--transition),
              background var(--transition);
}
input[type="checkbox"] { accent-color: var(--accent); }
input:hover, select:hover, textarea:hover { border-color: var(--border-strong); }
input:focus-visible, select:focus-visible, textarea:focus-visible {
  /* The generic ring is suppressed only so the accent border can stay -- the
     ring itself must remain a real indication: the old 3px --accent-soft
     halo measures about 1.1:1 against the sunk surface, which WCAG 2.2's
     focus-appearance rule does not count. --focus-ring is the token measured
     at >=4.5:1 on both themes. */
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
  border-color: var(--accent);
}

textarea {
  resize: vertical;
  line-height: 1.45;
  white-space: pre;
  overflow: auto;
  tab-size: 2;
}
.code-area {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}
.code-area--tall  { height: 168px; min-height: 96px; }

.note {
  color: var(--text-muted);
  font-size: var(--text-xs);
  line-height: 1.45;
}
.note:empty { display: none; }

.empty-note {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-4);
  border: 1px dashed var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-sunk);
  color: var(--text-muted);
  font-size: var(--text-xs);
  line-height: 1.5;
  text-align: center;
}
.empty-note__title {
  margin: 0;
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 700;
}
.empty-note__body {
  margin: 0;
  max-width: 36ch;
  color: var(--text-muted);
  font-size: var(--text-xs);
  line-height: 1.55;
}

/* ==========================================================================
   toasts
   ========================================================================== */

.toast-stack {
  position: fixed;
  z-index: var(--z-tooltip);
  right: var(--space-4);
  bottom: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  pointer-events: none;
  max-width: 340px;
}
.toast {
  pointer-events: auto;
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border-strong);
  border-left-width: 3px;
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-2);
  color: var(--text);
  font-size: var(--text-xs);
  line-height: 1.5;
  animation: toast-in var(--duration-base) var(--ease-out);
}
.toast__icon {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--accent-ink);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-2xs);
  font-weight: 700;
}
.toast--ok    { border-left-color: var(--success); }
.toast--ok    .toast__icon { background: var(--success); color: var(--surface); }
.toast--warn  { border-left-color: var(--warn); }
.toast--warn  .toast__icon { background: var(--warn); color: var(--surface); }
.toast--error { border-left-color: var(--danger); }
.toast--error .toast__icon { background: var(--danger); color: var(--surface); }
.toast__title {
  margin: 0;
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 700;
}
.toast__body {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-xs);
  word-break: break-word;
}
@keyframes toast-in {
  from { transform: translateY(8px); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
}

/* ==========================================================================
   input panel
   ========================================================================== */

.input-panel {
  display: flex;
  flex-direction: column;
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-sunk);
  gap: var(--space-3);
}
.input-panel__legend {
  padding: 0 var(--space-1);
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 700;
}

/* ==========================================================================
   buttons
   ========================================================================== */

/* The inputs scroll, the primary action does not. On a 1280x720 laptop the
   input fields alone overflow the panel, so a Run button that scrolls away is a
   Run button nobody presses. The dock is pulled by the panel's own padding so
   it sits flush against the border instead of floating a gutter above it. */
.dock {
  position: sticky;
  bottom: calc(-1 * var(--space-4));
  z-index: var(--z-sticky);
  display: grid;
  gap: var(--space-2);
  margin-top: auto;
  margin-inline: calc(-1 * var(--space-4));
  padding: var(--space-3) var(--space-4) var(--space-4);
  background: var(--surface);
  border-top: 1px solid var(--border);
}

.actions {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--control-height);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-alt);
  color: var(--text);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition),
              transform var(--transition), box-shadow var(--transition),
              color var(--transition);
}
.btn:hover:not(:disabled):not([aria-disabled="true"]) {
  background: var(--surface);
  border-color: var(--accent);
  color: var(--accent-strong);
}
.btn:active:not(:disabled):not([aria-disabled="true"]) {
  transform: translateY(0.5px);
}
.btn:disabled {
  background: var(--surface-sunk);
  color: var(--text-faint);
  border-color: var(--border);
  cursor: not-allowed;
}
/* The 「下载」 control is unavailable before the first inference, and it is
   aria-disabled rather than native-disabled so it keeps focus, its tooltip and
   its click handler: a native-disabled button is mute, and this one has to say
   why it refuses. Same sunk look as the native state, and the .btn:hover /
   .btn:active and .viewer-toolbar .btn--mini:hover rules exclude it, so an
   unavailable control cannot light up or nudge under the cursor. */
.btn[aria-disabled="true"] {
  background: var(--surface-sunk);
  color: var(--text-faint);
  border-color: var(--border);
  cursor: not-allowed;
}
.btn__icon {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  color: currentColor;
}
.btn__icon svg { width: 100%; height: 100%; }
.btn__label { display: inline-block; }

.btn--primary {
  border-color: var(--accent-strong);
  background: linear-gradient(
    180deg,
    var(--accent) 0%,
    var(--accent-strong) 100%
  );
  color: var(--accent-ink);
  box-shadow: var(--shadow-1), inset 0 1px 0 var(--surface);
}
.btn--primary:hover:not(:disabled) {
  border-color: var(--accent-strong);
  background: var(--accent-strong);
  color: var(--accent-ink);
  box-shadow: var(--shadow-1), inset 0 1px 0 var(--surface);
}
.btn--ghost { font-weight: 500; }

.btn--icon {
  padding: 0;
  width: var(--control-height);
  min-height: var(--control-height);
  background: var(--surface-sunk);
  border-color: var(--border);
  color: var(--text-muted);
}
.btn--icon svg { width: 14px; height: 14px; }
.btn--icon:hover:not(:disabled) {
  background: var(--accent-soft);
  color: var(--accent-strong);
  border-color: var(--accent);
}

/* ==========================================================================
   status
   ========================================================================== */

.statusbar {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: var(--space-2);
  padding-top: var(--space-1);
}
.statusbar > .status,
.statusbar > .status--error { grid-column: 1 / -1; }
.status {
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  color: var(--text-muted);
  font-size: var(--text-xs);
  line-height: 1.45;
  word-break: break-word;
}
.status--error {
  border-color: var(--danger);
  background: var(--danger-soft);
  color: var(--danger);
  font-weight: 600;
}
.status-dot {
  flex: 0 0 auto;
  width: 10px;
  height: 10px;
  margin-top: 5px;
  border-radius: var(--radius-pill);
  background: var(--success);
  box-shadow: 0 0 0 3px var(--success-soft);
  transition: background var(--transition), box-shadow var(--transition);
}
.status-dot[data-state="loading"] {
  background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
  animation: status-pulse 1.4s ease-in-out infinite;
}
.status-dot[data-state="error"] {
  background: var(--danger);
  box-shadow: 0 0 0 3px var(--danger-soft);
}
@keyframes status-pulse {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.2); }
}

/* ==========================================================================
   viewer
   ========================================================================== */

.viewer-card { padding: 0; overflow: hidden; }

.viewer-card__bar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--border);
  background: linear-gradient(
    180deg,
    var(--surface) 0%,
    var(--surface-sunk) 100%
  );
}
.viewer-card__bar .panel__title { margin-bottom: 0; }
.viewer-card__caption {
  margin-left: auto;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  text-align: right;
  font-weight: 500;
}

.geometry-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface-sunk);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  transition: background var(--transition), color var(--transition),
              border-color var(--transition);
}
.geometry-chip__dot {
  width: 6px;
  height: 6px;
  border-radius: var(--radius-pill);
  background: var(--warn);
  box-shadow: 0 0 0 2px var(--warn-soft);
}

.viewer-toolbar {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 2px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-sunk);
}
.viewer-toolbar .btn--icon { width: 26px; min-height: 26px; }

/* The export buttons sit on the figure they download rather than beside the
   inputs, so they join the view toolbar. Matched to the icon buttons' 26px
   row — the bar is not tall enough for full-height controls. */
.viewer-toolbar .btn--mini {
  min-height: 26px;
  padding: 2px var(--space-2);
  background: var(--surface-sunk);
  border-color: var(--border);
  color: var(--text-muted);
  font-size: var(--text-xs);
  letter-spacing: 0.03em;
}
.viewer-toolbar .btn--mini:hover:not(:disabled):not([aria-disabled="true"]) {
  background: var(--accent-soft);
  color: var(--accent-strong);
  border-color: var(--accent);
}
/* The numbering toggle must read as *on* after the click, not just under the
   cursor: aria-pressed is the machine state, this is the human one. Same
   accent lane as the hover, held. Without a rule like this the class would
   compile, pass every test, and leave a pressed button looking idle. */
.viewer-toolbar .btn--mini[aria-pressed="true"] {
  background: var(--accent-soft);
  color: var(--accent-strong);
  border-color: var(--accent);
}
/* One download trigger with a format dropdown, replacing the three
   always-visible PNG/SVG/PDF buttons. The panel is positioned against the
   trigger and right-aligned so it cannot overflow the card edge; the card
   itself is taller than the panel, so its overflow:hidden never clips it. */
.export-menu {
  position: relative;
  display: inline-flex;
}
.export-menu__trigger { gap: var(--space-1); }
.export-menu__caret { width: 8px; height: 5px; flex: none; }
.export-menu__panel {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-1));
  z-index: var(--z-tooltip);
  min-width: 120px;
  padding: var(--space-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-2);
  display: flex;
  flex-direction: column;
}
/* The panel's visibility is the `hidden` attribute; the global
   `[hidden] { display: none !important }` already beats the rule's own
   `display: flex`, so no per-rule hidden dance is needed. */
.export-menu__item {
  display: block;
  width: 100%;
  padding: var(--space-1) var(--space-2);
  border: 0;
  border-radius: var(--radius-xs);
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-xs);
  text-align: left;
  cursor: pointer;
  transition: background var(--transition), color var(--transition);
}
.export-menu__item + .export-menu__item { margin-top: 2px; }
.export-menu__item:hover { background: var(--surface-sunk); color: var(--text); }
.viewer-toolbar__sep {
  width: 1px;
  align-self: stretch;
  margin: 3px 2px;
  background: var(--border);
}

.viewer {
  position: relative;
  /* min-height:0 is mandatory: without it this flex child refuses to shrink
     below its content and the canvas overflows instead of compressing. */
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  background: var(--viewer-bg);
  touch-action: none;
}
.viewer::before {
  /* A subtle radial backdrop so the plot sits inside a soft accent rather than
     on flat surface, hinting at where the lattice will appear without stealing
     attention from the eventual draw. */
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(
    ellipse at center,
    var(--surface) 0%,
    var(--viewer-bg) 70%
  );
  pointer-events: none;
}
.viewer > svg,
.viewer > canvas {
  display: none;
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  cursor: grab;
}
/* The two viewer surfaces are the one place where a stale `hidden` attribute
   would destroy the product's main output rather than merely hide a control,
   and the global `[hidden] { display: none !important }` above would follow
   it. The renderer therefore owns their visibility through `.is-active`, so a
   stale attribute can never blank the plot. */
.viewer > svg.is-active,
.viewer > canvas.is-active { display: block; }
.viewer > svg:active,
.viewer > canvas:active { cursor: grabbing; }

/* The marker sizes and label sizes are *not* set here. The renderer draws
   them counter-scaled (they keep a constant apparent size while the geometry
   zooms), and it writes them as inline styles so that they win. A CSS rule
   in this file would outrank the SVG presentation attributes the renderer
   used to set, which silently defeated the counter-scaling: the labels grew
   with the zoom while the markers shrank. */
.graph-edge { fill: none; stroke-linecap: round; }
.graph-node { stroke: var(--node-ring); }
.graph-intra { fill: none; }
.graph-label {
  fill: var(--text);
  font-family: var(--font-sans);
  font-weight: 600;
  paint-order: stroke;
  stroke: var(--viewer-bg);
  stroke-linejoin: round;
  pointer-events: none;
  user-select: none;
}

.viewer-empty {
  position: absolute;
  inset: 0;
  z-index: var(--z-base);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-6);
  text-align: center;
}
/* 还没推断时放大居中的品牌点阵图标；出结果后随 #viewer-empty 一起隐去。 */
.viewer-empty__mark {
  flex: 0 0 auto;
  display: block;
  width: 96px;
  height: 96px;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-1), 0 0 0 1px var(--border);
}
.viewer-empty__svg { display: block; width: 100%; height: 100%; border-radius: inherit; }
.viewer-empty__title {
  font-size: var(--text-lg);
  font-weight: 700;
  color: var(--text);
  letter-spacing: -0.005em;
}
.viewer-empty__body {
  margin: 0;
  max-width: 38ch;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.6;
}
.viewer-empty__steps {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
  text-align: left;
}
.viewer-empty__steps li {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex: 0 1 auto;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-sunk);
  font-size: var(--text-xs);
  line-height: 1.4;
  color: var(--text);
  white-space: nowrap; /* 每条说明自成一行，不再在窄格里换行成两行 */
}
.viewer-empty__step-num {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--accent-ink);
  font-size: var(--text-2xs);
  font-weight: 700;
}

.viewer-spinner {
  position: absolute;
  z-index: var(--z-raised);
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}
.viewer-spinner__inner {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface);
  box-shadow: var(--shadow-2);
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 600;
}
.spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);
  border-radius: var(--radius-pill);
  animation: spinner-rotate 0.85s linear infinite;
}
@keyframes spinner-rotate {
  to { transform: rotate(360deg); }
}

.viewer-hint {
  position: absolute;
  z-index: var(--z-sticky);
  right: var(--space-2);
  bottom: var(--space-2);
  left: var(--space-2);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--warn);
  border-radius: var(--radius-sm);
  background: var(--warn-soft);
  color: var(--warn);
  font-size: var(--text-xs);
  line-height: 1.45;
  /* The hint sits on top of the plot, across its full width. Without this it
     swallowed the pointer events in that strip, so the bottom of the figure
     could not be panned or zoomed at all. The spinner above already says so. */
  pointer-events: none;
}

.tooltip {
  position: absolute;
  z-index: var(--z-tooltip);
  max-width: 320px;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-2);
  font-size: var(--text-xs);
  pointer-events: none;
  /* No backdrop-filter: the background is an opaque --surface, so a blur
     behind it has nothing to show and costs a GPU pass per frame. */
}
.tooltip__title {
  margin-bottom: var(--space-1);
  padding-bottom: var(--space-1);
  border-bottom: 1px solid var(--border);
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 700;
}
.tooltip__list { display: grid; grid-template-columns: auto 1fr; gap: 1px var(--space-2); }
.tooltip__list dt { color: var(--text-muted); }
.tooltip__list dd {
  color: var(--text);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* ==========================================================================
   results
   ========================================================================== */

.results > .panel__title { margin-bottom: 0; }

/* The reference-only disclaimer. It must out-shout the muted notes around it
   without borrowing the danger red (nothing here went wrong): one step up the
   type scale from --text-sm body text, weight 700 on the lead, and the warm
   warning lane of the palette. The panel itself scrolls, and app.js returns it
   to the top when a result renders, so this sits on the first screen. */
.reference-note {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: var(--radius-md);
  background: var(--warn-soft);
  color: var(--text);
  font-size: var(--text-md);
  font-weight: 500;
  line-height: var(--leading-base);
}
.reference-note strong { font-weight: 700; }
.reference-note__icon {
  flex: none;
  width: 16px;
  height: 16px;
  margin-top: 2px;
  color: var(--warn);
}

.results > #res-content {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  animation: results-fade-in var(--duration-slow) var(--ease-out);
}
@keyframes results-fade-in {
  from { transform: translateY(4px); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}

.block {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-sunk);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.block:hover {
  border-color: var(--border-strong);
  box-shadow: var(--shadow-1);
}
.block__title {
  padding-bottom: var(--space-1);
  border-bottom: 1px solid var(--border);
  font-size: var(--text-md);
  line-height: var(--leading-tight);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text);
  font-weight: 700;
}

/* Folded result block: secondary sections (confidence breakdown, hypotheses,
   assumptions, warnings, geometry, raw summary) share the .block chrome while
   collapsed, then expand into the same surface treatment when opened. The
   summary acts as both the header and the toggle. */
.block--foldable {
  padding: 0;
  gap: 0;
}
.block--foldable > .block__title {
  margin: 0;
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  user-select: none;
  list-style: none;
  transition: color var(--transition), border-color var(--transition);
}
.block--foldable > .block__title::-webkit-details-marker { display: none; }
.block--foldable > .block__title::after {
  content: "▸";
  margin-left: auto;
  color: var(--text-faint);
  font-size: var(--text-xs);
  transition: transform var(--transition), color var(--transition);
}
.block--foldable[open] > .block__title::after {
  content: "▾";
  color: var(--text-muted);
}
.block--foldable > .block__title:hover { color: var(--text-muted); }
.block--foldable > :not(summary):not(.block__title) {
  padding: var(--space-3) var(--space-3) var(--space-3);
}
/* 折叠块开头那行说明贴住标题与正文之间：保留完整下边距会和后一块内容
   的上边距叠加，行距比面板其他地方宽出一截。 */
.block--foldable > .block__lede {
  padding-bottom: 0;
}
/* 表格的包层：border-collapse: collapse 下浏览器忽略 <table> 自身的
   padding，折叠块「子元素各自带 padding」的套路对它无效，缩进必须由
   这一层 div 承接，否则表格会贴到卡片边缘。 */
.block__inset {
  padding: var(--space-3);
}
.block--foldable[open]:hover {
  border-color: var(--border-strong);
  box-shadow: var(--shadow-1);
}
.block__icon {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  color: var(--text-muted);
}
.block__icon svg { width: 100%; height: 100%; }
.block__lede { color: var(--text-muted); font-size: var(--text-xs); line-height: 1.55; }
.block__lede:empty { display: none; }

/* 折叠块标题上的计数徽标：点开前先知道里面有没有东西。计数一律保持中性——
   数字本身不是问题，不必抢先染上警示色。 */
.block__badge {
  display: inline-block;
  min-width: 1.7em;
  padding: 0 var(--space-1);
  margin-left: var(--space-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface-alt);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  line-height: 1.5;
  text-align: center;
}

.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--text-sm); }
.kv dt { color: var(--text-muted); }
.kv dd { color: var(--text); font-variant-numeric: tabular-nums; text-align: right; }
/* A kv value that qualifies a headline number -- a bounded dimension, say -- has
   to read as secondary. The class name is the one the data table already uses;
   it needed its own rule here, since `.data-table .is-muted` does not reach a
   definition list. */
.kv dd.is-muted { color: var(--text-muted); }
/* A muted span *inside* a kv value. The rule above styles a dd that carries
   the class itself, which is the other case; a span nested in a dd needs its
   own, or it renders at full strength exactly where the qualifier is needed. */
.kv dd .is-muted { color: var(--text-faint); }

/* Confidence breakdown: label / value / bar on one compact row. */
.kv--meter { gap: var(--space-1) var(--space-3); }
.kv--meter dd { display: flex; align-items: center; gap: var(--space-2); }
.kv--meter dd .meter { flex: 1 1 auto; min-width: 40px; }

.meter {
  position: relative;
  height: 6px;
  border-radius: var(--radius-pill);
  background: var(--surface-alt);
  overflow: hidden;
}
.meter__fill {
  position: absolute;
  inset: 0 auto 0 0;
  border-radius: inherit;
  background: var(--accent);
  width: 0;
  transition: width var(--duration-slow) var(--ease-out);
}
.meter__fill--good { background: var(--success); }
.meter__fill--mid  { background: var(--warn); }
.meter__fill--low  { background: var(--danger); }
.meter--inline { display: inline-block; width: 56px; height: 5px; vertical-align: middle; margin-left: var(--space-2); }

/* identity headline */
.kv__type {
  color: var(--text);
  font-size: var(--text-xl);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: -0.01em;
  white-space: nowrap;
}
/* A Chinese term and its English identifier, stacked: the term on its own
   line, the identifier as the caption under it. The value column is ~190px
   wide, and the identifier is longer than the term in every case, so the two
   cannot share a line without breaking the term in the middle. */
.kv__stack {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  text-align: right;
}
/* The English identifier kept under a Chinese term. It is there so a reader
   can match it against the API value and the docs, and it is deliberately
   quiet: a reference, not a second headline. The size is set here rather than
   inherited, because the term it follows may be the 22px headline. */
.kv__ref {
  color: var(--text-faint);
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0;
  white-space: nowrap;
}
.kv__dim {
  margin-left: var(--space-2);
  padding: 1px var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--accent-soft);
  color: var(--accent-strong);
  font-size: var(--text-xs);
  font-weight: 700;
  white-space: nowrap;
}

.callout {
  padding: var(--space-2) var(--space-3);
  border-left: 3px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  font-size: var(--text-xs);
  line-height: 1.5;
}
.callout--info { border-left-color: var(--accent); background: var(--info-soft); color: var(--text); }

/* legend */
.legend { display: flex; flex-direction: column; gap: var(--space-1); }
.legend__head { display: flex; align-items: center; gap: var(--space-2); }
.legend__name { font-size: var(--text-xs); font-weight: 600; }
.legend__bar {
  height: 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  /* the ramp itself is injected from legend.<x>.lut by app.js */
  background: var(--surface-alt);
}
.legend__bar--cyclic { position: relative; }
/* Marks the wrap-around point where −π meets +π. */
.legend__bar--cyclic::after {
  content: "";
  position: absolute;
  top: -1px;
  right: -1px;
  bottom: -1px;
  width: 2px;
  background: var(--text);
}
.legend__ticks {
  display: flex;
  justify-content: space-between;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  line-height: 1.3;
}
.legend__ticks span { white-space: nowrap; }
.legend__ticks span:not(:first-child):not(:last-child) { text-align: center; }
.legend__note { color: var(--text-muted); font-size: var(--text-2xs); line-height: 1.45; }
.legend__note:empty { display: none; }

.chip {
  padding: 0 var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--surface-alt);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.chip--cyclic { background: var(--accent-soft); color: var(--accent-strong); }

/* tables */
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-xs);
}
.data-table th,
.data-table td {
  padding: 6px var(--space-2);
  border-bottom: 1px solid var(--border);
  text-align: right;
  vertical-align: middle;
}
.data-table th:first-child,
.data-table td:first-child { text-align: left; padding-left: 0; }
.data-table td:last-child,
.data-table th:last-child { padding-right: 0; }
/* No text-transform here: the header 「振幅 |t|」 is the only Latin text in
   the row, and uppercasing it prints |T| -- which in this vocabulary is a
   period or a temperature, not a hopping amplitude. */
.data-table th {
  color: var(--text-muted);
  font-weight: 600;
  letter-spacing: 0.04em;
  font-size: var(--text-2xs);
}
.data-table td { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table .is-muted { color: var(--text-muted); font-family: var(--font-sans); }
.data-table .cellbar {
  position: relative;
  display: block;
  height: 4px;
  margin-top: 3px;
  border-radius: var(--radius-pill);
  background: var(--surface-alt);
  overflow: hidden;
}
.data-table .cellbar i {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--accent);
  transition: width var(--duration-slow) var(--ease-out);
}

/* hypotheses */
.hyp-list { display: flex; flex-direction: column; gap: var(--space-1); }
.hyp {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  font-size: var(--text-xs);
}
.hyp__rank { color: var(--text-muted); font-family: var(--font-mono); }
.hyp__name { color: var(--text); font-weight: 600; }
.hyp__conf { color: var(--text-muted); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.hyp--top { border-color: var(--accent); background: var(--accent-soft); }
/* Per-row role tag: says what the reading is for (adopted vs fallback), so
   the list is not mistaken for several switchable pictures. */
.hyp__tag {
  margin-left: var(--space-2);
  padding: 0 var(--space-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-pill);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 400;
  white-space: nowrap;
}
.hyp--top .hyp__tag { border-color: var(--accent); color: var(--accent); }

/* assumptions */
.assume-list { display: flex; flex-direction: column; gap: var(--space-2); }
.assume {
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--warn);
  border-left-width: 3px;
  border-radius: var(--radius-sm);
  background: var(--warn-soft);
  color: var(--text);
  font-size: var(--text-xs);
  line-height: 1.55;
}

/* warnings */
.warn-list { display: flex; flex-direction: column; gap: var(--space-2); }
.warn {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border);
  border-left: 3px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-sunk);
  font-size: var(--text-xs);
  line-height: 1.5;
}
.warn__code {
  align-self: flex-start;
  padding: 0 var(--space-1);
  border-radius: var(--radius-xs);
  background: var(--surface-alt);
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
}
.warn__body { color: var(--text); word-break: break-word; }
/* Non-error notes take the neutral base above: they are cautions to read,
   not alarms to glance at. Red is reserved for what actually failed. */
.warn--error { border-left-color: var(--danger); background: var(--danger-soft); }
.warn--error .warn__code { background: var(--danger); color: var(--danger-soft); }

/* geometry */
.geom { display: flex; flex-direction: column; gap: var(--space-2); }
.geom__group { display: flex; flex-direction: column; gap: 2px; }
.geom__label { color: var(--text-muted); font-size: var(--text-2xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.geom__rows { display: flex; flex-direction: column; }
.geom__row {
  display: flex;
  gap: var(--space-2);
  padding: 2px var(--space-2);
  border-radius: var(--radius-xs);
  background: var(--surface-sunk);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
}
.geom__row i { color: var(--text-muted); font-style: normal; min-width: 4ch; }
.geom__row span { color: var(--text); }

/* raw summary */
.raw {
  max-height: 260px;
  margin: 0;
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--border);
  overflow: auto;
  color: var(--text-muted);
  font-size: var(--text-2xs);
  line-height: 1.55;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: var(--font-mono);
}

/* ==========================================================================
   responsive
   ========================================================================== */

/* (the former 1440px step is gone: the clamp() tracks in `.layout` above
   absorb that whole range continuously) */
@media (max-width: 1240px) {
  .layout {
    grid-template-columns: clamp(18rem, 24vw, 21rem) minmax(0, 1fr);
    /* Two rows now: the viewer keeps the bulk of the height, the results
     band rides along under it. The row is height-relative (with a floor)
     so a short window gives the plot its pixels back instead of paying a
     fixed 16rem to a half-empty panel. */
    grid-template-rows: minmax(0, 1fr) minmax(11rem, 26vh);
  }
  .controls { grid-row: 1 / span 2; }
  .results { grid-column: 2; }
  /* Two/one-column stacking has no side-by-side boundary to straddle, and an
     inline dragged --left-w is deliberately not honoured here: the fixed
     clamp keeps the narrow layout sane. */
  .splitter { display: none; }
}

@media (max-width: 880px) {
  /* Single column: the page itself scrolls, and the viewer gets an explicit
     height because it can no longer inherit one from a grid row. */
  body { height: auto; min-height: 100vh; min-height: 100dvh; overflow: auto; }
  .layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    gap: var(--space-3);
    padding: var(--space-3);
  }
  .controls { grid-row: auto; overflow: visible; }
  .results { grid-column: auto; overflow: visible; }
  /* Once the page itself scrolls, a bottom-sticky dock would pin itself over
     the content instead of docking to the panel, so it rejoins the flow. */
  .dock { position: static; margin-inline: 0; padding-inline: 0; }
  .viewer { height: 58vh; min-height: 280px; flex: 0 0 auto; }
  /* .viewer-empty__steps 已是 flex-wrap，窄屏自行折行，无需再改成单列 */
}

@media (max-width: 480px) {
  .layout { padding: var(--space-2); gap: var(--space-2); }
  .panel { padding: var(--space-3); }
  .field-grid { grid-template-columns: minmax(0, 1fr); }
  .actions { grid-template-columns: 1fr; }
  .app-header h1 { font-size: var(--text-xl); }
  .brand__tagline { display: none; }
  .app-header__meta { gap: var(--space-1) var(--space-2); }
  .code-area--tall { height: 132px; }
  .viewer { height: 46vh; min-height: 220px; }
  .kv { gap: var(--space-1) var(--space-2); font-size: var(--text-xs); }
  .toast-stack { right: var(--space-2); bottom: var(--space-2); max-width: calc(100vw - 2 * var(--space-2)); }
  /* The dialog's text column keeps more of its width to itself at phone size. */
  .about { --about-pad: var(--space-4); }
}

@media (max-width: 360px) {
  /* The empty-state steps promise "one line per step" (the nowrap above), and
     that holds down to phone width in Chinese. The English copy is longer --
     "Read the result in the right panel" -- and at watch width the nowrap
     meets the viewer's overflow:hidden and silently loses its tail. Letting
     the chip wrap is the honest fallback: the number badge stays whole and
     nothing is clipped. */
  .viewer-empty__steps li { white-space: normal; }
}

/* --------------------------------------------------------------------------
   large displays
   Past ~1800px the clamp() columns above have already grown toward their
   ceilings; what must follow them is the type and control scale, or the page
   reads as a small window magnified onto a big screen. The overrides restate
   the --text-* tokens one step up on their existing scale, so every rule that
   references them -- including the ones the design-system tests pin by token
   name -- scales in place without touching a single component rule.
   -------------------------------------------------------------------------- */
@media (min-width: 1800px) {
  :root {
    --text-2xs: 11.5px;
    --text-xs:  12.5px;
    --text-sm:  13.5px;
    --text-md:  14.5px;
    --text-lg:  16.5px;
    --text-xl:  20px;
    --text-2xl: 24px;
    --control-height: 34px;
  }
  /* Variables, not gap/padding: the splitter calcs read the same tokens, so
     the handles stay centred on the (wider) column boundary. */
  .layout { --layout-gap: var(--space-5); --layout-pad: var(--space-5); }
}

@media (min-width: 2560px) {
  :root {
    --text-2xs: 12.5px;
    --text-xs:  13.5px;
    --text-sm:  14.5px;
    --text-md:  16px;
    --text-lg:  18px;
    --text-xl:  22px;
    --text-2xl: 26px;
    --control-height: 38px;
  }
}

/* --------------------------------------------------------------------------
   short viewports
   A 1366x768 laptop minus browser chrome, or any half-height window, leaves
   under 700px of page. The trade is chrome first, content last: the header
   slims and the tall textarea yields, while the panels keep scrolling on
   their own -- nothing becomes unreachable, only less airy. The 1240 two-row
   band needs nothing extra here; its rows already share height through 26vh.
   -------------------------------------------------------------------------- */
@media (max-height: 760px) {
  .app-header { padding: var(--space-2) var(--space-4); }
  .viewer-empty__mark { width: 64px; height: 64px; }
  .app-header h1 { font-size: var(--text-lg); }
  .code-area--tall { height: 120px; }
  .about { max-height: calc(100vh - var(--space-6)); max-height: calc(100dvh - var(--space-6)); }
}

/* ==========================================================================
   reduced motion
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }
}

/* ==========================================================================
   forced colours (Windows 高对比模式等)
   When the OS takes over painting, the page's background-based language goes
   flat: a pressed toggle resting on --accent-soft, the confidence and shell
   bars, the legend ramps and the status dot all collapse into the same system
   fill. Each indicator gets back a signal the system may not repaint away.
   ========================================================================== */

@media (forced-colors: active) {
  /* Pressed state is carried by the background; a system-highlight border
     survives and reads as "this one is on". */
  .viewer-toolbar .btn--mini[aria-pressed="true"] { border: 2px solid Highlight; }
  .geometry-chip__dot { forced-color-adjust: none; }
  /* The dot encodes idle/loading/error purely by hue; keeping the author's
     colours is the difference between a status line and three identical
     circles. */
  .status-dot { forced-color-adjust: none; }
  /* Bars and ramps are the data itself -- flattening them to one system fill
     destroys the measurement they exist to show. */
  .meter, .meter__fill, .cellbar, .cellbar i { forced-color-adjust: none; }
  .legend__bar, .legend__bar--cyclic::after { forced-color-adjust: none; }
  /* The −π/+π wrap marker and the cyclic/linear chips are colour words in
     the legend; give the chips a text-weight border to stand in. */
  .chip--cyclic { border: 1px solid CanvasText; }
  /* The export panel floats by its box-shadow, which the system drops. */
  .export-menu__panel { border: 1px solid CanvasText; }
  .toast { border: 1px solid CanvasText; }
  /* The unavailable 「下载」 says itself with a sunk fill and faint text, both of
     which the system palette replaces. The opacity and the dashed edge are the
     parts that survive, so the "not yet" state stays visible there too. */
  .btn[aria-disabled="true"] { opacity: 0.55; border-style: dashed; }
}

/* ==========================================================================
   print
   Ctrl+P is not the designed export path -- PNG/SVG/PDF come from the server
   and print the same figure the screen shows -- but a user printing the page
   for the record must get something readable: the single-column scroll
   stacking already exists at narrow widths, minus the chrome that cannot be
   operated on paper.
   ========================================================================== */

@media print {
  html, body { height: auto; overflow: visible; }
  .app-header__meta, .splitter, .skip-link, .viewer-toolbar, .toast-stack,
  .tooltip, .viewer-spinner, .actions, #about-dialog { display: none !important; }
  .layout { display: block; height: auto; padding: 0; }
  .panel { overflow: visible; margin-bottom: var(--space-4); border: 1px solid var(--border); }
  .controls, .results { height: auto; }
  .dock { position: static; margin-inline: 0; border-top: 0; }
  .viewer { height: 240pt; flex: none; }
  .panel, .block { break-inside: avoid; }
}
