/* system.css — Develop base + component layer (v3).
 *
 * v3 evolves v2 with the 漫剧工坊 component language on the paper-default
 * palette: `.btn-primary` is the SOLID ACCENT CTA (amber; `.btn-accent` stays
 * as an alias), `.btn-secondary` is the neutral outline sibling, ghost stays
 * the quiet tool button, and every selected/active state (chip / segmented /
 * card / sidebar rail / tree) speaks the accent tonally. /v2 is frozen.
 *
 * Explicit @layer order: a consumer's own UNLAYERED rules always win over every
 * lib layer (opt-in, non-breaking); within lib, utilities > components > reset.
 * Tokens come from v3/tokens.css. Compact 12px base; one shared focus ring; one
 * global reduced-motion guard. Components reference SEMANTIC tokens only. */

@layer lib.reset, lib.components, lib.utilities;

@layer lib.reset {
  *, *::before, *::after { box-sizing: border-box; }

  html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; tab-size: 4; }
  img, svg, video, canvas { max-width: 100%; height: auto; }
  svg { display: block; }
  /* An svg that declares its size via width/height ATTRIBUTES (the inline-icon
     pattern) must not be fluid-capped: inside a shrink-wrapped flex parent
     (e.g. an icon-only .btn) `max-width:100%` makes parent↔child sizing
     circular — Chrome resolves it to the attribute size, WebKit/iOS Safari
     collapses the icon to 0×0 (the "copy button vanished on mobile" bug).
     Explicit attributes mean the author already fixed the size; honor it. */
  svg[width][height] { max-width: none; }
  button, input, textarea, select { font: inherit; color: inherit; }
  :where(p, li, h1, h2, h3, h4, h5, h6) { overflow-wrap: break-word; }

  body {
    margin: 0;
    font-family: var(--font-sans);
    font-size: var(--text-body);
    line-height: var(--leading-body);
    background: var(--background);
    color: var(--foreground);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
  }

  /* Platform scrollbar: thin, tonal, track-less. chatbot and user-center each carried this
     exact rule as their own CSS; it is the design system's, so every channel gets it here.
     Colour follows --foreground so dark themes need no second block. */
  * { scrollbar-width: thin; scrollbar-color: color-mix(in oklch, var(--foreground) 12%, transparent) transparent; }
  *::-webkit-scrollbar { width: 4px; height: 4px; }
  *::-webkit-scrollbar-track, *::-webkit-scrollbar-corner { background: transparent; }
  *::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--foreground) 12%, transparent); border-radius: var(--radius-full); }
  *::-webkit-scrollbar-thumb:hover { background: color-mix(in oklch, var(--foreground) 25%, transparent); }

  h1, h2, h3, h4, h5, h6 {
    margin: 0;
    font-weight: var(--weight-semibold);
    line-height: var(--leading-heading);
    letter-spacing: var(--tracking-tight);
    color: var(--foreground);
  }
  h1 { font-size: var(--text-display); }
  h2 { font-size: var(--text-subheading); }
  h3 { font-size: var(--text-heading); }
  h4 { font-size: var(--text-title); }
  h5 { font-size: var(--text-body); }
  h6 { font-size: var(--text-micro); }
  p { margin: 0; line-height: var(--leading-body); }
  /* Complete the margin/padding normalization so consumers never need their own
     `*{margin:0;padding:0}` reset (an UNLAYERED consumer reset would beat every
     lib component's padding — see top comment). :where() keeps specificity 0 so
     any product/component rule trivially overrides. */
  :where(ul, ol, menu) { margin: 0; padding: 0; }
  :where(figure, blockquote, dl, dd, fieldset) { margin: 0; }

  a { color: inherit; }

  /* Custom-element FOUC guards (components.js is deferred). */
  dev-site-header:not(:defined) { display: block; min-height: 48px; background: var(--background); border-bottom: 1px solid var(--border); }
  dev-site-footer:not(:defined) { display: block; min-height: 120px; background: var(--background); border-top: 1px solid var(--border); }
  /* Async custom elements with slotted application content stay hidden until
     upgrade; otherwise drawer bodies and option labels dump into normal page
     flow on a cold/slow components.js load. Important is intentional: the
     reset-layer guard must also beat the contextual drawer's utility-layer
     display rule before that element is defined. */
  dev-dialog:not(:defined),
  dev-drawer:not(:defined),
  dev-select:not(:defined) { display: none !important; }

  /* **`.w-full` 在 `<dev-select>` 上必须真的是满宽**(2026-09-18)。它的 `:host` 带着
     `max-inline-size: 18rem`(给独立摆放的下拉一个合理上限),而外层 `.w-full` 只设
     `width` —— 于是一行表单里输入框 410px、下拉 288px,右边缘参差,而两边都没报错。
     宿主写了 `.w-full` 就是要它跟着容器走,把那个上限一并解开。同理 `.field-w-*`:
     梯子给的宽度也不该被组件的默认上限截断。 */
  dev-select.w-full, dev-select[class*="field-w-"] { max-inline-size: 100%; }
  dev-select.w-full { inline-size: 100%; }

  /* 减少动效的全局守卫搬去 recipes.css 了 —— 页面样式表进不了 shadow DOM,
     那条 `*` 规则于是只覆盖页面这一层,组件内部的动效一概不受约束(实测 9 个
     带动效的组件一个都没被压住)。recipes.css 是两侧共用的那一份。 */
}

@layer lib.components {
  /* ---- Buttons ---- 已抽到 styles/v3/recipes.css(shadow DOM 也要用同一份)。
     构建时会把 recipes.css 拼进这份文件,消费方看到的仍是一个 /v3/system.css。 */
  /* ---- Inputs ---- */
  .input, .textarea, select.input {
    box-sizing: border-box; width: 100%; padding: 0 var(--sp-3);
    font-family: inherit; font-size: var(--text-body); line-height: var(--leading-body);
    color: var(--foreground); background: var(--c-input-bg);
    border: 1px solid var(--c-input-border); border-radius: var(--radius);
    outline: none; caret-color: var(--ui-accent);   /* branded text cursor — a subtle refined detail */
    transition: border-color var(--duration-fast) var(--ease-smooth), box-shadow var(--duration-fast) var(--ease-smooth);
  }
  .input, select.input { min-height: var(--c-input-h); }
  .input-number { width: 4rem; min-width: 4rem; }
  .textarea { padding: var(--sp-2) var(--sp-3); resize: vertical; }
  .input:hover, .textarea:hover, select.input:hover { border-color: var(--border-strong); }
  /* Focus with the BRAND accent — the field a user is typing in reads as intentional, and
     ties the form to the same accent the buttons use (resting state stays neutral). */
  .input:focus-visible, .textarea:focus-visible, select.input:focus-visible,
  .input:focus, .textarea:focus, select.input:focus { border-color: var(--ui-accent); outline: var(--focus-outline-brand); outline-offset: var(--focus-outline-offset); }
  .input::placeholder, .textarea::placeholder { color: var(--placeholder); }
  .input:disabled, .textarea:disabled, select.input:disabled { opacity: var(--state-disabled-opacity); cursor: not-allowed; background: var(--muted); }
  .input-invalid, .input[aria-invalid="true"], .textarea[aria-invalid="true"] { border-color: var(--danger); }
  /* 无效态聚焦给红环,不是品牌琥珀环 —— 环的颜色要跟着控件此刻的意图走。 */
  .input-invalid:focus, .input[aria-invalid="true"]:focus,
  .textarea[aria-invalid="true"]:focus, select.input[aria-invalid="true"]:focus {
    border-color: var(--danger); outline: var(--focus-outline-danger); outline-offset: var(--focus-outline-offset);
  }
  .input-sm { min-height: var(--control-h-dense); font-size: var(--text-caption); }
  .input-lg { min-height: var(--control-h-lg); }
  select.input {
    appearance: none; cursor: pointer; padding-right: var(--sp-8);
    background-image: var(--c-select-caret);
    background-repeat: no-repeat; background-position: right var(--sp-3) center;
  }

  /* ---- Form field scaffold ---- */
  .form-field { display: grid; gap: var(--sp-1); }
  /* Horizontal field: label on the left (fixed column so inputs align across a
     stack), control on the right. Pair with .form-label. Override the label
     column width per-form with --field-label-w. */
  .form-field-row { display: grid; grid-template-columns: var(--field-label-w, 5rem) minmax(0, 1fr); align-items: start; gap: var(--sp-2); }
  /* A grid item's min-width is `auto` = its content's intrinsic width, so a datetime input
     (13rem min-content) pushed the whole row past a 390px card. Let the field column shrink. */
  .form-field-row > * { min-inline-size: 0; }
  /* 标签列的三档宽度。缺省 5rem 够两三个汉字 / 一个英文单词;设置页那种「注册赠金(美元)」
     「Stripe Webhook 密钥」得要 8rem,公告表单「用户 id / 用户名 / 邮箱」要 10rem。挂在表单
     或卡片上,一整张表一个口径 —— 每行各写一个 inline 变量就是各行标签不对齐的开始。 */
  .field-label-lg { --field-label-w: 8rem; }
  .field-label-xl { --field-label-w: 10rem; }
  /* 表单/弹窗内的分组标题:替代裸 strong / span.text-title 三写法(2026-08-15 盘点)。
     与 .pop-label / .menu-label 同一视觉语义:micro + semibold + muted。 */
  .form-legend, .pop-label { margin: 0; font-size: var(--text-micro); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  /* The label is a box the height of ONE input, top-aligned in the grid, so it lines up with the
     control even when the control column carries a help line beneath the input (align-items:center
     used to sink the label to the middle of input+help — the row-misalignment bug). */
  /* A label longer than the column WRAPS inside it (balanced, so an 8-character
     CJK label splits 4/4 instead of orphaning one glyph). `nowrap` made it spill
     LEFT out of the grid cell instead: in a dialog the label escaped the panel's
     own padding box (16px past it, 5px from the border), and in a multi-column
     filter grid it came within 1px of the neighbouring column's input. */
  .form-field-row > .form-label { white-space: normal; overflow-wrap: break-word; text-wrap: balance; text-align: end; min-block-size: var(--c-input-h); display: inline-flex; align-items: center; justify-content: flex-end; }
  .form-label { font-size: var(--text-caption); font-weight: var(--weight-medium); color: var(--foreground); }
  .form-label .required { color: var(--danger-text); margin-left: 2px; }
  .form-help { font-size: var(--text-micro); color: var(--muted-foreground); }
  /* 参数分组 —— 一组参数 = 一个 `.form-legend` 小标题 + 它管的那几行 `.form-field-row`,
     组与组之间一条线。没有它,十几行参数就平铺成一面平:决定通路与价钱的那几行
     (收费模式 / 分辨率 / 真人审核)和只决定画面的那几行(时长 / 画幅)长得一模一样
     (2026-08-15 operator:「右侧参数选择部分…一点分割都没有吗」)。视频规格弹层、
     图片规格弹层、AI网关手册共用这一副 —— 别在产品里再排一次裸行。 */
  .field-group { display: flex; flex-direction: column; gap: var(--sp-2); padding-block: var(--sp-3); border-block-start: 1px solid var(--border); }
  .field-group:first-child { padding-block-start: 0; border-block-start: none; }
  .field-group:last-child { padding-block-end: 0; }
  /* 组标题右边那句「这组为什么要紧」:同一行、更轻,不抢标题。 */
  :is(.form-legend, .pop-label) > .legend-hint { margin-inline-start: var(--sp-2); font-weight: var(--weight-normal); letter-spacing: 0; color: var(--foreground-subtle); }
  /* 某一行下面的补充说明:与**控件列**对齐(顶到标签列去就成了另一条标签)。 */
  .field-note { margin: 0; font-size: var(--text-micro); color: var(--foreground-subtle); line-height: var(--leading-normal); padding-inline-start: calc(var(--field-label-w, 5rem) + var(--sp-2)); }
  /* 小圆圈 "?" 帮助点:啰嗦的说明文字不平铺在界面上,收进 <dev-tooltip> 的触发器
     (operator 2026-08-05:「这些文字做成小圆圈的?,鼠标放上去再显示」)。用法:
     <dev-tooltip text="…"><span class="help-dot" tabindex="0">?</span></dev-tooltip>
     tabindex 让键盘 focus 也能唤出提示(dev-tooltip 监听 focusin)。 */
  .help-dot { display: inline-flex; align-items: center; justify-content: center; inline-size: 14px; block-size: 14px; border-radius: 50%; border: 1px solid var(--border); color: var(--muted-foreground); background: var(--muted, transparent); font-size: var(--text-micro); line-height: 1; font-weight: var(--weight-medium); cursor: help; user-select: none; flex: 0 0 auto; vertical-align: middle; }
  .help-dot:hover, .help-dot:focus-visible { color: var(--foreground); border-color: var(--muted-foreground); }
  /* 11px error copy = normal text, so it takes the INK step (--danger was
     4.34:1 on the paper canvas). */
  .form-error { font-size: var(--text-micro); color: var(--danger-text); }

  /* ---- Checkbox / radio (native, tinted) ---- */
  .checkbox, .radio { width: 1rem; height: 1rem; accent-color: var(--primary); cursor: pointer; }
  .checkbox:focus-visible, .radio:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }

  /* ---- Tags / badges ---- */
  .tag {
    display: inline-flex; align-items: center; gap: var(--sp-1);
    padding-block: 0.1875rem; padding-inline: var(--sp-2);
    font-size: var(--text-micro); font-weight: var(--weight-medium); line-height: var(--leading-snug);
    border-radius: var(--c-tag-radius); background: var(--secondary); color: var(--secondary-foreground); white-space: nowrap;
  }
  /* Soft intent tints communicate category through background + inset border;
     text stays on the guaranteed readable foreground token. Saturated intent
     ink on a 10–14% tint fell below normal-text contrast, especially warning. */
  .tag-success { background: var(--success-bg); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--success-border); }
  .tag-warning { background: var(--warning-bg); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--warning-border); }
  .tag-danger  { background: var(--danger-bg);  color: var(--foreground); box-shadow: inset 0 0 0 1px var(--danger-border); }
  .tag-info    { background: var(--info-bg);    color: var(--foreground); box-shadow: inset 0 0 0 1px var(--info-border); }
  .tag-brand   { background: var(--brand-soft); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--brand-soft-hover); }
  .tag-solid   { background: var(--primary); color: var(--primary-foreground); }
  /* High-contrast filled semantic tags (saturated bg + readable foreground) —
     for a coloured LABEL that must stay legible where the soft .tag-* (light bg
     + saturated text) reads too faintly. */
  .tag-solid-info    { background: var(--info);    color: var(--info-foreground); }
  .tag-solid-warning { background: var(--warning); color: var(--warning-foreground); }
  .tag-solid-success { background: var(--success); color: var(--success-foreground); }
  .tag-solid-danger  { background: var(--danger);  color: var(--danger-foreground); }

  /* ---- Cards ---- */
  /* 卡片配方搬到 recipes.css:组件里也要用它(<dev-card> 以前自抄一份,圆角用
     --radius、阴影用 --shadow-card、内距写死 20px —— 和页面上的 .card 并排放着
     就是两种卡片)。build.mjs 会把 recipes.css 拼进 /v3/system.css,页面侧看到的
     还是同一个文件。 */
  /* Whole-card actions: use with .card + layout utilities. This keeps channel
     landing pages semantic and keyboard accessible without private reset CSS. */
  .action-card { appearance: none; inline-size: 100%; box-sizing: border-box; text-align: start; cursor: pointer; font: inherit; }
  .action-card:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); border-color: var(--ring); }
  .action-card:disabled { cursor: not-allowed; opacity: var(--state-disabled-opacity); }
  /* Small, reusable onboarding flow. Content and actions remain channel-owned;
     lib owns only the responsive visual relationship. */
  .workflow-steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-3); }
  .workflow-step { display: flex; align-items: flex-start; gap: var(--sp-3); min-inline-size: 0; padding: var(--sp-3); border: 0; border-radius: var(--radius); background: var(--surface-sunken); color: var(--foreground); }
  .workflow-step-index { display: inline-grid; place-items: center; flex: 0 0 1.75rem; inline-size: 1.75rem; block-size: 1.75rem; border-radius: var(--radius-full); background: var(--primary); color: var(--primary-foreground); font-size: var(--text-micro); font-weight: var(--weight-bold); }
  /* Leading-edge colour accent — a 3px semantic bar on a card's start edge to
     category-code it (e.g. one card per vendor). Generic + reusable.
     The `.card.` form is load-bearing: `.card` lives in recipes.css, which the
     build appends AFTER this file, so on a card the single-class rule lost to
     `.card { border: 1px solid … }` and the bar never rendered on v3 (measured
     1px grey, 2026-09-23). The bare form stays for non-card hosts (xapi-tool
     eval.js puts it on a plain div). */
  .card-accent-info,    .card.card-accent-info    { border-inline-start: 3px solid var(--info); }
  .card-accent-warning, .card.card-accent-warning { border-inline-start: 3px solid var(--warning); }
  .card-accent-success, .card.card-accent-success { border-inline-start: 3px solid var(--success); }
  .card-accent-danger,  .card.card-accent-danger  { border-inline-start: 3px solid var(--danger); }
  .card-accent-brand,   .card.card-accent-brand   { border-inline-start: 3px solid var(--brand); }
  /* **草稿卡**(`card card-draft`):这张卡还只是一份待定的稿子(AI 起草、未采纳)。
     只换边的线型与颜色,底色/阴影/内距不动 —— 采纳之后把这个类拿掉就是一张普通卡,
     不需要第二个「已采纳」类。写成 `.card.card-draft` 是刻意的:`.card` 的配方在
     recipes.css 里,build 时拼在本文件**之后**,同层同特异性的单类规则会被它的
     `border` 简写盖回实线。颜色只在非选中时接管,选中的草稿仍是强调色描边。 */
  .card.card-draft { border-style: dashed; }
  .card.card-draft:not(.card-active) { border-color: var(--border-strong); }

  /* ---- Alert ---- */
  .alert { display: flex; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface-sunken); color: var(--foreground); font-size: var(--text-caption); }
  .alert-success { background: var(--success-bg); border-color: var(--success-border); }
  .alert-warning { background: var(--warning-bg); border-color: var(--warning-border); }
  .alert-danger  { background: var(--danger-bg);  border-color: var(--danger-border); }
  .alert-info    { background: var(--info-bg);    border-color: var(--info-border); }

  /* ---- Progress ---- */
  .progress { height: 6px; border-radius: var(--radius-sm); background: var(--muted); overflow: hidden; }
  .progress-bar { height: 100%; border-radius: inherit; background: var(--brand-gradient); transition: width var(--duration-slow) var(--ease-out); }
  .progress-indeterminate .progress-bar { width: 40%; animation: lib-indeterminate 1.1s var(--ease-smooth) infinite; }

  /* 构成条 —— 一条 100% 的轨道分成几段,每段一个占比(「相同 37 / 不符 0 / 缺失 11」这类)。
     与 `.progress` 的区别是它有**多段**、每段各自上色,段宽由调用方给(占比是数据)。
     段用 `<span style="width:N%">` + 一个语义色;空段不渲染,别渲染成 0 宽的缝。 */
  .bar-stack { display: flex; block-size: 8px; border-radius: var(--radius-sm); overflow: hidden; background: var(--surface-sunken); }
  .bar-stack > * { block-size: 100%; }
  /* 图例色块 —— 与构成条 / 图表配套的那颗小方块。 */
  .legend-swatch { display: inline-block; inline-size: 8px; block-size: 8px; border-radius: 2px; margin-inline-end: var(--sp-1); vertical-align: middle; }

  /* 漏斗 / 留存清单 —— 一列阶段,每阶段一行:名字 | 条 | 数 | 占比。
       <ol class="funnel">
         <li class="funnel-step">
           <span class="funnel-label">采集</span>
           <span class="funnel-track" aria-hidden="true"><span class="funnel-bar" style="--funnel-share:1"></span></span>
           <span class="funnel-value">260,431</span>
           <span class="funnel-rate">100%</span>
         </li> …
       </ol>
     `--funnel-share` 是 0..1 的占比(数据,与 .sparkbar 的 --v 同一口径),条宽 = 轨道的这么多。
     四列用 subgrid 对齐:各行的名字列等宽、数字列在个位上对齐 —— 每行各自一个网格的话,
     「260,431」和「1,204」的右边缘会各在各的位置。
     **有量就有 2px**:0.00001 的占比按比例算不到一个像素,画出来和 0 一样,而「还剩几个」
     与「一个不剩」是两个答案;0 才真的是空轨道。
     窄容器(手机、窄卡片)名字单起一行,条 + 数 + 占比在下一行,不横向滚动。断点看
     **容器**不看视口,所以 .funnel 自带 container-type —— 它因此不能放进按内容收缩的
     盒子(inline-flex / 浮动)里,那里它的宽会塌成 0;正常块级流里撑满即可。 */
  .funnel {
    container-type: inline-size;
    display: grid; grid-template-columns: fit-content(40%) minmax(3rem, 1fr) auto auto;
    gap: var(--sp-2) var(--sp-3); margin: 0; padding: 0; list-style: none;
    font-size: var(--text-body);
  }
  .funnel-step { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; row-gap: var(--sp-1); align-items: center; min-inline-size: 0; }
  .funnel-label { min-inline-size: 0; color: var(--foreground); overflow-wrap: anywhere; }
  .funnel-track { display: block; block-size: 8px; border-radius: var(--radius-sm); background: var(--muted); overflow: hidden; }
  .funnel-bar {
    display: block; block-size: 100%; border-radius: inherit; background: var(--primary);
    inline-size: min(100%, max(min(2px, var(--funnel-share, 0) * 100000px), var(--funnel-share, 0) * 100%));
  }
  .funnel-value { text-align: end; font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); color: var(--foreground); white-space: nowrap; }
  .funnel-rate { text-align: end; font-variant-numeric: tabular-nums; font-size: var(--text-micro); color: var(--muted-foreground); white-space: nowrap; }
  /* 还没跑到的阶段:字退成灰、条退成中性色。 */
  .funnel-step.is-muted > .funnel-label, .funnel-step.is-muted > .funnel-value { color: var(--muted-foreground); }
  .funnel-step.is-muted .funnel-bar { background: var(--border); }
  /* 这一步丢得反常:条用危险填充色,占比用墨色级的危险字色(--danger 是填充色,11px 字读不清)。 */
  .funnel-step.is-danger .funnel-bar { background: var(--danger); }
  .funnel-step.is-danger > .funnel-rate { color: var(--danger-text); }
  @container (max-width: 30rem) {
    .funnel-step > .funnel-label { grid-column: 1 / -1; }
    .funnel-step > .funnel-track { grid-column: 1 / 3; }
  }

  /* ---- Trace / step list(2026-10-06,随 xdata 轨迹查看器沉淀进 lib)----
     `<dev-trace>` 画出来的骨架:ol.trace > li.trace-step[data-role] > button.trace-step-head
     (.trace-index 步骤号 + .trace-role 角色 + 宿主的标签)+ .trace-step-body(正文块)。
     左边那条色带按角色走语义令牌;.is-highlight 是「证据 / 出错的那一步」。
     content-visibility: auto —— 几百步的清单里屏幕外的步骤不排版(contain-intrinsic-size
     记住它上次的高度,滚动条不跳)。纯 CSS 也能用:服务端渲染的页面照抄结构即可。 */
  .trace { display: flex; flex-direction: column; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; min-inline-size: 0; }
  .trace-step {
    content-visibility: auto; contain-intrinsic-size: auto 2.75rem;
    min-inline-size: 0; overflow: hidden;
    border: 1px solid var(--border); border-inline-start: 3px solid var(--trace-accent, var(--border-strong));
    border-radius: var(--radius); background: var(--card);
  }
  .trace-step[data-role="user"] { --trace-accent: var(--primary); }
  .trace-step[data-role="assistant"] { --trace-accent: var(--info); }
  .trace-step[data-role="tool"] { --trace-accent: var(--success); }
  .trace-step[data-role="system"] { --trace-accent: var(--muted-foreground); }
  .trace-step.is-highlight { --trace-accent: var(--warning); border-color: var(--warning-border); background: var(--warning-bg); }
  .trace-step-head {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
    inline-size: 100%; padding: var(--sp-2) var(--sp-3);
    border: none; background: none; color: inherit; font: inherit; text-align: start; cursor: pointer;
  }
  .trace-step-head:hover { background: var(--state-hover); }
  .trace-step-head:focus-visible { outline: var(--focus-outline); outline-offset: calc(var(--focus-outline-offset) * -1); }
  .trace-step-head::after { content: "▸"; margin-inline-start: auto; color: var(--muted-foreground); transition: transform var(--duration-fast) var(--ease-smooth); }
  .trace-step-head[aria-expanded="true"]::after { transform: rotate(90deg); }
  .trace-index { min-inline-size: 2.5rem; font-family: var(--font-mono); font-size: var(--text-micro); font-variant-numeric: tabular-nums; color: var(--muted-foreground); }
  .trace-role { font-size: var(--text-caption); font-weight: var(--weight-semibold); }
  .trace-step-body { display: flex; flex-direction: column; gap: var(--sp-2); min-inline-size: 0; padding: 0 var(--sp-3) var(--sp-3); }
  .trace-block-label { font-size: var(--text-micro); color: var(--muted-foreground); }
  .trace-text { font-size: var(--text-body); line-height: var(--leading-normal); white-space: pre-wrap; overflow-wrap: anywhere; }
  /* 截过的块已经短了;「显示全部」之后让它长到 40rem 再自己滚,别在长清单里再套一个 14rem 的小窗。 */
  .trace .payload { --payload-max-h: 40rem; }
  .trace-fold { display: flex; flex-direction: column; gap: var(--sp-2); min-inline-size: 0; }
  .trace-fold-head {
    display: inline-flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
    padding: 0; border: none; background: none; cursor: pointer;
    font: inherit; font-size: var(--text-micro); color: var(--muted-foreground);
  }
  .trace-fold-head:hover { color: var(--foreground); }
  .trace-fold-head:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); border-radius: var(--radius-sm); }
  .trace-fold-head::before { content: "▸"; transition: transform var(--duration-fast) var(--ease-smooth); }
  .trace-fold-head[aria-expanded="true"]::before { transform: rotate(90deg); }
  .trace-more { display: flex; justify-content: center; padding: var(--sp-2); }

  /* ---- Spinner ---- */
  .spinner { display: inline-block; width: var(--icon-md); height: var(--icon-md); border: 2px solid var(--border); border-right-color: var(--foreground); border-radius: 50%; animation: lib-spin 0.7s linear infinite; }

  /* ---- Skeleton ---- */
  .skeleton { background: linear-gradient(90deg, var(--muted) 25%, var(--surface-sunken) 50%, var(--muted) 75%); background-size: 200% 100%; border-radius: var(--radius-sm); animation: lib-shimmer 1.3s linear infinite; }

  /* ---- Landing kit (营销落地页的段落配方) ----
     Three landing pages (AI网关 gateway.js / xmemory landing / www home) each carried the
     same six shapes as inline styles. The shapes live here once: a full-width band, the
     eyebrow + lede pair, a section head, a stat band, a text|media split, an icon badge,
     a check list, a code window and the closing CTA band. Bands are full-width <section>s;
     the width limit is the .container inside them. */
  .band { padding-block: clamp(2.75rem, 6vw, 4.75rem); }
  .band-tight { padding-block: clamp(1.75rem, 3.5vw, 2.5rem); }
  .band-sunken { background: var(--surface-sunken); border-block: 1px solid var(--border); }
  .band-rule { border-block-start: 1px solid var(--border); }
  .band-hero { position: relative; isolation: isolate; overflow: hidden; padding-block: clamp(3rem, 7vw, 5.5rem); }
  /* Layers behind the hero are positioned siblings that come first in the DOM; the .container
     is positioned too and comes last, so it paints on top without any z-index. */
  .band-hero > .container { position: relative; }
  /* Decorative layers behind a hero: a host for <dev-aurora> and a dot grid. Both sit under
     the .container (z-index 0 vs 1) and never take pointer events. */
  .band-glow { position: absolute; inset: 0; opacity: .5; pointer-events: none; }
  .band-dots { position: absolute; inset: 0; pointer-events: none; opacity: .5; color: color-mix(in oklab, var(--foreground) 8%, transparent); background-image: radial-gradient(currentColor 1px, transparent 1px); background-size: 18px 18px; }
  .eyebrow { margin: 0; font-family: var(--font-mono); font-size: var(--text-micro); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: .18em; color: var(--muted-foreground); }
  .eyebrow-brand { color: var(--brand-text); }
  .lede { margin: 0; font-size: var(--text-heading); line-height: var(--leading-normal); color: var(--muted-foreground); max-inline-size: 36rem; text-wrap: pretty; }
  .section-head { display: grid; gap: var(--sp-3); justify-items: center; text-align: center; max-inline-size: 46rem; margin-inline: auto; margin-block-end: clamp(1.75rem, 4vw, 2.75rem); }
  .section-head-start { justify-items: start; text-align: start; margin-inline: 0; }
  .section-head > h1, .section-head > h2 { margin: 0; text-wrap: balance; }
  /* "01 —— develop.cc": a numbered rule above a section title. */
  .section-index { display: flex; align-items: baseline; gap: var(--sp-3); font-family: var(--font-mono); font-size: var(--text-caption); color: var(--muted-foreground); text-transform: uppercase; letter-spacing: .08em; }
  .section-index > b { font-weight: var(--weight-normal); color: color-mix(in oklab, var(--foreground) 40%, transparent); }
  .section-index > i { block-size: 1px; inline-size: 2.5rem; background: var(--border); align-self: center; }
  .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--stat-min, 12rem), 1fr)); gap: var(--sp-4); text-align: center; }
  .stat { display: grid; gap: var(--sp-1); justify-items: center; }
  .stat-value { font-size: var(--text-poster-sm); font-weight: var(--weight-bold); line-height: 1; letter-spacing: var(--tracking-tight); }
  .stat-label { font-size: var(--text-caption); color: var(--muted-foreground); }
  .feature-split { display: grid; grid-template-columns: 1fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
  @media (min-width: 48.0625rem) {
    .feature-split { grid-template-columns: 1fr 1fr; }
    .feature-split-reverse > :first-child { order: 2; }
  }
  .icon-badge { display: inline-flex; align-items: center; justify-content: center; flex: none; inline-size: 2.6rem; block-size: 2.6rem; border-radius: var(--radius-lg); background: var(--brand-soft); color: var(--brand-text); }
  .icon-badge > svg { inline-size: 1.25rem; block-size: 1.25rem; }
  .icon-badge-round { border-radius: var(--radius-full); }
  .icon-badge-sm { inline-size: 2.3rem; block-size: 2.3rem; }
  .icon-badge-lg { inline-size: 3rem; block-size: 3rem; }
  .icon-badge-gradient { background: var(--brand-gradient); color: var(--brand-gradient-fg); font-weight: var(--weight-semibold); }
  .check-list { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); margin: 0; padding: 0; list-style: none; }
  .check-list > * { display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--text-caption); }
  .check-list svg { flex: none; inline-size: 1rem; block-size: 1rem; color: var(--success); }
  .check-list-center { justify-content: center; }
  /* A code sample framed as an editor window (three dots + a file name). */
  .window { overflow: hidden; box-shadow: var(--shadow-float); }
  .window-bar { display: flex; align-items: center; gap: var(--sp-2); padding: .55rem .85rem; border-block-end: 1px solid var(--border); background: var(--surface-sunken); }
  .window-dot { inline-size: .66rem; block-size: .66rem; border-radius: var(--radius-full); background: var(--border); }
  .window-title { margin-inline-start: auto; font-family: var(--font-mono); font-size: var(--text-micro); color: var(--muted-foreground); }
  .window > pre { margin: 0; padding: var(--sp-4) var(--sp-5); font-family: var(--font-mono); font-size: var(--text-caption); line-height: var(--leading-normal); overflow-x: auto; }
  .cta-band { position: relative; isolation: isolate; overflow: hidden; text-align: center; padding-block: clamp(3rem, 7vw, 5.25rem); border-block-start: 1px solid var(--border); }
  .cta-band::before { content: ""; position: absolute; inset: 0; opacity: .06; background: var(--brand-gradient); }
  .cta-band > .container { position: relative; display: grid; gap: var(--sp-5); justify-items: center; max-inline-size: 46rem; }
  .cta-band h2 { margin: 0; text-wrap: balance; }
  /* A soft brand glow behind a media card (the .halo-host is the card's positioned wrapper). */
  .halo-host { position: relative; }
  .halo { position: absolute; inset: -2.5rem; border-radius: 2.5rem; filter: blur(46px); pointer-events: none; background: radial-gradient(60% 60% at 50% 50%, color-mix(in oklab, var(--brand) 22%, transparent), transparent 70%); }

  /* ---- Vendor editors: CodeMirror 6 / ProseMirror on platform tokens ----
     Any channel that embeds these editors gets the platform look (mono stack, tonal
     selection, accent cursor) from here; a product must not carry its own .cm-* theme.
     !important is required — CodeMirror injects its theme as a <style> at runtime. */
  .ProseMirror { outline: none; }
  .cm-editor { background: transparent !important; outline: none !important; font-family: var(--font-mono) !important; font-size: var(--text-menu) !important; line-height: var(--leading-normal) !important; }
  .cm-gutters { background: transparent !important; border-inline-end: 0 !important; outline: none !important; }
  .cm-gutter.cm-lineNumbers { min-inline-size: 3rem; color: color-mix(in oklch, var(--muted-foreground) 40%, transparent); font-size: var(--text-micro) !important; }
  .cm-gutter.cm-lineNumbers .cm-gutterElement { padding-inline-end: 12px !important; }
  .cm-scroller { overflow: auto !important; }
  .cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content::selection { background: color-mix(in oklch, var(--primary) 18%, transparent) !important; }
  .cm-activeLine { background: color-mix(in oklch, var(--muted) 50%, transparent) !important; border-radius: var(--radius-sm) !important; }
  .cm-activeLineGutter { background: transparent !important; }
  .cm-activeLineGutter .cm-gutterElement { color: var(--foreground) !important; opacity: 0.7; }
  .cm-foldGutter { min-inline-size: 0.75rem; }
  .cm-cursor { border-inline-start: 2px solid var(--primary) !important; }
  .cm-matchingBracket { background: color-mix(in oklch, var(--primary) 12%, transparent) !important; outline: 1px solid color-mix(in oklch, var(--primary) 30%, transparent); border-radius: 2px; }
  /* An inline AI suggestion inside the text editor: a click target, tonally marked. */
  .suggestion-highlight { cursor: pointer; border-radius: var(--radius-sm); background: color-mix(in oklch, var(--primary) 22%, transparent); transition: background var(--duration-fast); user-select: none; -webkit-user-select: none; }
  .suggestion-highlight:hover { background: color-mix(in oklch, var(--primary) 34%, transparent); }

  /* ---- Empty state ---- */
  /* align-items 是承重的:空态几乎总是和 .stack 一起写(要自定 gap),而 .stack 在
     本文件里排得更靠后,会把 display 从 grid 翻成 flex —— justify-items 于是彻底
     失效,图标和按钮全部靠左,只有文字因为 text-align 还居中。两种 display 下都
     居中,才不用每个调用点各记一次(线上 404 页与资产库空态都栽在这)。 */
  .empty-state { display: grid; justify-items: center; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; color: var(--muted-foreground); }
  .empty-state-icon { font-size: var(--text-display); opacity: 0.7; }
  .empty-state-title { font-size: var(--text-title); font-weight: var(--weight-semibold); color: var(--foreground); }

  /* ---- Misc primitives ---- */
  .divider { border: none; border-top: 1px solid var(--border); margin: var(--sp-3) 0; }
  .kbd { font-family: var(--font-mono); font-size: var(--text-micro); padding: 1px var(--sp-1); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--foreground); }
  /* 表格/正文里的标识符:**用档位字号,不用 0.95em** —— 算出来的 11.4px 不在
     任何一档上,同一屏于是有 11 / 11.4 / 12 三种近似字号,读着就是"糊"。 */
  .code { font-family: var(--font-mono); font-size: var(--text-micro); padding: 1px var(--sp-1); border-radius: var(--radius-sm); background: var(--surface-sunken); color: var(--foreground); }
  /* 留痕块 —— 一段**可能很长**的原文 / JSON(请求体、上游返回、错误全文)。
     `.code` 是行内的一小段;这一个是整块:自己滚、高度封顶、长行能折。
     高度用 `--payload-max-h` 调(缺省 14rem),别去改 max-block-size 本身。
     进 lib 是因为两个页面各自拿 `<pre>` 配三条行内样式拼过一份(AI网关的完整日志
     与错误日志),那就是第二套外观 —— 而且各自的高度封顶还不一样。 */
  .payload {
    margin: 0; padding: var(--sp-3);
    font-family: var(--font-mono); font-size: var(--text-micro); line-height: var(--leading-normal);
    white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto;
    max-block-size: var(--payload-max-h, 14rem);
    background: var(--surface-sunken); color: var(--foreground);
    border: 1px solid var(--border); border-radius: var(--radius);
  }
  /* 块头:左边说这段是什么,右边放动作(复制 / 下载)。 */
  .payload-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
  /* 块尾:截断说明(「显示 24 / 1,203 行」)+ 展开动作。块头的镜像,同一套对齐。 */
  .payload-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); font-size: var(--text-micro); color: var(--muted-foreground); }
  .avatar { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: var(--radius-full); background: var(--secondary); color: var(--secondary-foreground); font-size: var(--text-caption); font-weight: var(--weight-semibold); overflow: hidden; }
  .avatar img { width: 100%; height: 100%; object-fit: cover; }
  /* Square variant. A round crop is right for a face and wrong for everything
     else — a scene still, a document thumbnail, a product shot all lose their
     corners, which is exactly where their content lives. */
  .avatar-square { border-radius: var(--radius-sm); }
  .avatar-sm { width: 1.5rem; height: 1.5rem; font-size: var(--text-micro); }

  /* ---- Overlay (for non-dev-dialog modals) ---- */
  .overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: oklch(0.2 0 0 / 0.4); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); z-index: var(--z-modal); animation: lib-fade-in var(--duration-normal) var(--ease-out); }
  .modal-card { background: var(--surface-overlay); color: var(--card-foreground); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: var(--elevation-4); max-width: min(560px, 92vw); max-height: 86vh; overflow: auto; animation: lib-scale-in var(--duration-normal) var(--ease-out); }
  /* Wide variant for content-heavy modals (a form + a data table); pair with .modal-card. */
  .modal-card.modal-lg { max-width: min(900px, 94vw); width: 100%; }

  /* ---- Scrollbar ---- */
  /* 搬去 recipes.css 了:组件在 shadow root 里够不着 system.css,于是每个有滚动区的
     <dev-*> 都露着一条系统默认的粗滚动条(课程栏、对话框正文、下拉列表…)。
     recipes.css 是两侧共用的那一份,页面这边由 build 拼回同一个文件。 */
}

@layer lib.utilities {
  .stack { display: flex; flex-direction: column; gap: var(--stack-gap, var(--sp-4)); }
  .cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--cluster-gap, var(--sp-2)); }
  .container, .center { margin-inline: auto; max-width: var(--container-max, 72rem); padding-inline: var(--container-pad, var(--sp-6)); }
  .text-balance { text-wrap: balance; }

  /* ---- Finite layout / spacing utilities (token-driven, RTL-safe logical
     properties, NO JIT — this is what makes "link + use" cover app screens). ---- */
  .flex { display: flex; } .inline-flex { display: inline-flex; }
  .grid { display: grid; gap: var(--grid-gap, var(--sp-4)); }
  .block { display: block; } .inline-block { display: inline-block; }
  .hidden { display: none !important; }
  /* The HTML `hidden` attribute must win over display-setting classes too (`.chip` /
     `.stack` / `.progress` all set display): channels that toggle `el.hidden` (lipu, xsvg)
     each shipped their own `[hidden]{display:none!important}` — the one line the UA sheet
     cannot enforce once a class sets display. */
  [hidden]:not([hidden="until-found"]) { display: none !important; }
  .flex-col { flex-direction: column; } .flex-row { flex-direction: row; }
  .flex-wrap { flex-wrap: wrap; } .flex-nowrap { flex-wrap: nowrap; }
  .items-start { align-items: flex-start; } .items-center { align-items: center; }
  .items-end { align-items: flex-end; } .items-stretch { align-items: stretch; }
  .self-stretch { align-self: stretch; }
  /* Weight + pointer utilities for a template that has nothing else to hang them on (a
     clickable table row, a bold total cell). Roles (.text-heading …) stay the first choice. */
  .font-normal { font-weight: var(--weight-normal, 400); } .font-medium { font-weight: var(--weight-medium, 500); } .font-semibold { font-weight: var(--weight-semibold, 600); }
  .text-brand { color: var(--brand-text); }
  .cursor-pointer { cursor: pointer; } .select-none { user-select: none; -webkit-user-select: none; }
  /* label/数值行要按文字基线对齐(规格事实行、版本条标题):没有这一档时
     消费方写了 items-baseline 落回默认 stretch,一直没人发现(幽灵类审计 2026-08-06)。 */
  .items-baseline { align-items: baseline; }
  .self-start { align-self: flex-start; } .self-end { align-self: flex-end; } .self-center { align-self: center; }
  .justify-start { justify-content: flex-start; } .justify-center { justify-content: center; }
  .justify-end { justify-content: flex-end; } .justify-between { justify-content: space-between; }
  .flex-1 { flex: 1 1 0%; min-width: 0; } .flex-auto { flex: 1 1 auto; }
  .shrink-0 { flex-shrink: 0; } .grow { flex-grow: 1; }
  /* 三段式工具栏要「中间那段真居中」时用:左右两段 grow basis-0 各占一半,
     中间那段就落在容器正中,与两侧内容多寡无关(顶栏的视图切换器就靠它)。 */
  .basis-0 { flex-basis: 0; }
  /* **弹性行里的列宽**(`.basis-w-*`,与 `.col-w-*` 同一把梯子):一行 `.cluster` / `.list-row` 里几段内容
     要「宽时并排、窄时折行」—— 起点宽度给 basis,多出来的按 `.grow` / `.grow-2` 分。此前产品侧写行内
     `style="flex:1 1 11rem"`,那正是 §4b 要消灭的 inline 排版(评测渠道页 2026-09-29)。 */
  .grow-2 { flex-grow: 2; }
  .basis-w-1 { flex-basis: 2.5rem; }
  .basis-w-2 { flex-basis: 4rem; }
  .basis-w-3 { flex-basis: 6rem; }
  .basis-w-4 { flex-basis: 8rem; }
  .basis-w-5 { flex-basis: 10rem; }
  .basis-w-6 { flex-basis: 12rem; }
  .basis-w-7 { flex-basis: 16rem; }
  .basis-w-8 { flex-basis: 20rem; }
  .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .grid-auto { grid-template-columns: repeat(auto-fill, minmax(var(--grid-min, 16rem), 1fr)); }
  .grid-rail { grid-template-columns: minmax(var(--rail-w-min, 13rem), var(--rail-w, 22rem)) minmax(0, 1fr); }
  /* 设置类页面(.grid-rail-nav)的目录列锁定:右列长内容滚动时目录留在原地,
     不随滚动丢失(xshort 设置页 2026-08-05 设计稿要点)。窄屏规则(下方
     media 块)会把它折成水平条,继续生效。
     封顶按**真实滚动视口**算:页面滚动容器在 dev-site-header(56px)之下,
     按 100dvh 封顶的话,矮窗口里目录自己的滚动条永远不启用、末尾几项被裁在
     折叠线下够不到(2026-08-05 评审);站头高度变了就同步 --rail-header-offset。 */
  .grid-rail.grid-rail-nav > .sidebar { position: sticky; inset-block-start: 0; max-block-size: calc(100dvh - var(--rail-header-offset, 56px)); overflow: auto; align-self: start; }
  /* Mirrored rail: content first, metadata/actions rail at the end (viewers). */
  .grid-rail-end { grid-template-columns: minmax(0, 1fr) minmax(var(--rail-w-min, 13rem), var(--rail-w, 22rem)); }
  /* Two-pane editing workspace. Desktop keeps a stable rail and a fill-height
     editor; phones stack full-width, independently usable panes inside the
     surrounding dialog/page scroller. */
  .workspace-split { display: grid; grid-template-columns: minmax(var(--rail-w-min, 13rem), var(--rail-w, 22rem)) minmax(0, 1fr); gap: var(--sp-4); block-size: 65dvh; min-block-size: 30rem; }
  .workspace-split > * { min-inline-size: 0; }
  /* Main document + contextual end Inspector. Pair the surrounding page or
     dialog with .cq so the split responds to its usable width, not only the
     viewport. The rail stays visible while both panes own their scrolling. */
  .workspace-split.workspace-split-end { grid-template-columns: minmax(0, 1fr) minmax(22rem, 26rem); align-items: start; block-size: calc(100dvh - 11rem); min-block-size: 38rem; }
  .workspace-split.workspace-split-end > * { min-block-size: 0; block-size: 100%; }
  /* 侧栏用**标准栏宽**(与 .grid-rail-end 同一档):同一个共享面板在不同页面里
     必须一样宽,否则同一个组件会因为列宽不同排出不同的列数,看着就是两个东西。 */
  .workspace-split.workspace-split-rail { grid-template-columns: minmax(0, 1fr) minmax(var(--rail-w-min, 13rem), var(--rail-w, 22rem)); }
  .workspace-split.workspace-split-end > :last-child { position: sticky; inset-block-start: var(--sp-4); }
  /* Fill variant: inside a fixed app-shell main (flex column) the split grows to
     the exact remaining height instead of guessing with 100dvh math, so a
     bottom-anchored composer really touches the viewport edge. */
  .workspace-split.workspace-split-fill { flex: 1 1 0%; block-size: auto; min-block-size: 0; align-items: stretch; }
  .workspace-split.workspace-split-fill > :last-child { position: static; }
  /* Opt-in contextual end rail. A dev-drawer is the second workspace column on
     desktop, then becomes its normal modal drawer below 48rem. The trigger and
     in-panel close button are real buttons; consumers only wire open()/close(). */
  .workspace-context-toggle, .workspace-context-close { display: none; }
  .workspace-split.workspace-split-end > dev-drawer.workspace-context-drawer {
    position: relative; inset: auto; z-index: auto; display: block;
    min-inline-size: 0; min-block-size: 0; block-size: 100%;
  }
  /* AI-assisted long-form workspace: light outline, one continuous document,
     and a persistent end rail. Each column owns its vertical scrolling and the
     shell never creates a page-level horizontal scrollbar. */
  .document-workspace-shell { display: flex; flex-direction: column; min-inline-size: 0; min-block-size: 38rem; block-size: calc(100dvh - 7rem); overflow: hidden; background: var(--background); }
  .document-workspace-shell[data-fullscreen] { block-size: 100dvh; min-block-size: 0; }
  /* min-block-size:同一产品里单行标题与两行标题的 topbar 必须一样高 ——
     否则切视图时整条工具行(和它下面的一切)上下跳 13px(2026-08-06 像素巡检)。 */
  .document-workspace-topbar { flex: none; display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); min-inline-size: 0; min-block-size: 3.75rem; padding: var(--sp-2) var(--sp-3); border-block-end: 1px solid var(--border); background: var(--background); }
  .document-workspace-topbar-start,.document-workspace-topbar-actions,.document-workspace-title { min-inline-size: 0; }
  /* The action row stays on one line while it fits — that's the desktop toolbar.
     Owned here rather than by a .flex-nowrap utility on the product, because the
     phone rule below has to be able to turn it back on (utilities are the last
     layer, so a utility would win over the media query and pin the row to one
     line at 390px — where it overflows LEFT past x=0 and eats the first control). */
  .document-workspace-topbar-actions { flex-wrap: nowrap; }
  .document-workspace-body { flex: 1 1 auto; display: grid; grid-template-columns: minmax(11rem,14rem) minmax(38rem,1fr) minmax(24rem,28rem); min-inline-size: 0; min-block-size: 0; overflow: hidden; }
  .document-workspace-body > * { min-inline-size: 0; min-block-size: 0; }
  .document-workspace-outline,.document-workspace-main { overflow: auto; overscroll-behavior: contain; }
  .document-workspace-outline { border-inline-end: 1px solid var(--border); background: var(--surface-sunken); }
  .document-workspace-main { background: var(--surface-sunken); }
  .document-workspace-rail { display: flex; flex-direction: column; overflow: hidden; border-inline-start: 1px solid var(--border); background: var(--card); overscroll-behavior: contain; }
  .document-workspace-body > dev-drawer.workspace-context-drawer { min-inline-size: 0; min-block-size: 0; block-size: 100%; }
  .document-workspace-body > dev-drawer.workspace-context-drawer .document-workspace-rail { block-size: 100%; }
  .document-workspace-rail-scroll { flex: 1 1 auto; min-block-size: 0; overflow: auto; }
  .document-workspace-rail-head { flex: none; padding: var(--sp-3); border-block-end: 1px solid var(--border); background: linear-gradient(180deg,color-mix(in oklab,var(--brand-soft) 58%,var(--card)),var(--card)); }
  .document-workspace-agent-mark { display: inline-grid; place-items: center; inline-size: var(--control-h); block-size: var(--control-h); flex: none; border: 1px solid color-mix(in oklab,var(--brand) 40%,var(--border)); border-radius: var(--radius-md); background: var(--brand-gradient); color: var(--brand-gradient-fg); box-shadow: var(--elevation-1); }
  .document-workspace-context-card { padding: var(--sp-2); border: 1px solid color-mix(in oklab,var(--brand) 24%,var(--border)); border-radius: var(--radius-md); background: color-mix(in oklab,var(--brand-soft) 60%,var(--background)); }
  .document-workspace-context-summary { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
  .document-workspace-context-actions { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
  .document-workspace-action-chip { --action-tone:var(--brand);--action-bg:var(--brand-soft);display:inline-flex;align-items:center;gap:var(--sp-1);min-block-size:var(--control-h-sm);padding-inline:var(--sp-2);border:1px solid color-mix(in oklab,var(--action-tone) 34%,var(--border));border-radius:var(--radius);background:color-mix(in oklab,var(--action-bg) 72%,var(--card));color:color-mix(in oklab,var(--action-tone) 72%,var(--foreground));font:inherit;font-size:var(--text-micro);font-weight:var(--weight-semibold);cursor:pointer;transition:background var(--duration-fast) var(--ease-standard),border-color var(--duration-fast) var(--ease-standard),transform var(--duration-fast) var(--ease-standard)}
  .document-workspace-action-chip:hover,.document-workspace-action-chip[aria-pressed="true"]{background:var(--action-bg);border-color:color-mix(in oklab,var(--action-tone) 62%,var(--border))}
  .document-workspace-action-chip:active{transform:translateY(1px)}
  .document-workspace-action-chip:focus-visible{outline:var(--focus-outline);outline-offset:var(--focus-outline-offset)}
  .document-workspace-action-chip[data-tone="image"],.document-workspace-action-chip[data-tone="reference"]{--action-tone:var(--success);--action-bg:var(--success-bg)}
  .document-workspace-action-chip[data-tone="video"]{--action-tone:var(--info);--action-bg:var(--info-bg)}
  .document-workspace-action-chip[data-tone="audio"],.document-workspace-action-chip[data-tone="settings"]{--action-tone:var(--warning);--action-bg:var(--warning-bg)}
  .document-workspace-action-chip[data-tone="history"]{--action-tone:var(--muted-foreground);--action-bg:var(--muted)}
  .document-workspace-reference-strip { flex: none; padding: var(--sp-2) var(--sp-3); border-block-end: 1px solid var(--border); background: var(--background); }
  .document-workspace-reference-list { display: flex; gap: var(--sp-2); max-inline-size: 100%; overflow-x: auto; padding-block-end: var(--sp-1); overscroll-behavior-inline: contain; scrollbar-width: thin; }
  .document-workspace-reference-item { --reference-tone:var(--info);position:relative;display:grid;grid-template-rows:calc(var(--sp-8) + var(--sp-2)) auto auto;inline-size:calc(var(--sp-12) + var(--sp-4));min-inline-size:calc(var(--sp-12) + var(--sp-4));padding:0;overflow:hidden;border:1px solid color-mix(in oklab,var(--reference-tone) 42%,var(--border));border-radius:var(--radius-md);background:var(--card);color:var(--foreground);font:inherit;text-align:start;box-shadow:var(--elevation-1)}
  /* KNOWN GAP (measured, not fixed here): --reference-tone is used as INK on its
     own 10% tint by .document-workspace-reference-kind below — 2.86:1 for scene
     (success) and 2.05:1 for prop (warning) in light. Closing it needs an ink
     step for warning/info too (amber needs ~55% toward --foreground, far heavier
     than the 65% the success/danger pair uses), which is a palette decision, not
     a rename. Do it with --warning-text/--info-text + a --reference-ink knob. */
  .document-workspace-reference-item[data-tone="scene"]{--reference-tone:var(--success)}
  .document-workspace-reference-item[data-tone="prop"]{--reference-tone:var(--warning)}
  .document-workspace-reference-item[data-tone="style"]{--reference-tone:var(--brand)}
  .document-workspace-reference-item[data-tone="result"],.document-workspace-reference-item[data-tone="video"]{--reference-tone:var(--brand)}
  .document-workspace-reference-item>img,.document-workspace-reference-item>video{inline-size:100%;block-size:100%;min-block-size:0;object-fit:cover;background:var(--surface-sunken)}
  .document-workspace-reference-kind{display:flex;align-items:center;gap:var(--sp-1);padding:var(--sp-0_5) var(--sp-1);border-block-start:var(--sp-0_5) solid var(--reference-tone);background:color-mix(in oklab,var(--reference-tone) 10%,var(--card));color:var(--reference-tone);font-size:var(--text-micro);font-weight:var(--weight-semibold)}
  .document-workspace-reference-label{padding:var(--sp-1);font-size:var(--text-micro);font-weight:var(--weight-medium)}
  .document-workspace-reference-lock { position:absolute;inset-block-start:var(--sp-1);inset-inline-end:var(--sp-1);display:grid;place-items:center;inline-size:var(--control-h-sm);block-size:var(--control-h-sm);padding:0;border:1px solid color-mix(in oklab,var(--card) 65%,transparent);border-radius:var(--radius-full);background:color-mix(in oklab,var(--foreground) 72%,transparent);color:var(--background);cursor:pointer }
  /* Pressed = the ACCENT pair, not --brand. Two reasons, and the second is the
     hard one: (1) v3's component language says selected/active speaks the
     accent and --brand* is decorative (see .card-active / .chip.is-selected);
     (2) --brand-gradient-fg on --brand is 4.00:1 in light BUT 2.93:1 in dark —
     under the 3:1 floor WCAG 1.4.11 requires even for a bare icon, so the
     pressed lock glyph is failing today, not hypothetically. The accent pair is
     already contract-bound to ≥4.5:1 in every skin × theme by
     tests/v3-contrast.test.mjs. */
  .document-workspace-reference-lock[aria-pressed="true"]{background:var(--ui-accent);color:var(--ui-accent-fg)}
  .document-workspace-reference-play { position:absolute;inset-block-start:calc(var(--sp-4) - var(--sp-1));inset-inline-start:50%;display:grid;place-items:center;transform:translateX(-50%);color:var(--brand-gradient-fg);filter:drop-shadow(0 1px 2px color-mix(in oklab,var(--foreground) 70%,transparent));pointer-events:none }
  .document-workspace-result-peek { overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-sunken) }
  .document-workspace-result-card { flex:0 1 22rem;min-block-size:12rem;overflow:auto;padding:var(--sp-2) var(--sp-3);border-block-end:1px solid var(--border);background:var(--surface-sunken);overscroll-behavior:contain }
  .document-workspace-tool-panel { flex:0 1 45%;min-block-size:10rem;display:flex;flex-direction:column;overflow:hidden;border-block-end:1px solid var(--border);background:var(--surface-sunken) }
  .document-workspace-tool-head { flex:none;padding:var(--sp-2) var(--sp-3);border-block-end:1px solid var(--border);background:var(--card) }
  .document-workspace-tool-scroll { flex:1 1 auto;min-block-size:0;overflow:auto }
  .document-workspace-thread { flex:1 1 auto;min-block-size:0;display:flex;flex-direction:column;overflow:hidden;background:var(--card) }
  .document-workspace-thread-scroll { flex:1 1 auto;min-block-size:0;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin }
  .document-workspace-composer { flex: none; padding: var(--sp-3); border-block-start: 1px solid color-mix(in oklab,var(--brand) 24%,var(--border)); background: var(--card); box-shadow:var(--elevation-2); }
  .document-workspace-composer-input { resize:none;background:var(--background);border-color:color-mix(in oklab,var(--brand) 24%,var(--border)) }
  .document-workspace-composer-input:focus { border-color:var(--ui-accent);outline:var(--focus-outline-brand);outline-offset:var(--focus-outline-offset) }
  /* Send / preview = the SOLID ACCENT CTA, not the decorative --brand-gradient.
     The gradient is amber→teal→blue: its label contrast varies per pixel and
     bottoms out at 3.62:1 light / 2.48:1 dark against --brand-gradient-fg, so
     an 11px label on it can never be AA. tokens.css already draws the line —
     --brand* is hero art, --ui-accent is the action color — and both consumers
     already carry .btn-primary, so this now matches every other primary CTA. */
  .document-workspace-send,.document-workspace-preview-action { border-color:transparent;background:var(--btn-solid-bg);color:var(--btn-solid-fg);box-shadow:var(--elevation-1) }
  .document-workspace-send:hover:not(:disabled),.document-workspace-preview-action:hover:not(:disabled){background:var(--btn-solid-bg-hover)}
  .document-workspace-outline-item { --outline-tone:var(--info);border-inline-start:var(--sp-0_5) solid transparent }
  .document-workspace-outline-item[data-tone="success"]{--outline-tone:var(--success)}
  .document-workspace-outline-item[data-tone="warning"]{--outline-tone:var(--warning)}
  .document-workspace-outline-item[data-tone="accent"]{--outline-tone:var(--brand)}
  .document-workspace-outline-item:hover{border-inline-start-color:color-mix(in oklab,var(--outline-tone) 48%,transparent);background:color-mix(in oklab,var(--outline-tone) 7%,var(--surface-sunken))}
  .document-workspace-outline-item.is-active{border-inline-start-color:var(--outline-tone);background:color-mix(in oklab,var(--outline-tone) 13%,var(--surface-sunken));color:var(--foreground)}
  .document-workspace-outline-toggle,.document-workspace-agent-dock { display: none; }
  .m-3{margin:var(--sp-3)} .m-4{margin:var(--sp-4)} .overflow-x-auto{overflow-x:auto} .overflow-y-auto{overflow-y:auto}
  /* 表单控件收回内容宽(.input 默认 100%);网格行占满整行。都是消费方已在用
     却不存在的意图类(幽灵类审计 2026-08-06)。 */
  .w-auto{inline-size:auto} .col-span-full{grid-column:1/-1}
  .w-full { width: 100%; } .h-full { height: 100%; } .min-w-0 { min-width: 0; } .min-h-0 { min-height: 0; }
  .max-w-prose { max-width: 65ch; } .max-w-field { max-width: 24rem; } .max-w-full { max-width: 100%; } .max-inline-none { max-inline-size: none; }
  .relative { position: relative; } .sticky { position: sticky; }
  /* 文本即按钮:可点击的正文/说明文字(历史记录的提示词、行内展开等)。
     真 <button> 保住键盘与读屏语义,视觉完全继承所在文字的字号与颜色,
     消费方不要用 span+role=button 自造。 */
  .btn-unstyled { appearance: none; background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: inherit; cursor: pointer; }
  .btn-unstyled:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  /* 长对话框 / 长表单里,主动作不许跟着正文滚出视口:这条把动作行钉在滚动容器的底边。
     用在动作行本身;它的滚动祖先负责滚动(dev-dialog 的正文区就是)。
     实测过的病:整集交付的「批准 / 退回」落在 982-1038px,视口只有 1000px,按钮被切在边缘外。 */
  .action-bar-sticky { position: sticky; inset-block-end: 0; z-index: var(--z-raised-2); padding-block: var(--sp-2); background: var(--card); border-block-start: 1px solid var(--border); }
  .overflow-auto { overflow: auto; } .overflow-hidden { overflow: hidden; }
  /* A swipe strip / command list that scrolls but must not show a bar (the sidebar's
     menu scroller, a chip row). Opposite of the platform default above. */
  .no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; } .no-scrollbar::-webkit-scrollbar { display: none; }
  .border-0 { border: 0; }
  .text-pre-wrap { white-space: pre-wrap; overflow-wrap: anywhere; }
  .page-pad { padding: var(--sp-5) var(--sp-7) var(--sp-8); }
  .fieldset-reset { min-inline-size: 0; margin: 0; padding: 0; border: 0; }
  /* 长串指纹 / URL 要能断行,否则撑破卡片 */
  .text-break { overflow-wrap: anywhere; word-break: break-word; }
  /* 品牌色文字(强调某个值,而不是整段) */
  .text-brand { color: var(--ui-accent-text, var(--primary)); }
  /* 语气色文字 —— 「这个数是坏的 / 好的 / 要留意」。用 `*-text` 那一档(墨水色)
     而不是填充色:11px 的数字用填充色只有 3:1 出头,读不清。
     **建它是因为三个页面都在用一个不存在的 `.text-danger`**:AI网关的实时状态条
     与统计信息脉搏都写了它、都静默失效 —— 一个该红的成功率一直是黑的,而这一列
     存在的全部意义就是让人一眼看见它红了(2026-08-14 像素验收抓到)。 */
  .text-danger { color: var(--danger-text); }
  .text-success { color: var(--success-text); }
  .text-warning { color: var(--warning-text); }
  /* 拖放接收面的拖入态:虚线描边说明「松手落在这里」。宿主在 dragover 时挂上、
     dragleave/drop 摘掉;描边画在 outline 上,不挤占布局。 */
  .drop-hint { outline: 2px dashed var(--ui-accent, var(--primary)); outline-offset: 2px; border-radius: var(--radius-md, 8px); }
  .text-start { text-align: start; } .text-center { text-align: center; } .text-end { text-align: end; }
  /* padding (logical: px=inline, py=block, ps/pe=inline start/end) */
  .p-0{padding:0} .p-1{padding:var(--sp-1)} .p-2{padding:var(--sp-2)} .p-3{padding:var(--sp-3)} .p-4{padding:var(--sp-4)} .p-6{padding:var(--sp-6)} .p-8{padding:var(--sp-8)}
  .px-0{padding-inline:0} .px-1{padding-inline:var(--sp-1)} .px-2{padding-inline:var(--sp-2)} .px-3{padding-inline:var(--sp-3)} .px-4{padding-inline:var(--sp-4)} .px-6{padding-inline:var(--sp-6)} .px-8{padding-inline:var(--sp-8)}
  .py-0{padding-block:0} .py-1{padding-block:var(--sp-1)} .py-2{padding-block:var(--sp-2)} .py-3{padding-block:var(--sp-3)} .py-4{padding-block:var(--sp-4)} .py-6{padding-block:var(--sp-6)} .py-8{padding-block:var(--sp-8)}
  /* Edge-specific padding is intentionally reset-only. Keep the public surface
     small; non-zero edge spacing should use a semantic role or a nested stack. */
  .pt-0{padding-block-start:0} .pt-1{padding-block-start:var(--sp-1)} .pt-2{padding-block-start:var(--sp-2)} .pt-3{padding-block-start:var(--sp-3)} .pt-4{padding-block-start:var(--sp-4)} .pt-6{padding-block-start:var(--sp-6)} .pt-8{padding-block-start:var(--sp-8)}
  .pb-0{padding-block-end:0} .pb-1{padding-block-end:var(--sp-1)} .pb-2{padding-block-end:var(--sp-2)} .pb-3{padding-block-end:var(--sp-3)} .pb-4{padding-block-end:var(--sp-4)} .pb-6{padding-block-end:var(--sp-6)} .pb-8{padding-block-end:var(--sp-8)}
  /* margin */
  .m-0{margin:0} .mx-auto{margin-inline:auto}
  .mt-0{margin-block-start:0} .mt-1{margin-block-start:var(--sp-1)} .mt-2{margin-block-start:var(--sp-2)} .mt-3{margin-block-start:var(--sp-3)} .mt-4{margin-block-start:var(--sp-4)} .mt-6{margin-block-start:var(--sp-6)} .mt-8{margin-block-start:var(--sp-8)} .mt-auto{margin-block-start:auto} .ms-auto{margin-inline-start:auto}
  .mb-0{margin-block-end:0} .mb-1{margin-block-end:var(--sp-1)} .mb-2{margin-block-end:var(--sp-2)} .mb-3{margin-block-end:var(--sp-3)} .mb-4{margin-block-end:var(--sp-4)} .mb-6{margin-block-end:var(--sp-6)} .mb-8{margin-block-end:var(--sp-8)}
  /* gap */
  .gap-0{gap:0} .gap-1{gap:var(--sp-1)} .gap-2{gap:var(--sp-2)} .gap-3{gap:var(--sp-3)} .gap-4{gap:var(--sp-4)} .gap-6{gap:var(--sp-6)} .gap-8{gap:var(--sp-8)}
  .gap-x-2{column-gap:var(--sp-2)} .gap-x-4{column-gap:var(--sp-4)} .gap-y-2{row-gap:var(--sp-2)} .gap-y-4{row-gap:var(--sp-4)}
  .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* **不折行**,而不是 `.truncate`(它同时裁掉溢出并加省略号)。一个时间戳、一颗
     操作按钮、一个尺寸 `1024x1024` 折成两行会把整行撑高,但它们**不该被裁** ——
     两件事分开表达。此前各处写行内 `style="white-space:nowrap"`,那正是 §4b 要消灭
     的行内样式(2026-08-23 沉淀)。 */
  .nowrap { white-space: nowrap; }
  /* **藏起来但照样占位**(`visibility: hidden`):一个还不适用的控件(「选了分组才有状态筛选」)不摆成一颗
     点不了的禁用件,也不从行里抽走 —— 抽走的话它出现的那一刻,后面的控件整体跳位。 */
  .invisible { visibility: hidden; }
  .line-clamp-2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
  .text-left { text-align: left; } .text-center { text-align: center; } .text-right { text-align: right; }
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
  .sr-only-focusable:focus, .sr-only-focusable:focus-within { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
  .mono { font-family: var(--font-mono); }
  /* 数字位:等宽 + 表格数字。金额/延迟/计数逐行对齐,否则一列数字在视觉上
     锯齿状,读的人得逐个数位数。 */
  .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
  /* 表格里的数字**右对齐**(2026-09-17 补):等宽 + tabular-nums 只解决了字宽,
     没解决位置 —— 左对齐时 `1378` 和 `9` 的个位不在同一竖线上,一列数字读起来
     还是锯齿状,而这正是上面那条注释想解决的事。表头带 `.num` 就跟着一起右对齐,
     否则表头与数据又差半格。**只在表格里生效**:行内的 `.num` 是一段文字的一部分,
     右对齐会把它从句子里扯出来。 */
  .table th.num, .table td.num { text-align: end; }
  /* 金额位:主币在前、副币跟在同一行的括号里(`¥6.12 ($0.90)`,operator 2026-08-25
     把上下两行改成了一行)。两段都等宽数字,基线对齐。
     没有这个原子之前,每个页面自己拼金额格式 —— 于是「双币种」这种全站口径
     只能逐页手写、逐页写得不一样。空值统一用 .money-empty(渲染 —)。

     **对齐不归这个原子管,归它外面那个格子**(表格里是 `.text-end`)。上下两行那一版
     靠 `align-items` 管交叉轴对齐是成立的(两行不等宽,交叉轴有剩余空间);横排之后
     它是个 shrink-wrap 的 inline-flex,**主轴的剩余空间恒为零**,`justify-content`
     与配套的 `.money-start` 一个像素都不会动(2026-08-25 实测:元素宽 = 内容宽,
     两个方向的靠边全由 td 的 text-align 决定)。所以那两句删掉了 ——
     留着最坏:它看起来是个旋钮,拧它不报错也不生效。 */
  .money { display: inline-flex; flex-direction: row; align-items: baseline;
           gap: var(--sp-1);
           font-family: var(--font-mono); font-variant-numeric: tabular-nums;
           line-height: var(--leading-heading); }
  .money > b { font-weight: var(--weight-semibold); color: var(--foreground); }
  .money > span { font-size: var(--text-micro); color: var(--muted-foreground); }
  .money-empty { color: var(--muted-foreground); }
  .dim { color: var(--muted-foreground); }
  /* Type-role utilities — bind text to the role scale (so channels never write
     a raw px size). SELF-CONTAINED: each role carries its canonical font-weight
     too, so it renders identically on ANY element (span / div / h*) and never
     depends on heading defaults — which a host CSS reset (e.g. Tailwind
     preflight) would otherwise strip. Heading-rank roles default to semibold;
     text roles to normal. Override weight only when truly needed via an inline
     style — a "title that isn't bold" usually means the wrong role. */
  .text-display { font-size: var(--text-display); font-weight: var(--weight-semibold); line-height: var(--leading-heading); letter-spacing: var(--tracking-tight); }
  .text-subheading { font-size: var(--text-subheading); font-weight: var(--weight-semibold); line-height: var(--leading-heading); }
  .text-heading { font-size: var(--text-heading); font-weight: var(--weight-semibold); line-height: var(--leading-heading); }
  .text-title { font-size: var(--text-title); font-weight: var(--weight-semibold); line-height: var(--leading-heading); }
  .text-menu { font-size: var(--text-menu); font-weight: var(--weight-normal); line-height: var(--leading-body); }
  .text-body { font-size: var(--text-body); font-weight: var(--weight-normal); line-height: var(--leading-body); }
  .text-caption { font-size: var(--text-caption); font-weight: var(--weight-normal); line-height: var(--leading-body); }
  .text-micro { font-size: var(--text-micro); font-weight: var(--weight-normal); line-height: var(--leading-body); }
  /* Marketing poster scale — fluid hero/section type beyond --text-display (22). */
  .text-poster-sm { font-size: var(--text-poster-sm); font-weight: var(--weight-bold); line-height: 1.1; letter-spacing: var(--tracking-tight); }
  .text-poster-md { font-size: var(--text-poster-md); font-weight: var(--weight-bold); line-height: 1.05; letter-spacing: var(--tracking-tight); }
  .text-poster-lg { font-size: var(--text-poster-lg); font-weight: var(--weight-bold); line-height: 1.02; letter-spacing: var(--tracking-tight); }
  .brand-gradient-text { background-image: var(--brand-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; }
  .animate-spin { animation: lib-spin 0.9s linear infinite; }
  /* Slow "living gradient" sweep — pair with .brand-gradient-text on a headline. */
  .animate-shine { background-size: 200% auto; animation: lib-shine 5.5s ease-in-out infinite; }
  /* Keep page transitions opacity-only. A transformed page becomes the
     containing block for fixed-position dropdowns/dialogs, which offsets
     floating panels by the app sidebar and can push them off-screen. */
  .page-enter { animation: lib-fade-in 0.24s var(--ease-out); }
  /* Element-level entrance (chat bubbles, media cards). Element scope only —
     never on a page/panel container (transform would trap fixed children,
     see .page-enter note above). `both` keeps the pre-animation frame hidden. */
  .fade-up { animation: lib-fade-up var(--duration-slow) var(--ease-out) both; }
  .fade-in { animation: lib-fade-in var(--duration-medium, 0.2s) var(--ease-out) both; }
  .stagger-1 { animation-delay: 0.05s; } .stagger-2 { animation-delay: 0.1s; }
  .stagger-3 { animation-delay: 0.15s; } .stagger-4 { animation-delay: 0.2s; }
  /* Responsive overrides must stay in the same cascade layer as the desktop
     utility. A lower-priority component layer cannot override utilities even
     when its media query matches. */
  @media (max-width: 48rem) {
    .grid-rail, .grid-rail-end { grid-template-columns: minmax(0, 1fr); }
    /* .grid-stack-narrow — opt-in for a fixed-column grid (.grid-cols-2/3/4) whose cells
       cannot share a phone row (a hero headline beside its illustration): one column
       under 48rem, the desktop columns untouched. */
    .grid-stack-narrow { grid-template-columns: minmax(0, 1fr); }
    /* Settings and other dense section navigation can opt into a compact,
       horizontally scrollable rail. Keeping the rail in normal flow avoids
       a full-height sidebar overlapping the stacked content on phones. */
    .grid-rail.grid-rail-nav > .sidebar {
      flex-direction: row;
      block-size: calc(var(--control-h) + var(--sp-4));
      min-block-size: calc(var(--control-h) + var(--sp-4));
      inline-size: 100%;
      max-inline-size: 100%;
      overflow-x: auto;
      overflow-y: hidden;
      padding-block: var(--sp-2);
      border-inline-end: 0;
      border-block-end: 1px solid var(--border);
      overscroll-behavior-inline: contain;
      scroll-snap-type: x proximity;
      scroll-padding-inline: var(--sp-2);
      -webkit-overflow-scrolling: touch;
    }
    .grid-rail.grid-rail-nav > .sidebar > .sidebar-label { display: none; }
    .grid-rail.grid-rail-nav > .sidebar > .sidebar-item {
      flex: 0 0 auto;
      inline-size: auto;
      white-space: nowrap;
      scroll-snap-align: start;
      scroll-margin-inline: var(--sp-2);
    }
    .grid-rail.grid-rail-nav > .sidebar > .sidebar-item.is-active,
    .grid-rail.grid-rail-nav > .sidebar > .sidebar-item[aria-current="page"] { scroll-snap-align: center; }
    .grid-rail.grid-rail-nav > .sidebar > .sidebar-item::before {
      inset-inline: var(--sp-2);
      inset-block-start: auto;
      inset-block-end: 0;
      inline-size: auto;
      block-size: 2px;
      border-radius: var(--radius-full);
    }
    .workspace-split { grid-template-columns: minmax(0, 1fr); block-size: auto; min-block-size: 0; }
    .workspace-split > * { min-block-size: 30rem; }
    .workspace-split.workspace-split-end { grid-template-columns: minmax(0, 1fr); block-size: auto; min-block-size: 0; }
    .workspace-split.workspace-split-end > * { min-block-size: 30rem; block-size: auto; }
    .workspace-split.workspace-split-end > :last-child:not(.workspace-context-drawer) { position: static; }
    .workspace-split.workspace-split-fill { flex: 0 0 auto; }
    .workspace-split.workspace-split-end > dev-drawer.workspace-context-drawer[data-context-modal] {
      position: fixed; inset: 0; z-index: var(--z-modal); display: none;
      min-block-size: 0; block-size: auto;
    }
    .workspace-split.workspace-split-end > dev-drawer.workspace-context-drawer[data-context-modal][open] { display: block; }
    /* These controls exist only in the phone drawer flow, so their touch
       target must not depend on pointer-capability detection. Width emulation,
       hybrid devices and mobile browsers can all report a fine pointer. */
    .workspace-context-toggle, .workspace-context-close {
      display: inline-flex;
      min-block-size: var(--sp-11);
    }
    .page-pad { padding-inline: var(--sp-3); }
    .workflow-steps { grid-template-columns: minmax(0, 1fr); }
  }
  @container (max-width: 56rem) {
    .workspace-split.workspace-split-fill { flex: 0 0 auto; }
    .workspace-split.workspace-split-end { grid-template-columns: minmax(0, 1fr); block-size: auto; min-block-size: 0; }
    .workspace-split.workspace-split-end > * { min-block-size: 30rem; block-size: auto; }
    .workspace-split.workspace-split-end > :last-child:not(.workspace-context-drawer) { position: static; }
    .workspace-split.workspace-split-end > dev-drawer.workspace-context-drawer[data-context-modal] {
      position: fixed; inset: 0; z-index: var(--z-modal); display: none;
      min-block-size: 0; block-size: auto;
    }
    .workspace-split.workspace-split-end > dev-drawer.workspace-context-drawer[data-context-modal][open] { display: block; }
    .workspace-context-toggle, .workspace-context-close {
      display: inline-flex;
      min-block-size: var(--sp-11);
    }
    .document-workspace-shell { block-size: calc(100dvh - 4rem); min-block-size: 0; }
    .document-workspace-topbar { padding-inline: var(--sp-2); }
    .document-workspace-body { display: block; overflow: hidden; }
    .document-workspace-main { block-size: 100%; padding-block-end: calc(var(--sp-11) + var(--sp-4)); }
    .document-workspace-body > dev-drawer.workspace-context-drawer[data-context-modal] {
      position: fixed; inset: 0; z-index: var(--z-modal); display: none;
      min-block-size: 0; block-size: auto;
    }
    .document-workspace-body > dev-drawer.workspace-context-drawer[data-context-modal][open] { display: block; }
    .document-workspace-agent-dock {
      position: fixed; z-index: var(--z-sticky); inset-inline: var(--sp-3); inset-block-end: var(--sp-3);
      display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
      min-block-size: var(--sp-11); padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border);
      border-radius: var(--radius-full); background: var(--card); color: var(--card-foreground,var(--foreground));
      box-shadow: var(--elevation-3);
    }
  }

  @media (max-width: 80rem) {
    .document-workspace-body { grid-template-columns: minmax(38rem,1fr) minmax(22rem,24rem); }
    .document-workspace-outline { display: none; }
    .document-workspace-outline-toggle { display: inline-flex; }
  }

  @media (max-width: 56rem) {
    .document-workspace-shell { block-size: calc(100dvh - 4rem); min-block-size: 0; }
    .document-workspace-topbar { padding-inline: var(--sp-2); }
    .document-workspace-body { display: block; overflow: hidden; }
    .document-workspace-main { block-size: 100%; padding-block-end: calc(var(--sp-11) + var(--sp-4)); }
    .document-workspace-agent-dock { position: fixed; z-index: var(--z-sticky); inset-inline: var(--sp-3); inset-block-end: var(--sp-3); display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); min-block-size: var(--sp-11); padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-full); background: var(--card); color: var(--card-foreground,var(--foreground)); box-shadow: var(--elevation-3); }
  }

  @media (max-width: 30rem) {
    /* Phone: ONE row. Wrapping used to be the escape hatch for "more controls
       than a 390px row holds", but two stacked rows cost 100px of a 844px screen
       on a surface whose whole point is the area BELOW the toolbar (a canvas /
       a document). The controls that don't fit belong in an overflow menu — the
       consumer swaps its wide action row (`.md-only`) for a `<dev-menu>`
       (`.md-hidden`) at the same container breakpoint. Keep the row nowrap so a
       consumer that forgets sees its overflow immediately instead of paying a
       silent second row on every phone. */
    .document-workspace-topbar { flex-wrap: nowrap; gap: var(--sp-1); }
    .document-workspace-subtitle { display: none; }
  }
}

/* Keyframes (outside @layer — not subject to the cascade). */
@keyframes lib-spin { to { transform: rotate(360deg); } }
@keyframes lib-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes lib-scale-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
@keyframes lib-fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes lib-shimmer { to { background-position: -200% 0; } }
@keyframes lib-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
@keyframes lib-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes lib-shine { 0% { background-position: 0% center; } 50% { background-position: 120% center; } 100% { background-position: 0% center; } }
@media (prefers-reduced-motion: reduce) { .animate-shine { animation: none; } }

/* ---- Forced-colors / Windows High Contrast Mode. box-shadow (our focus ring)
   is stripped here, so restore real borders + a system-colored focus outline,
   and keep functional fills painted. Top-level (not layered) so it wins. ---- */
@media (forced-colors: active) {
  .btn, .input, .textarea, select.input, .tag, .card, .alert, .modal-card,
  .kbd, .code, .avatar { border: 1px solid ButtonText; }
  .btn-ghost { border-color: transparent; }
  :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
  .progress-bar, .skeleton, .spinner { forced-color-adjust: none; }
}

/* ---- Scrim robustness: opaque fallback where backdrop-filter is unsupported,
   and respect prefers-reduced-transparency. ---- */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .overlay { background: rgba(20, 20, 22, 0.62); }
}
@media (prefers-reduced-transparency: reduce) {
  .overlay { background: var(--background); -webkit-backdrop-filter: none; backdrop-filter: none; }
}

/* ===== vP5 — structural components (pure CSS; same @layer merges) ===== */
@layer lib.components {
  /* Tabs (visual; consumer wires roving-tabindex / panels) */
  .tabs { display: flex; gap: var(--sp-1); border-block-end: 1px solid var(--border); }
  .tabs.tabs-scroll { overflow-x: auto; scrollbar-width: thin; overscroll-behavior-inline: contain; scroll-snap-type: x proximity; scroll-padding-inline: var(--sp-2); -webkit-overflow-scrolling: touch; }
  .tabs.tabs-scroll > .tab { flex: 0 0 auto; scroll-snap-align: start; scroll-margin-inline: var(--sp-2); }
  .tab { appearance: none; background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-3); font: inherit; font-size: var(--text-caption); color: var(--muted-foreground); border-block-end: 2px solid transparent; margin-block-end: -1px; }
  .tabs.tabs-compact > .tab { padding-inline: var(--sp-2); }
  .tab:hover { color: var(--foreground); background: var(--state-hover); border-start-start-radius: var(--radius-sm); border-start-end-radius: var(--radius-sm); }
  .tab[aria-selected="true"], .tab.is-active { color: var(--foreground); border-block-end-color: var(--ui-accent); font-weight: var(--weight-semibold); }
  .tabs.tabs-scroll > .tab[aria-selected="true"], .tabs.tabs-scroll > .tab.is-active { scroll-snap-align: center; }
  .tab:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); border-radius: var(--radius-sm); }

  /* Table */
  .table { width: 100%; border-collapse: collapse; font-size: var(--text-caption); font-variant-numeric: tabular-nums; }
  .table th, .table td { padding: var(--sp-2) var(--sp-3); text-align: start; border-block-end: 1px solid color-mix(in oklab, var(--border) 62%, transparent); }
  /* 最后一行不画线:表几乎总是坐在一张卡里,那条线与卡片边框叠成一道粗边。
     **限定 `tbody:last-child`** —— 有 `tfoot`(合计行)时最后一条线要留着,
     否则合计与表身之间就没有分界了。 */
  .table tbody:last-child tr:last-child td { border-block-end: none; }
  /* **表头不填底色,靠一条 2px 下沿立住**(2026-09-17,operator 定「轻框线」那一版;
     设计稿 lib 项目「lib 标准表格 · 三种样貌」4b)。
     理由是这一族表的形态:一屏 20 行 × 13 列,**线条本身就是噪音** —— 表头那块实色带
     与六条组竖线加起来比数据还抢眼。去掉之后一屏里唯一的重色就是数据与状态色。
     行高一个像素没动,密度不变。
     **大写同时去掉**:它对中文一个字都不起作用,而对 `1080p` / `5M` / 模型 id 这类
     字面标识是有害的(`1080p` 被抬成 `1080P`,与正文里那批小写的档位 chip 对不上)。
     `.th-plain` 因此退化成一个空操作类,**先留着**(线上 30 多处在写它),下一轮再清。 */
  /* **表头与正文同字号**(2026-09-17 第二轮,operator:「文字的大小、间距……尤其是表头」)。
     此前是 `--text-micro`(11px),比正文的 `--text-caption`(12px)还**小一号** —— 那在
     「大写 + 字距」还在的时候成立(它读起来是一行 eyebrow),而这一轮把大写去掉之后,
     表头就只剩「更小、更灰」两个很弱的信号,中文在 11px 上笔画还糊。
     层级改由**字重与颜色**表达:600 + `--muted-foreground`,字号回到表格自己的基准档。

     **分界是一条发丝线 + 一格留白,不是一条粗线**(2026-09-17 第三轮,operator:「表头下个
     粗的下边框,太丑了,不要这个设计」)。上一版用 2px 的 `--border-strong` 去补「去掉底色
     之后表头立不住」——那是拿一条更重的线换掉一块底色,分量根本没减,只是换了形状。
     真正让表头立住的是**它下面的空**:行线一律发丝,表头底下多给一格 `--sp-3`,
     于是表头与数据之间的距离比行与行之间大,一眼就分得开。
     密表(`-sm` / `-tight`)按比例收到 `--sp-2`,否则那一格留白在 4px 的行距里显得突兀。 */
  .table thead th { font-size: var(--text-caption); font-weight: var(--weight-semibold); text-transform: none; letter-spacing: normal; white-space: nowrap; color: var(--muted-foreground); background: none; border-block-end: 1px solid var(--border); padding-block-end: var(--sp-3); }
  .table-sm thead th, .table-tight thead th { padding-block-end: var(--sp-2); }
  /* 表头是一个**字面标识**(分辨率 `1080p`、档位 `4k`、模型 id、尺寸 `1024x1024`),
     不是一个标题 —— 关掉上面那条大写与字距。缺了它,`1080p` 会被抬成 `1080P`、
     `768p` 抬成 `768P`,与同一页正文里那批小写的档位 chip 对不上,而没有任何地方
     会报错。放在 lib 是因为它对任何「把维度值做成列」的表都成立(AI网关的模型价格页
     把档位做成了列,图片/视频统计的维度表同理)。 */
  .table thead th.th-plain { text-transform: none; letter-spacing: normal; }
  /* **可排序表头**(2026-09-03)。只有「长什么样」在 lib:光标、箭头、当前排序列的
     高亮。「按哪个字段、怎么比大小」是消费方的数据逻辑,不是组件能替它决定的 ——
     所以这里没有任何 JS,消费方给 `<th data-sortable>` 加点击、排完自己写 `aria-sort`。

     为什么不让消费方自己写这几行:那就是每张表一份箭头、一份高亮,而箭头画在哪一边、
     未排序时多淡,这些是设计系统的事。`<dev-data-table>` 是另一条路(它连排序逻辑一起
     包了),但它的单元格 HTML 只许用组件自己的几个类 —— 塞不进带下拉、菜单与自定义
     单元格的运维表,所以两条路都要有。

     `aria-sort` 是原生 ARIA 属性,读屏器直接播报,不是我们发明的 data-*。

     **每一条都必须收在 `[data-sortable]` 里** —— 用户中心的「用户管理」「用量分析」
     两张表早就在 `.table` 的 `<th>` 上写 `aria-sort`,而且**自己已经渲染了 ▲/▼**:
     少了这个限定,那两张线上表的当前排序列会长出第二个箭头。它们还恒发
     `aria-sort="none"`,所以高亮那条也要排掉 `none`,否则**每一列**都被当成
     「当前排序列」,高亮等于失效。 */
  .table thead th[data-sortable] { cursor: pointer; user-select: none; }
  .table thead th[data-sortable]:hover { color: var(--foreground); }
  .table thead th[data-sortable]::after { content: "↕"; margin-inline-start: var(--sp-1); opacity: .35; }
  .table thead th[data-sortable][aria-sort]:not([aria-sort="none"]) { color: var(--foreground); }
  .table thead th[data-sortable][aria-sort="ascending"]::after { content: "↑"; opacity: 1; }
  .table thead th[data-sortable][aria-sort="descending"]::after { content: "↓"; opacity: 1; }
  .table:where(:not(.table-merged)) > tbody > tr:hover { background: var(--state-hover); }
  /* A merged label describes the group, not the hovered row. Keep it stable,
     including when the pointer is inside that label rather than a data cell. */
  .table-merged > tbody > tr:not([aria-selected="true"]):not(.is-selected):has(> td:not([rowspan]):hover) > td:not([rowspan]) { background: var(--state-hover); }
  .table-zebra > tbody > tr:nth-child(even) { background: color-mix(in oklab, var(--foreground) 3%, transparent); }
  .table > tbody > tr[aria-selected="true"], .table > tbody > tr.is-selected { background: var(--state-selected); }
  /* **粘住的表头必须有不透明底**(2026-09-17,随「表头去底色」同轮补):表头透明之后,
     滚动时正文会从它身下穿过去,两层字叠在一起。这不是审美问题,是读不了。
     用 `--card`(表几乎总在卡里);卡外的表底色是 `--background`,两者只差一档,
     叠不出可见的接缝。 */
  .table-sticky thead th { position: sticky; inset-block-start: 0; z-index: var(--z-raised); background: var(--card); }
  /* **紧凑表**:一格里只有一个 chip / 一个数 / 一段短文本时,默认的行高把一屏能看的
     行数砍掉一半,而这类表存在的理由正是「一眼扫完」。**它此前不存在** —— 三处
     产品代码都写着 `class="table table-sm"`,而 lib 里没有这个类:不报错,只是静默
     失效(2026-08-23 审出来的,与 `.text-subheading` / `gap-5` 那两个幽灵类同类)。 */
  .table-sm th, .table-sm td { padding: var(--sp-1) var(--sp-2); }
  /* **只收横向,不动行高**(2026-09-17,operator:「不要出现滚动条」)。
     一张 16 列的运维表里,每格两侧 12px 加起来就是 **384px** 全花在留白上 ——
     「上游渠道监控 · 按渠道」实测 1361px、容器 1322px,溢出 39px,于是那张要
     「一眼扫完」的表上挂着一条横滚条。而横滚条在这类表上是最坏的形态:右边那几列
     (超时率 / P50)恰恰是判断一条渠道好不好的那几列,推到屏幕外等于没有。
     **为什么不用 `.table-sm`**:它连纵向一起收,而这一页的行高是被 24 格条与
     「主名 + 副注」撑起来的 —— 收纵向只会让内容贴边,横向一分钱不省。
     两个档管两件事:`.table-sm` 收行高(一格只有一个 chip 的密表),
     `.table-tight` 收列距(列多到快要溢出的宽表)。可以叠。 */
  .table-tight th, .table-tight td { padding-inline: var(--sp-2); }
  /* **列与列之间画一条发丝线**(2026-09-17,operator:「有些不同意区分不同列的表头,
     文字有些混在一起了」)。列多的表把列距收紧之后,**分列全靠留白**这件事就破产了:
     「RPM」与「过去 24 小时 · 上:探针 下:用户请求」两个表头之间只剩 8px,读起来是
     一串连着的字。lib 此前只有 `.cg`(组边界),普通列之间一无所有 —— 于是一张表上
     只有三条线,而需要被分开的是十六列。

     **两级线,粗细分明**:组边界用 `--border-strong`(`.cg`,已有),普通列边界用
     更淡的 `--border`。两条线一样重的话,「这三列是一伙的」这个信息就没了 ——
     而那正是分组表头存在的全部理由。
     特异性是刻意的:这条是 `(0,1,1)`,而 `.table .cg` 是 `(0,2,0)`,所以组边界永远
     压得过它,不需要 `!important`,也不许把这条写成 `.table .table-ruled th`
     —— 那会反过来盖掉组边界,而页面上只表现为「三条粗线不见了」。 */
  .table-ruled th, .table-ruled td { border-inline-start: 1px solid var(--border); }
  /* 首列不画线(否则表格最左边多出一条与外框重叠的竖线)。**但要放过 `.cg`** ——
     分组表头的第二行里,`5M` 既是这一行的 `:first-child`、又是一个组边界,
     而 `:first-child` 把这条重置的特异性抬到 `(0,2,1)`,压过了 `.table .cg` `(0,2,0)`:
     组边界在那一格被悄悄抹掉,而它上面那一行还画着,所以肉眼几乎看不出来
     (2026-09-17 真机按计算样式逐格量才抓到)。 */
  .table-ruled th:first-child:not(.cg), .table-ruled td:first-child:not(.cg) { border-inline-start: 0; }
  /* **合并单元格里的内容顶到上边**。`rowspan` 的格子默认垂直居中,于是一个跨三行的
     渠道名会飘在中间,与它第一行的数据对不上 —— 读的人得回头找它属于哪几行。
     lib 里此前没有任何 vertical-align 原子(只有 flex 的 `.items-*`,那对表格无效),
     2026-08-23 随图片准入矩阵的渠道名合并一起沉淀。 */
  .align-top { vertical-align: top; }
  /* Dense operational tables with many columns should preserve readable cells
     and let .table-wrap provide horizontal scrolling instead of crushing every
     column into a few characters. */
  .table-wide { min-inline-size: 72rem; }
  /* **按内容定宽的表**(`.table-hug`,2026-10-14 用量汇总:收起时只有五列,拉满整行的宽度全是空白):
     `.table` 默认 `width: 100%`;这个修饰类让表缩到内容那么宽,数字列右对齐后挨在一起。
     **`.table-head-wrap`**:表头允许折行(`.table thead th` 默认 `nowrap`,列多、表头长的表被表头撑宽,
     而数据其实很窄);自动布局下列宽仍跟内容走,只是长表头在需要时折成两行。两者可叠,也可单用。 */
  .table.table-hug { inline-size: auto; }
  .table.table-head-wrap > thead > tr > th { white-space: normal; }
  /* Wrap a wide .table in this so it scrolls horizontally on a narrow screen
     instead of overflowing the viewport (clipping the 操作/copy buttons on the
     right). The wrapper carries the scroll; the table keeps its desktop layout. */
  /* `position: relative` makes the wrapper the containing block of absolutely
     positioned cells' content (`.sr-only` above all — an icon-only action column's
     label). Without it their containing block is OUTSIDE the scroller, so the
     scroller doesn't clip them and they widen the whole page instead: the v3
     gallery's ops table pushed a 390px phone page to 478px through one 1px
     sr-only span (2026-09-23). lib's menus/selects float with `position: fixed`,
     so this clips none of them. */
  .table-wrap { position: relative; inline-size: 100%; max-inline-size: 100%; overflow-x: auto; scrollbar-width: thin; -webkit-overflow-scrolling: touch; overscroll-behavior-inline: contain; }

  /* **分组表头**:两行 `th`,第一行是组名(跨若干列),第二行是列名。用在「同一批
     指标按两个来源并排」的对照表上 —— 图片规格手册的「openai 发送 / gemini 原生交付」、
     渠道对比、任何 A/B 两栏同构的表。
     lib 此前没有这个原子,于是产品侧手写 `border-inline-start` 的 inline style 去画
     组间竖线;而 `.table thead th` 自带大写+字距(它假设表头是标题),组名里的
     `openai-images` 会被抬成 `OPENAI-IMAGES`,与正文里那批小写的协议名对不上。
     **整条表头带只有一个底色**(2026-09-17 第三次订正,operator:「表头背景色你看到了吗?」)。
     前两版都想用「把组名格的底色去掉」来表达层级,结果是同一条带上被挖了几个洞 ——
     而第一行里那些**没有名字的列**(行尾操作列)反倒留着通高实色,右上角凸出一块。
     两个方向都错:底色是「这一整条是表头」的唯一信号,不该拿它当层级工具。
     层级改由**排版**表达,三件事:组名不大写 + 字重 medium + `--muted-foreground`;
     组名与子表头之间一条 `--border` 发丝线;子表头升到 `--foreground`。
     普通列(`rowspan=2`)底对齐,与子表头同一条基线。 */
  .table-grouped thead tr:first-child th { text-transform: none; letter-spacing: normal; font-weight: var(--weight-medium); }
  /* 组名格:居中、退淡、贴着下面那行,**底色照旧**。发丝线用 `--border`(比表头下沿的
     `--border-strong` 淡一档),读起来是「同一条带里的一道分隔」而不是第二条边界。 */
  /* 组名退后:**大写 + 常规字重 + 退淡**;子表头(下一行)反过来 —— 不大写、加重、
     升到 `--foreground`。层级全部由排版表达,不动字号(字号档最小就是 `--text-micro`,
     再往下是档位之间的尺寸,同一屏会出现 10 / 11 / 12 三种近似字号,读着就是糊)。 */
  .table-grouped thead tr:first-child th[colspan] { text-align: center; color: var(--muted-foreground); font-weight: var(--weight-normal); letter-spacing: var(--tracking-wide); border-block-end: 1px solid var(--border); padding-block-end: var(--sp-1); }
  /* 普通列(`rowspan=2`)**居中跨两行**,子表头升到 `--foreground`:子表头那一行是
     **可点排序**的那一行,它退淡的话「哪一列正在排序」的高亮对比度会一起变小,
     而那正是这类运维表最常用的交互。
     **居中是 2026-09-17 第二轮订正的**(此前是底对齐,理由是「与 5M/1H/6H 同一条基线」)——
     真机上那样会让表头**上半行的左边整片空白**:跨两行的那几列全贴在下半行,而组名只在
     右边几列上有,读起来像表格顶上多了一条空行。居中之后表头是一整块。 */
  /* 字重要写回来:上面那条 `tr:first-child th` 的 `--weight-medium` 是**给组名的**,
     而它连坐了跨两行的普通列头 —— 于是同一条表头带上「模型 / 状态」是 500、
     「5M / 1H / 6H」是 600,两种字重(2026-09-17 真机量出来的)。 */
  .table-grouped thead tr:first-child th[rowspan] { vertical-align: middle; font-weight: var(--weight-semibold); }
  .table-grouped thead tr:last-child th { color: var(--foreground); font-weight: var(--weight-semibold); padding-block-start: var(--sp-1); }
  /* 组的第一列画一条竖线。**必须同时打在 `th` 与 `td` 上**(挂在 `col` 上不生效:
     `border` 不是 `<col>` 支持的属性之一),所以它是一个格子级的类。 */
  .table .cg { border-inline-start: 1px solid color-mix(in oklab, var(--border-strong) 55%, transparent); }

  /* **密度标准:行的密度由「格子里装了什么」决定,不由页面自己挑**
     (2026-09-18,operator:「你的表格设计的还是不行,太拥挤了。我们 lib 表格到底有没有标准。」)

     他说的是对的,而且量得出来:线上那张表行高 **35px**,而格子里装着 **26px 的按钮**与
     **26px 的缩略图** —— 上下各只剩 4.5px。病根不是字号,是**产品自己挑了密档**:
     `.table-sm` 是给「一格只有文字 / 数字 / 一个小标签」的表用的,而那一行里有控件。

     所以密度不再是页面的选择题,而是一条规则:

       一格里有**控件**(按钮 / 下拉 / 勾选框)或**缩略图** → 默认档 + 最小行高
       一格只有文字 / 数字 / 小标签                        → 密档(`.table-sm`)

     最小行高**从梯子推**,不写死像素:`--control-h-sm`(控件高)+ `--sp-4`(上下各 --sp-2)。
     控件高度改一次,这条跟着走 —— 写死 42px 的话,下次调控件高就会悄悄变回「贴着行线」。

     `<dev-table>` 按列声明**自动**挂这个类(`control: true` / `actions: true`),所以
     产品再也不需要知道什么时候该写 `table-sm` —— 那正是这次拥挤的来源。 */
  .table-controls tbody td { padding-block: var(--sp-2); }
  .table-controls tbody tr { block-size: calc(var(--control-h-sm) + var(--sp-4)); }
  /* 密档与控件档互斥:两个都挂上时,控件档赢(它是保底,不是偏好)。 */
  .table-sm.table-controls th, .table-sm.table-controls td { padding: var(--sp-2) var(--sp-3); }

  /* ── 下面五个原子是 2026-09-17 全树盘点(~140 张表)之后补的:每一个今天都在产品里
     被手写了至少两遍,而手写的那几份互相不一样。operator 的要求是「所有页面所有表格都用
     lib 表格」,那就得先让 lib 真的够用 —— 缺一个原子,迁过去的人只能再手写一份。 ── */

  /* **合计行**。今天 stats.js 写的是
     一条内联的 `border-top: 2px solid var(...)`,而它引用的那个变量名 ——
     而它引用的那个变量名在 v3 里**根本不存在**(拼成了 `--color-` 前缀那一套),那条上边框一直是透明的:合计与明细之间
     没有任何分界,而这一行正是整张表要人看的那个数。用 `<tfoot class="table-total">`。 */
  /* 合计行同理:分界靠**一条发丝线 + 一点底色**,不靠粗线(与表头同一轮订正)。 */
  .table tfoot td, .table .table-total td { border-block-start: 1px solid var(--border-strong); border-block-end: none; padding-block-start: var(--sp-3); font-weight: var(--weight-semibold); background: color-mix(in oklab, var(--foreground) 2%, transparent); }

  /* **展开行**(`<tr class="row-detail"><td colspan=N>`)。今天有四套写法:嵌一张表、
     追加一条 `<tr>`、格子里塞 `<details>`、`.accordion-body`。四套的内边距各不相同,
     于是同一个产品里展开出来的东西一会儿贴边一会儿留一大圈。
     判据只有一条:**展开区是上一行的延续,不是新的一行** —— 所以它不画上边框(与
     触发行之间不断开),左边留一道 2px 的实边把它归属回去。 */
  .table tr.row-detail > td { border-block-start: none; padding: var(--sp-2) var(--sp-3) var(--sp-3); background: color-mix(in oklab, var(--foreground) 1.6%, transparent); box-shadow: inset 2px 0 0 var(--border-strong); }
  /* **展开区里的子表**(`.table-nested`,2026-09-27 评测列表页):父行下面的一张小表,它是父行的延续 ——
     没有自己的卡片边框、表头退一档(小一号、无底色)、行与行之间只用发丝线。不写它,子表会带着和父表
     一模一样的表头条与行高,读起来像又一张独立的大表,层级只剩一道 2px 边线在说话。 */
  .table.table-nested { background: transparent; }
  /* 子表的外框:父行下面一块内缩的浅面板,自带一行小标题(`.nested-head`,写清「这是谁的什么」)。
     父表与子表列不同是常态(渠道一行 / 模型一行)—— 靠这块面板把子表放进父行里,而不是让两套表头
     在同一张表里并排,读起来像两张表硬拼在一起。 */
  .nested-panel { margin-inline-start: var(--sp-6); border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); overflow: hidden; }
  .nested-head { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border-block-end: 1px solid var(--border); background: var(--surface-sunken); font-size: var(--text-micro); font-weight: var(--weight-semibold); color: var(--muted-foreground); }
  .nested-panel > .table-wrap > .table.table-nested > thead > tr > th:first-child,
  .nested-panel > .table-wrap > .table.table-nested > tbody > tr > td:first-child { padding-inline-start: var(--sp-3); }
  .table.table-nested > thead > tr > th { background: transparent; font-size: var(--text-micro); font-weight: var(--weight-medium); color: var(--muted-foreground); padding-block: var(--sp-1); border-block-end: 1px solid var(--border); }
  .table.table-nested > tbody > tr > td { padding-block: var(--sp-2); border-block-end: 1px solid color-mix(in oklab, var(--border) 60%, transparent); }
  .table.table-nested > tbody > tr:last-child > td { border-block-end: none; }
  /* **钉死列宽**(`.table-fixed` + `<colgroup>` 里的 `.col-w-*`,2026-09-29 评测列表页):几张列相同的表
     上下摆着(每条渠道展开一张模型子表),自动布局会让每张表按各自格子里的内容定列宽 —— 同一列在
     两张表里差出几十像素,读起来像没对齐。固定布局只认 `<col>` 上的宽度,不看内容:没给宽度的那一列
     吃掉剩下的宽度(一般是名字列)。宽度只走这把梯子,不写 inline `width` —— 与 `.field-w-*` 同一条理由。
     格子里的长词在固定宽度里换行,不溢进邻列。**表头也换行**:`.table thead th` 的 `nowrap` 在自动布局里
     是对的(列会被撑宽),在固定布局里列宽不会让步,于是长表头(西语「Interruptor de razonamiento」
     在 8rem 的列里)直接画进隔壁列 —— 所以这里把表头的 `white-space` 还回 `normal`。 */
  .table.table-fixed { table-layout: fixed; }
  .table.table-fixed > :is(thead, tbody, tfoot) > tr > :is(th, td) { overflow-wrap: anywhere; }
  .table.table-fixed > thead > tr > th { white-space: normal; }
  .col-w-1 { inline-size: 2.5rem; }
  .col-w-2 { inline-size: 4rem; }
  .col-w-3 { inline-size: 6rem; }
  .col-w-4 { inline-size: 8rem; }
  .col-w-5 { inline-size: 10rem; }
  .col-w-6 { inline-size: 12rem; }
  .col-w-7 { inline-size: 16rem; }
  .col-w-8 { inline-size: 20rem; }

  /* **分段带**(`<tr class="row-band"><td colspan=N>`):把一张长表切成几段,段名独占一行。
     评测对比页今天手写 `style="background:var(--surface-sunken)"`。 */
  .table tr.row-band > td { background: var(--surface-sunken); font-size: var(--text-micro); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  /* **组头带**(`.row-band-group`,写在带内容的外层):带子不只是段名,而是一组行的表头 ——
     名字、这一组的结论、这一组的按钮(模型评测:渠道一段,段头是渠道测试,下面一个模型一行)。
     它要正文的字号与颜色,不要段名的小号灰字;底色与分隔仍是 row-band 的。 */
  .table tr.row-band > td > .row-band-group { font-size: var(--text-caption); font-weight: var(--weight-normal); letter-spacing: normal; color: var(--foreground); }

  /* **空态行**(`<tr><td colspan=N class="table-empty">`)。两套写法今天并存:一套换掉整张表,
     一套塞一行字。后者要保留表头(列还在,只是没有数据),所以它是一行,不是一张卡。 */
  .table td.table-empty { text-align: center; padding-block: var(--sp-4); color: var(--muted-foreground); }

  /* **冻结首列**:宽表横向滚动时,行的身份(名字/id)不能跟着滚走 —— 滚到第 10 列就
     不知道自己在看谁了。要求外面那层是 `.table-wrap`(滚动发生在它身上)。
     底色必须显式给,否则滚过来的格子会从它身下透出来(与 `.table-sticky` 同一个道理)。 */
  .table-freeze-1 th:first-child, .table-freeze-1 td:first-child { position: sticky; inset-inline-start: 0; z-index: var(--z-raised); background: var(--card); }
  .table-freeze-1 thead th:first-child { z-index: var(--z-raised-2); }
  .table-freeze-1 th:first-child::after, .table-freeze-1 td:first-child::after { content: ""; position: absolute; inset-block: 0; inset-inline-end: 0; inline-size: 1px; background: var(--border); }

  /* **行尾操作槽**(2026-09-17)。一张运维表的最后一格是「⋯ 菜单 + 一颗表示在跑的
     常驻占位」,三个产品各自手拼 `<span class="cluster items-center justify-end">`,
     而那是个 **inline** 盒子:`td` 的 `vertical-align: middle` 只管这一格相对行的
     位置,管不到格子里的 inline 内容 —— 那一串 span 按**基线**对齐,常驻占位槽把
     基线整个拖下去。实测 xapi-tool 渠道监控两张表、每一行的 `⋮` 都比行的垂直中心
     **低 10.0px**,而这是「看着不精致」里最刺眼的一处(operator 2026-09-17 两次点名)。
     修法是让它根本不走基线:块级 flex + `align-items: center`。
     `.slot` 是那颗常驻占位(转圈 / 中性圆点):**闲时 `visibility: hidden` 仍占位**,
     不占位的话每次开测都会让整列宽度跳一下,那是 2026-09-17 上午刚修掉的抖动。 */
  .cell-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-1); }
  /* **名字叫 `.cell-slot`,不叫 `.slot`** —— lib 早就有一个 `.slot`(素材选择器的格子,
     带 `min-inline-size: 5.5rem`)。撞名的后果不是覆盖,是**更阴**的一种:
     `min-inline-size` 压得过 `inline-size`,于是这一格被撑到 88px、行尾整列从 52px
     变成 142px,而没有任何一处报错(2026-09-17 真机量出来的)。
     三个尺寸全部显式钉死,不给任何环境里的 `min-*` / `max-*` 留缝。 */
  .cell-actions > .cell-slot { display: flex; align-items: center; justify-content: center; inline-size: 14px; min-inline-size: 14px; max-inline-size: 14px; block-size: 14px; flex: none; }

  /* **一列只有一个控件时,让它撑满格子**(2026-09-17)。一列全是下拉/按钮而宽度按
     内容收缩,那一列的右边缘就是锯齿状的:实测「探测策略」列并排着 52 / 116 / 123px
     三种宽度(「关闭」「1 小时 · 模型保活」「20 分钟 · 模型保活」)。
     **只用在「一列只有一个控件」的格子上** —— 一行里并排两颗按钮时撑满是错的,
     所以它是格子级的选择,名字里带 `fill`。`space-between` 把尾巴上的 `▾` 推到
     右边缘,于是整列的可点区域是一条直边。 */
  .control-fill { inline-size: 100%; justify-content: space-between; }
  /* **撑满的是「包着它的那一层」的宽度,不是格子的** —— 这一条不是补丁,是这个原子
     不完整。运维表里的控件几乎总有一层触发器容器(`<dev-menu>` 的下拉、`<label>`
     的字段),而那一层自己按内容收缩,于是 `inline-size: 100%` 撑满了一个已经收缩过
     的盒子 = 什么都没做。2026-09-17 真机实测:探测策略列加了 `.control-fill` 之后
     **仍然是 68 / 75 两种宽度**(`dev-menu` 是 `inline-block`),四条不变量里的 B
     当场没过 —— 单测与设计稿都看不出来,只有真浏览器能。
     `:has(> ...)` 只命中**直接父层**,而且这里逐个点名了可以安全变成块级的容器:
     **绝不能写成裸 `:has()`** —— 那会命中 `<td>` 本身,把它 `display: block` 掉,
     整张表当场塌成一列。 */
  dev-menu:has(> .control-fill),
  label:has(> .control-fill),
  span:has(> .control-fill) { display: block; inline-size: 100%; }

  /* **主名 + 副注**(2026-09-17)。运维表的第一列几乎都是「一个 id + 一句状态注解」,
     而 xapi-tool / user-center / xshort 各拼了一份(`stack gap-1` + 手写字号),
     三份行距不一样 —— 那正是 operator 说的「不成体系」的本体。
     两条硬行为:**主名不折行**(超长省略,调用方必须给 `title`,否则省略号后面的
     内容无处可取);**副注是一行小字,不是彩色 tag** —— 一张表里每隔两行冒一颗琥珀色
     tag,等于把「故障」两个字的份量用光,而它本来就不是状态(状态列另有),是注解。
     容器用已有的 `.min-w-0 .flex-1`(不新造 `.cell-main`):`min-inline-size: 0` 是
     flex 子项能真的省略而不是撑破容器的前提。 */
  /* **`.cell-title` 只管布局,不管字体**(2026-09-17 订正)。第一版把
     `font-family: var(--font-mono)` 写了进去 —— 那是把排版耦合进一个布局原子:
     调用方想要一个不折行的**中文**标题就没得选,而且等宽字比同样字数的无衬线宽,
     实测把渠道表从 1322 撑到 1378px(那张表 operator 刚说过「又被撑大了」)。
     要等宽就叠 `.mono`,lib 已经有了。 */
  .cell-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .cell-title-wrap { max-inline-size: 24ch; white-space: normal; overflow-wrap: anywhere; }
  .cell-meta { display: block; margin-block-start: var(--sp-1); font-size: var(--text-micro); line-height: var(--leading-tight); color: var(--muted-foreground); }

  /* **格子级语义**:一格的值相对某个基准是「一样 / 不一样 / 没有数据」。
     lib 已有的 `.diff-*` 解决的是另一件事(文本双栏面板,整行加/删底色),
     对照表里逐格标注用不上它。
     三种状态**只靠颜色与字重**,一律不铺底色 —— 一张十行八列的对照表里,只要有
     三分之一的格子铺了底,整张表就变成了色块图,而真正要找的「哪几格不一样」
     反而淹了。铺底色的那一版 2026-08-24 在设计稿上试过,当场判掉。 */
  .cell-base { color: var(--muted-foreground); }
  .cell-flag { color: var(--warning-text); font-weight: var(--weight-semibold); }
  .cell-none { color: var(--muted-foreground); opacity: .45; }
  /* 「这一格与基准**不同**」(2026-08-25)。与 `.cell-flag` 是两件事:琥珀那个说的是
     「注意看一眼」(存疑 / 待办 / 未保存),这个说的是一个**已经确定的差异** ——
     一张同构对照表里,读的人要一眼扫出哪几格偏离了共用基准。同样不铺底色。 */
  .cell-diff { color: var(--danger-text); font-weight: var(--weight-semibold); }
  /* **子项缩进**(2026-09-18):分段表里「分类行 → 它下面的项目行」这层层级此前没有原子,
     于是评测对比页写了行内 `padding-inline-start:1.5rem`。缩进量走 `--sp` 梯子,与
     `.tree-group` 同一格 —— 表里的层级与树里的层级是同一件事,两个数不该不一样。
     模板的 `cellClass` 是**按列**给的,而缩进是**按行**的事,所以这个类既能写在 `<td>` 上,
     也能写在格子里那一层元素上 —— 后者要撑出左边距,必须是块级。 */
  .table .cell-indent { padding-inline-start: calc(var(--sp-3) + var(--sp-4)); }
  .table span.cell-indent { display: inline-block; }
  /* **嵌套子行**(`.row-toggle` + `tr.row-child`):父行第一格放一颗折叠钮,它下面的子行
     **与父行共用同一组列** —— 子行的格子落在父表同一列里(价格列、数量列对得上),
     这是它与 `.row-detail`(整行 colspan 的一块展开区)的区别。子行不画新结构:行高、
     格内边距就是 `.table td` 的那一档(`--sp` 梯子),只是第一格缩进到父行**标签**的左缘
     (折叠钮宽 + 一格间距之后)、标签退一档灰。默认折叠:子行带 `hidden`,折叠钮
     `aria-expanded="false"` + `aria-controls="<子行 id 列表>"`;展开时宿主去掉 `hidden`、
     置 `aria-expanded="true"`。钮是原生 <button>,Tab 聚焦、Enter/Space 触发。 */
  .table { --row-toggle-w: 1.25rem; }
  .table .row-toggle { display: inline-flex; align-items: center; justify-content: center; inline-size: var(--row-toggle-w); block-size: var(--row-toggle-w); margin-inline: 0 var(--sp-2); margin-block: -0.125rem; padding: 0; vertical-align: middle; border: none; border-radius: var(--radius-sm); background: none; color: var(--muted-foreground); font: inherit; line-height: 1; cursor: pointer; }
  .table .row-toggle::before { content: "▸"; transition: transform var(--duration-fast) var(--ease-smooth); }
  .table .row-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }
  .table .row-toggle:hover { background: var(--state-hover); color: var(--foreground); }
  .table .row-toggle:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .table tr.row-child[hidden] { display: none; }
  /* 缩进用占位伪元素而不是改 padding:`.table-sm` / `-tight` / `-controls` 各自的格内边距不同,
     占位随当前那一档走,标签左缘永远与父行标签对齐。 */
  .table tr.row-child > td:first-child { color: var(--muted-foreground); }
  .table tr.row-child > td:first-child::before { content: ""; display: inline-block; inline-size: calc(var(--row-toggle-w) + var(--sp-2)); }
  /* **表格里再赢一次**(2026-08-24 真机抓到)。这三个几乎总是与字号工具类连用
     (`class="mono text-micro cell-flag"`),而 `.text-micro` 自己也设
     `font-weight: var(--weight-normal)` 且排在本段**之后** —— 同等特异性,后来者赢,
     于是「不符」那一格的加粗**静默消失**,只剩颜色。色觉障碍下那张表就没有编码了。
     加一层 `.table` 前缀把特异性抬到 (0,2,0):这不是硬凑,这三个本来就是**表格
     单元格**的语义,写在表格上下文里是它们的本分。工具类在别处照旧赢。 */
  .table .cell-base { color: var(--muted-foreground); }
  .table .cell-flag { color: var(--warning-text); font-weight: var(--weight-semibold); }
  .table .cell-none { color: var(--muted-foreground); opacity: .45; }
  .table .cell-diff { color: var(--danger-text); font-weight: var(--weight-semibold); }

  /* **行内编辑格**:平时是文本,hover 浮出边框,点进去才是输入框,失焦即存。
     用在「看着改」的表上(规格手册的尺寸表、倍率表、任何一屏几十个可改数值的表)。
     此前这类表是**每格一个 `<input>`** —— 30 个边框糊成一片格子牢笼,而其中
     大多数格子人这辈子都不会去改。语义是「这是数据,顺手能改」,不是「请填表」。
     `min-inline-size` 保证空值也点得到(否则一个空格子塌成 0 宽,鼠标够不着)。 */
  .ecell { display: inline-block; min-inline-size: var(--ecell-w, 6.5rem); padding: 2px 6px; margin: -2px -6px; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: text; transition: border-color .1s, background .1s; }
  .ecell:hover { border-color: var(--border); background: var(--muted); }
  .ecell:focus-visible, .ecell.is-editing { border-color: var(--ring); background: var(--card); outline: var(--focus-outline); outline-offset: -1px; }
  .ecell-empty { color: var(--muted-foreground); opacity: .55; }

  /* 表单控件的宽度梯子。产品侧此前一律写 `style="inline-size:8rem"` —— inline 尺寸
     与 lib-frontend-spec §4b 禁的 inline 间距是同一类熵:它落在 `--sp` 网格之外,
     而且同一种控件在不同页面上会长出四种宽度。 */
  /* **也要能压住 `select.input`**:基础规则那一条的选择器里带元素名(`select.input`,
     0-1-1),比单个类(0-1-0)重 —— 于是 `<select class="input field-w-3">` 上这把梯子
     静默失效,下拉照旧占满整行。加一条同名的 `.input.field-w-*`(0-2-0)把它压回来,
     否则「宽度只走梯子、不写 inline 尺寸」这条纪律对下拉根本执行不了
     (2026-08-28 真机拍到:参考行的用途下拉宽到半屏)。 */
  /* A fixed-width field never exceeds its column: on a 390px phone a 24rem datetime input
     otherwise pokes past the card (xtoken 公告发布, 2026-09-05). */
  .field-w-1, .field-w-2, .field-w-3, .field-w-4, .field-w-5 { max-inline-size: 100%; }
  .field-w-1, .input.field-w-1 { inline-size: 4rem; }
  .field-w-2, .input.field-w-2 { inline-size: 6.5rem; }
  .field-w-3, .input.field-w-3 { inline-size: 9rem; }
  .field-w-4, .input.field-w-4 { inline-size: 13rem; }
  /* 第五格给「一句话」宽的控件(保存过的对比名、搜索框)——梯子少一格,产品就会
     回去写 `style="inline-size:16rem"`,而那正是这把梯子存在的理由。 */
  .field-w-5, .input.field-w-5 { inline-size: 16rem; }

  /* Input group + addons */
  .input-group { display: flex; align-items: stretch; }
  /* **按钮也是这一组的成员**(2026-08-14):「输入框 + 一颗动作按钮」是这套组件最常见的
     用法(搜索框配「搜索」、标签框配「加入」、金额框配单位)。此前只给 .input 与
     .input-addon 削圆角,按钮不在列 —— 于是按钮保留自己的胶囊圆角与投影,两件接不上,
     而且 .btn-sm(26px)比输入框(30px)矮一截。**产品侧一律不许自己补这几行**:
     补一次就是第二套外观,而它只会在某一个页面上是对的。 */
  .input-group > .input, .input-group > .input-addon, .input-group > .btn { border-radius: 0; }
  /* 投影会盖在邻居的边框上,组里一律去掉;高度对齐输入框(btn-sm 也拉到同一高)。 */
  .input-group > .btn { box-shadow: none; min-height: var(--c-input-h); }
  .input-group > :first-child { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); }
  .input-group > :last-child { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); }
  .input-group > :not(:first-child) { margin-inline-start: -1px; }
  /* 聚焦环画在 outline 上,而 -1px 的负边距会让右邻居压住它 —— 聚焦的那一个提到上层。 */
  .input-group > .input:focus-visible, .input-group > .btn:focus-visible { position: relative; z-index: var(--z-raised); }
  .input-addon { display: inline-flex; align-items: center; padding: 0 var(--sp-3); white-space: nowrap; background: var(--secondary); border: 1px solid var(--border); color: var(--muted-foreground); font-size: var(--text-caption); }

  /* Button group (segmented) */
  .button-group { display: inline-flex; }
  .button-group > .btn { border-radius: 0; }
  .button-group > .btn:first-child { border-start-start-radius: var(--radius); border-end-start-radius: var(--radius); }
  .button-group > .btn:last-child { border-start-end-radius: var(--radius); border-end-end-radius: var(--radius); }
  .button-group > .btn:not(:first-child) { margin-inline-start: -1px; }

  /* Breadcrumb */
  .breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; font-size: var(--text-caption); color: var(--muted-foreground); }
  .breadcrumb li { display: flex; align-items: center; gap: var(--sp-2); }
  .breadcrumb li:not(:last-child)::after { content: "/"; color: var(--border-strong); }
  .breadcrumb a { color: var(--muted-foreground); text-decoration: none; }
  .breadcrumb a:hover { color: var(--foreground); }
  .breadcrumb [aria-current="page"] { color: var(--foreground); font-weight: var(--weight-medium); }

  /* Pagination */
  .pagination { display: flex; align-items: center; gap: var(--sp-1); margin: 0; padding: 0; list-style: none; }
  .pagination .page { display: inline-flex; align-items: center; justify-content: center; min-width: var(--control-h); height: var(--control-h); padding: 0 var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); color: var(--foreground); font-size: var(--text-caption); text-decoration: none; cursor: pointer; }
  .pagination .page:hover { border-color: var(--border-strong); background: color-mix(in oklab, var(--foreground) 5%, var(--surface-raised)); }
  .pagination .page:active { background: color-mix(in oklab, var(--foreground) 9%, var(--surface-raised)); }
  .pagination .page[aria-current="page"] { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }

  /* List */
  .list { display: flex; flex-direction: column; overflow: hidden; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg); }
  .list-header { padding: var(--sp-2) var(--sp-3); font-size: var(--text-micro); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--muted-foreground); background: var(--surface-sunken); border-block-end: 1px solid var(--border); }
  /* A row inside .list. USE `.list-row` — `.list-item` is kept only for the
     existing non-Tailwind consumers.
     Why two names: `list-item` is ALSO a Tailwind display utility
     (`display: list-item`). A Tailwind consumer that merely writes lib's class
     name in JSX makes Tailwind emit `.list-item{display:list-item}` into its
     utilities layer — which sits above lib's — so the row silently stops being
     a flex box: `justify-between` goes inert and label/value collapse into one
     run of text. Observed live on chat.develop.cc 2026-07-30. */
  .list-row, .list-item { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); border-block-end: 1px solid var(--border); }
  .list-row:last-child, .list-item:last-child { border-block-end: none; }
  .list-row:hover, .list-item:hover { background: var(--state-hover); }
  .list-row.list-row-bordered, .list-item.list-item-bordered { border: 1px solid var(--border); border-radius: var(--radius); }
  /* "Release here." Set it while a drag is over a row that will actually accept
     the payload — never on hover, and never on a row that would reject the drop:
     a highlight is a promise, and a target that lights up but refuses the drop
     is worse than one that never lit. Generic on purpose (rows, cards, tiles all
     receive drags); the host owns the decision, lib owns the look. */
  .is-drop-target { outline: 2px dashed var(--primary); outline-offset: -2px; background: color-mix(in oklab, var(--primary) 8%, transparent); }
  /* Dense rows — a side rail is not a settings page. `--sp-3` all round is right
     when a row is the page's main content and wrong when twenty of them share a
     22rem rail with a search box and a tab strip. Opt in on the container so no
     existing list changes. */
  .list-dense > :is(.list-row, .list-item) { padding: var(--sp-1) var(--sp-2); gap: var(--sp-2); }
  /* Read-only list — rows that are DATA (a spec sheet's label/value pairs), not
     choices. Kills the row hover: a highlight under the cursor promises a click
     that never happens. Opt-in on the container so every existing interactive
     list keeps its hover untouched. */
  .list.list-static > :is(.list-row, .list-item):hover { background: transparent; }
  /* Single-line title cell for any row (list row, sidebar row, picker option).
     Titles here are DATA (a chat session name, a file name) — they arrive at
     any length, and wrapping them turns a 50-row rail into ragged, unscannable
     blocks. min-inline-size:0 is the load-bearing part: without it the flex
     item refuses to shrink below its text and the ellipsis never appears. */
  .row-title { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* Banner (page-level vs inline .alert) */
  .banner { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); font-size: var(--text-caption); color: var(--foreground); background: var(--surface-sunken); border-block-end: 1px solid var(--border); }
  .banner-success { background: var(--success-bg); }
  .banner-warning { background: var(--warning-bg); }
  .banner-danger { background: var(--danger-bg); }
  .banner-info { background: var(--info-bg); }

  /* Accordion (native <details>) */
  .accordion { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); }
  .accordion > details { border-block-end: 1px solid var(--border); }
  .accordion > details:last-child { border-block-end: none; }
  /* Header rule is scoped to the accordion's OWN summary (both markup shapes:
     `<details class="accordion">` and `<div class="accordion"><details>`). A
     descendant selector leaked this 12px flex header — and its far-right caret —
     onto every inline <details> that happened to live inside an accordion body
     (a "原始错误详情" disclosure inside a card rendered 249×41 with its marker
     171px away from its own label). */
  .accordion > summary,
  .accordion > details > summary { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); font-weight: var(--weight-medium); cursor: pointer; list-style: none; }
  .accordion > summary:hover,
  .accordion > details > summary:hover { background: var(--state-hover); }
  .accordion > summary:focus-visible,
  .accordion > details > summary:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); border-radius: var(--radius-sm); }
  .accordion > summary::-webkit-details-marker,
  .accordion > details > summary::-webkit-details-marker { display: none; }
  .accordion > summary::after,
  .accordion > details > summary::after { content: "▸"; color: var(--muted-foreground); transition: transform var(--duration-fast) var(--ease-smooth); }
  /* Open state must rotate for BOTH shapes; keying only on a descendant
     `details[open]` left `<details class="accordion" open>` showing a collapsed
     caret while its body was expanded. */
  .accordion[open] > summary::after,
  .accordion > details[open] > summary::after { transform: rotate(90deg); }
  .accordion-body { padding: 0 var(--sp-3) var(--sp-3); font-size: var(--text-caption); color: var(--muted-foreground); }

  /* Single advanced disclosure. Channels often need one optional filter or
     expert-settings block without constructing a full accordion wrapper. */
  .advanced-filter { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); }
  .advanced-filter > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-3); font-weight: var(--weight-medium); cursor: pointer; list-style: none; }
  .advanced-filter > summary:hover { background: var(--state-hover); }
  .advanced-filter > summary:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); border-radius: var(--radius-sm); }
  .advanced-filter > summary::-webkit-details-marker { display: none; }
  .advanced-filter > summary::after { content: "▸"; color: var(--muted-foreground); transition: transform var(--duration-fast) var(--ease-smooth); }
  .advanced-filter[open] > summary { border-block-end: 1px solid var(--border); }
  .advanced-filter[open] > summary::after { transform: rotate(90deg); }
}

/* ===== vP6 — component breadth: batch 1 (separator / label / skeleton-text /
   stat-card / segmented). Pure CSS, same @layer merges. ===== */
@layer lib.components {
  /* Separator (explicit h/v line; .divider stays the margin'd <hr>) */
  .separator { flex: none; border: none; background: var(--border); }
  .separator:not(.separator-vertical) { block-size: 1px; inline-size: 100%; }
  .separator-vertical { inline-size: 1px; align-self: stretch; min-block-size: 1em; }
  /* Dashed variant — soft boundary for collapsible detail regions (e.g. a
     composer's advanced-params fold). Draws with a dashed border, not bg. */
  .separator-dashed:not(.separator-vertical) { block-size: 0; background: transparent; border-block-start: 1px dashed var(--border); }
  .separator-dashed.separator-vertical { inline-size: 0; background: transparent; border-inline-start: 1px dashed var(--border); }

  /* Fit-content input/select — for bare inline controls in a toolbar row where
     the default width:100% would blow the flex line. select.input-fit matches
     select.input's specificity so the width:100% base loses. */
  .input-fit, select.input-fit { width: auto; min-width: 0; }

  /* Standalone label (complements grid-context .form-label) */
  .label { display: inline-flex; align-items: center; gap: var(--sp-1); font-size: var(--text-caption); font-weight: var(--weight-medium); color: var(--foreground); }
  .label .required { color: var(--danger-text); }
  .label-muted { color: var(--muted-foreground); font-weight: var(--weight-normal); }

  /* Skeleton text line (paragraph placeholders; last line is shorter) */
  .skeleton-text { display: block; block-size: 0.7em; margin-block: 0.35em; border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--muted) 25%, var(--surface-sunken) 50%, var(--muted) 75%); background-size: 200% 100%; animation: lib-shimmer 1.3s linear infinite; }
  .skeleton-text:last-child { inline-size: 70%; }

  /* NOTE: .stat-card intentionally NOT defined here — the clipboard channel
     already uses that class name for its own boxes. It will return once that
     channel adopts lib's version (see one 仓 git show ba73b77:docs/lib-design-system-progress.md(已归档)). */

  /* Segmented control (visual radio / mode switch). Works with aria-pressed
     buttons OR <label><input type=radio>…</label> via :has().
     段也可以是 <a>:同页换视图用 button,跨页去另一个视图必须是真链接 ——
     否则中键 / ⌘点击 / 右键复制链接全没了。当前段用 aria-current 表达。 */
  /* 互斥单选的**唯一**长相(平台规则:.chip 只表示开关,不表示互斥的一组)。
     `flex-wrap` 是能力不是装饰:取值多到一行放不下时要换行,不是把最后一档裁掉
     —— 分辨率档就因为这个被裁过,于是产品改用了 .chip 排,同一个弹窗里互斥单选
     从此有了两种长相(2026-08-15 operator:「感觉每个都是重新设计的」)。 */
  .segmented { display: inline-flex; inline-size: fit-content; flex-wrap: wrap; gap: 2px; padding: 2px; background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius); }
  /* `text-decoration` 只收 <a> 成员(button/label 本来就没下划线)。写成三者通吃时,
     它的特异性(0,1,1)压过零特异性的状态层,于是系统级的「没货 = 划线」落不到
     分段控件上 —— 2026-08-15 设计系统页当场量出来的:.picker-option 划了线,
     .segmented > button 是 none。基座规则不许顺手把状态层的话堵死。 */
  .segmented > a { text-decoration: none; }
  .segmented > button, .segmented > label, .segmented > a { appearance: none; border: none; background: none; cursor: pointer; font: inherit; font-size: var(--text-caption); color: var(--muted-foreground); padding: 0 var(--sp-3); min-block-size: calc(var(--control-h) - 6px); display: inline-flex; align-items: center; justify-content: center; border-radius: calc(var(--radius) - 2px); white-space: nowrap; transition: background-color 120ms var(--ease-smooth), color 120ms var(--ease-smooth); }
  .segmented > button:hover, .segmented > label:hover, .segmented > a:hover { color: var(--foreground); }
  /* v3: the active segment carries the accent tonally (amber wash + ink). */
  .segmented > button[aria-pressed="true"], .segmented > [aria-selected="true"], .segmented > [aria-current], .segmented > .is-active, .segmented > label:has(input:checked) { background: var(--btn-tonal-selected-bg); color: var(--btn-tonal-fg); box-shadow: var(--shadow-card); font-weight: var(--weight-medium); }
  .segmented > label > input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
  .segmented > button:focus-visible, .segmented > a:focus-visible, .segmented > label:focus-within { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  /* 「这一档现在没有通路承载」—— 不是禁掉一个动作,是这个**取值**此刻没货:留在
     原位划掉,人才知道「有这一档,只是现在选不了」。以前只有 opacity,表达不了
     这句话,产品于是绕去用 .chip(它有划线)——补齐这一态,互斥单选才回得来。 */
  .segmented > button:disabled, .segmented > label:has(input:disabled) { opacity: var(--state-disabled-opacity); cursor: not-allowed; text-decoration: line-through; }

  /* Menu / dropdown surface — styles the slotted content of <dev-menu>, and
     stands alone for pure-CSS menus. */
  .menu { display: flex; flex-direction: column; min-inline-size: 11rem; max-block-size: var(--c-popover-max-h); overflow: auto; overscroll-behavior: contain; padding: var(--sp-1); background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--elevation-3); }
  /* 宽面板变体:带分类 tab / 缩略图列表的选择型浮层(@ 提及面板一类)。默认 11rem
     会把 tab 行挤出横向滚动条;视口不够宽时以视口收底,绝不横向溢出。 */
  .menu-lg { inline-size: 30rem; max-inline-size: calc(100vw - 2rem); }
  .menu-item { display: flex; align-items: center; gap: var(--sp-2); inline-size: 100%; box-sizing: border-box; padding: var(--sp-1) var(--sp-2); border: none; background: none; border-radius: var(--radius-sm); font: inherit; font-size: var(--text-caption); color: var(--foreground); text-align: start; cursor: pointer; }
  .menu-item:hover { background: var(--state-hover); }
  .menu-item:focus-visible { background: var(--state-hover); outline: var(--focus-outline); outline-offset: calc(var(--focus-outline-offset) * -1); }
  .menu-item.is-active { background: var(--state-selected); box-shadow: inset 2px 0 0 var(--ui-accent); }
  .menu-item:disabled, .menu-item[aria-disabled="true"] { opacity: var(--state-disabled-opacity); pointer-events: none; }
  /* Hover paints --danger-bg under this label, so the ink step is mandatory
     here too — the fill tone on its own tint is 3.74:1. */
  .menu-item-danger { color: var(--danger-text); }
  .menu-item-danger:hover, .menu-item-danger:focus-visible { background: var(--danger-bg); }
  .menu-item .menu-shortcut { margin-inline-start: auto; padding-inline-start: var(--sp-4); color: var(--muted-foreground); font-size: var(--text-micro); }
  /* **可多选的菜单项**(`<label class="menu-item menu-check"><input type="checkbox" class="checkbox">…</label>`,
     2026-09-27):同一个下拉里一次勾几项再点底部按钮。勾选不关菜单(它不是 `button`,dev-menu 不把它当选中)。
     `.menu-foot` 是菜单底部的操作条(「已选 N 项 · 开始」),与 `.pop-foot` 同一视觉。 */
  .menu-item.menu-check { cursor: pointer; }
  .menu-item.menu-check > .checkbox { flex: none; margin: 0; }
  .menu-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-block-start: var(--sp-1); padding: var(--sp-2) var(--sp-2) var(--sp-1); border-block-start: 1px solid var(--border); font-size: var(--text-micro); color: var(--muted-foreground); }
  .menu-sep { block-size: 1px; background: var(--border); margin: var(--sp-1) calc(-1 * var(--sp-1)); }
  .menu-label { padding: var(--sp-1) var(--sp-2); font-size: var(--text-micro); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  /* Anchored popover variant: pair with .menu inside a position:relative anchor
     (e.g. a composer's @-mention layer). Fixed compact width, capped height,
     floats above the anchor; interior scrolling belongs to a .scroll-area child. */
  .menu-float { position: absolute; inset-block-end: 100%; inset-inline-start: 0; z-index: var(--z-dropdown); inline-size: min(26.25rem, 100%); max-block-size: 25rem; margin-block-end: var(--sp-2); border-radius: var(--radius-lg); overflow: hidden; }

  /* ---- pop: the ONE floating-panel skeleton (popover / hover-card / menu-float 正文) ----
     BaseDialog 给弹窗定了 title/subtitle/footer 三段式,浮层此前一段都没有 —— 盘点
     (2026-08-15)发现 5 个 popover/hover-card 里 4 个连标题都没有,分组标签、底注、
     宽度全是各写各的 inline。骨架 = .pop-head(标题 + 右侧 meta)→ .pop-body
     (.pop-label 分组)→ .pop-foot(底注/键位行);宽度档 .pop-sm/md/lg 消灭 inline
     宽度。head/foot 可省略,但省略是例外不是默认。皮(底色/圆角/阴影)由宿主容器
     (dev-popover 的 content、.menu-float)提供,这里只管内部结构。 */
  .pop-head { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border-block-end: 1px solid var(--border); }
  .pop-title { font-size: var(--text-caption); font-weight: var(--weight-semibold); color: var(--foreground); }
  .pop-meta { margin-inline-start: auto; font-size: var(--text-micro); color: var(--muted-foreground); }
  .pop-body { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
  /* 分组小标签:与 .menu-label 同义 —— 菜单里用 menu-label,面板里用 pop-label。
     负边距(标签贴近自己那组)只在 .pop-body 的 sp-3 间距下成立;放进 gap-1 的
     紧凑栏会把间距抵成 0(评审 #3),所以只对 .pop-body 直系子级生效。 */
  /* `.pop-label` 与 `.form-legend` 是**同一件事的两个名字**(定义逐字相同,2026-08-15
     盘点):都是「一组东西的小标题」。合并成一条选择器 —— 两个名字各写一遍配方,
     下一次谁改了其中一个,浮层里的组标题和表单里的组标题就会长得不一样。
     `.form-legend` 是那个该留的名字(表单/参数分组是它的主场);`.pop-label` 作为
     既有消费方的别名保留,不再单独维护配方。 */
  .pop-body > .pop-label { margin-block-end: calc(-1 * var(--sp-1)); }
  /* 底注里既有一行小字、又可能有一颗按钮:窄屏(或较长语言)下不换行就是挤成
     一团或直接溢出;字那一侧要能收缩,否则它把按钮顶出去。 */
  .pop-foot { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-block-start: 1px solid var(--border); background: var(--surface-sunken); font-size: var(--text-micro); color: var(--muted-foreground); }
  .pop-foot > :first-child { min-inline-size: 0; }
  .pop-sm { inline-size: min(94vw, 20rem); }
  .pop-md { inline-size: min(94vw, 26rem); }
  .pop-lg { inline-size: min(94vw, 34rem); }
  /* 两栏 + 多行参数的浮层(视频规格:左家族列 + 右五行规格)。 */
  .pop-xl { inline-size: min(94vw, 44rem); }
  /* **尺寸档一次定宽高。**
     这里曾经只定宽:高度是另一个旋钮(--c-popover-max-h),读取点还在 <dev-popover>
     宿主的 shadow 面板上 —— 两件事挂在两个节点,消费方改一个看不见另一个的后果,
     只能真机试数。而它的缺省 min(60vh,25rem)=400px 是**按菜单**调的,任何「内容型」
     浮层套上去都会被压出滚动条(2026-08-15:规格弹层面板 400px、内容 516px)。
     现在宽度档自带匹配的高度上限:消费方**只写一个类**,宽高一起对。
     `:has(> …)` 选的是 light-DOM 里的插槽内容 —— 它就是 dev-popover 的直接子节点。 */
  dev-popover:has(> .pop-sm) { --c-popover-max-h: min(70vh, 26rem); }
  dev-popover:has(> .pop-md) { --c-popover-max-h: min(76vh, 32rem); }
  dev-popover:has(> .pop-lg) { --c-popover-max-h: min(80vh, 36rem); }
  dev-popover:has(> .pop-xl) { --c-popover-max-h: min(82vh, 40rem); }
  /* .menu 容器自带 4px 内衬,head/foot 的分隔线要和 .menu-sep 一样出血到边 ——
     否则两端缩进 4px 像断线。 */
  .menu > .pop-head, .menu > .pop-foot { margin-inline: calc(-1 * var(--sp-1)); }
  .menu > .pop-head { margin-block-start: calc(-1 * var(--sp-1)); }
  .menu > .pop-foot { margin-block-end: calc(-1 * var(--sp-1)); }
  /* 菜单浮层里的 .list-row 是纯数据行(侧栏用量那类):hover 高亮是可点语义,
     只读行不给(评审 #1)。 */
  .menu .list-row:hover { background: transparent; }
  /* pop 密度(设计稿帧 5,2026-08-14 验收退回后修订):浮层是紧凑上下文 ——
     控件降 1.75rem 高 caption 字号,字段标签降 micro 灰。浮层内文字只许两档
     (caption=数据 / micro=元信息),层级靠颜色(墨/灰)不靠字号;对话框
     (BaseDialog)保持标准密度,这组规则只进浮层的两个容器。 */
  .pop-body .input, .pop-body select.input, .picker-detail .input, .picker-detail select.input { min-block-size: var(--control-h-dense); font-size: var(--text-caption); }
  /* 分段控件在密集上下文里也降到同一档:容器 = --control-h-dense,所以成员要减去
     自己的内衬(2px 内衬 × 2 + 1px 边框 × 2 = 6px)。不降的话它 30px、旁边的
     输入 28px,同一行里两个控件差 2px —— 这就是「感觉每个都不一样」的那 2px。 */
  .pop-body .segmented > button, .picker-detail .segmented > button { min-block-size: calc(var(--control-h-dense) - 6px); }
  .pop-body .form-field-row, .picker-detail .form-field-row { --field-label-w: 3.5rem; }
  .pop-body .form-field-row > .form-label, .picker-detail .form-field-row > .form-label { font-size: var(--text-micro); font-weight: var(--weight-medium); color: var(--muted-foreground); min-block-size: var(--control-h-dense); }
  /* 浮层内表单 = 严格两列网格,不是 flex-wrap 自由漂:第 3 个字段落到第二行
     第一列,空位空着 —— 对齐比填满重要(设计稿帧 5)。 */
  .pop-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-3); }
  .pop-grid > .form-field-row { min-inline-size: 0; }

  /* ---- picker: the platform's ONE "choose a model / preset / variant" panel ----
     Left = the index (families / groups), right = the chosen entry's variants and
     the facts about it. Lives inside a .modal-card or a popover; the channel
     supplies only rows and labels.
     Why a template and not per-channel CSS: AI对话 had a two-pane popover for
     image models and a one-column 560px card list for video models — two looks
     for one job. The index column is fixed-width and the detail pane carries
     min-inline-size:0 so a long model id wraps INSIDE the column instead of
     stretching the whole panel. */
  .picker { display: flex; gap: var(--sp-3); align-items: stretch; }
  .picker-index { flex: none; inline-size: var(--picker-index-w, 10.5rem); display: flex; flex-direction: column; gap: 2px; padding-inline-end: var(--sp-3); border-inline-end: 1px solid var(--border); }
  .picker-detail { flex: 1; min-inline-size: 0; display: flex; flex-direction: column; gap: var(--sp-3); }
  /* An index row. Block (not flex) so a name + its sub-line stack; text-align
     start because a <button>'s UA default is centered. */
  .picker-option { appearance: none; display: block; inline-size: 100%; padding: var(--sp-2); border: none; background: none; font: inherit; color: inherit; border-radius: var(--radius-sm); text-align: start; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-smooth); }
  .picker-option:hover { background: var(--state-hover); }
  /* Both spellings: aria-pressed is the valid one on a <button> (aria-selected
     needs an option/row role), aria-selected fits a listbox/row markup. */
  .picker-option[aria-pressed="true"], .picker-option[aria-selected="true"], .picker-option.is-selected { background: var(--state-selected); }
  .picker-option:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  /* Phone: two panes do not fit. Measured on a 390px viewport — the detail pane
     lands at 153px, the tier .segmented overflows (the last tier is clipped) and
     the panel grows a horizontal scrollbar. Stack instead: index on top with a
     hairline under it, detail below at full width. lib owns this, so no channel
     re-invents a breakpoint for its own picker. */
  @media (max-width: 30rem) {
    .picker { flex-direction: column; }
    .picker-index { inline-size: auto; padding-inline-end: 0; padding-block-end: var(--sp-2); border-inline-end: none; border-block-end: 1px solid var(--border); }
  }

  /* ---- video picker: `<dev-video-picker>` 的版面 ----
     组件渲染在 light DOM(它要和页面是同一套 .chip / .tag / .segmented / .card /
     .payload),所以它的排版类住在这里,而不是在组件里再抄一份配方。
     左边参数、右边报价条;报价条 sticky —— 参数改一格就要看一眼价,滚下去看不见
     价的话人得来回滚。
     断点用 @container 而不是 @media:同一个组件既会整页铺开(AI网关操作手册),
     也会塞进一条窄参数栏(xshort 生成面板),看**视口**宽度会得出相反的结论。 */
  dev-video-picker { display: block; container-type: inline-size; }
  /* 版面复用 `.picker` 两栏(左选家族、右调规格,与图片规格弹层同一副骨架,
     operator 2026-08-15)。家族 id 比图片模型名长,索引列宽一档。 */
  .vpick.picker { --picker-index-w: 14rem; }
  /* **两栏都不自己滚**:一个弹层只该有一个滚动容器 —— 浮层面板本身。
     各自滚的时候,滚轮先被内层吃掉、内层没得滚才冒泡,于是同一个手势在
     弹层不同位置行为不一样;而且左栏还会独立于右栏错位滚动。
     真装不下时由 dev-popover 面板兜(.pop-tall 已把上限抬到 40rem)。 */
  /* 家族条 = 三行(id + 档位 + 参考预算)。给它自己的紧凑节奏:9 个家族按默认
     行距要 607px,把右栏(约 460px)拖出一块 150px 的空洞 —— 而这一栏只是「先选谁」,
     不该决定整个面板多高(2026-08-28 量出来的)。 */
  .vpick-families .picker-option { display: block; padding-block: var(--sp-1); }
  .vpick-families .picker-option > .text-micro { line-height: var(--leading-snug); }
  .vpick-detail { gap: 0; }
  /* 报价条是**结论**,钉在右栏底边:左栏(家族清单)比右栏高时,原先右栏最后一行
     下面会空掉一大块(实测 174px),而报价条却紧贴着上一行参数 —— 两处都不对。
     `margin-block-start:auto` 把它推到底,两栏于是同时收口。
     选择器也修了:报价条外面包着 `aside.vpick-quote`,旧的 `> .quote-bar` 根本
     选不中,所以那 12px 上边距从来没生效过(2026-08-28 量出来的)。 */
  .vpick-detail > .vpick-quote { margin-block-start: auto; padding-block-start: var(--sp-3); }
  .vpick-detail > .vpick-notes { margin-block-start: var(--sp-2); }
  .vpick-notes-body { padding: var(--sp-3); line-height: var(--leading-normal); }
  /* **一个选项不是选择**:那一格是一句陈述,不是控件。但它仍要与同一列里的胶囊
     等高、文字压同一条左边缘 —— 否则一排参数里就它浮在行的上沿,而且比别人靠左
     (2026-08-28 量到:17px 高、顶对齐,胶囊是 27px;`.form-field-row` 是
     `align-items:start` 的网格,矮的那个不会自己居中)。 */
  .field-value { display: inline-flex; align-items: center; min-block-size: var(--control-h-dense); padding-inline: calc(var(--sp-3) + 2px); font-size: var(--text-caption); color: var(--foreground); }
  /* 紧凑上下文:标签列窄一档、标签降到 micro 灰(同 .pop-body 的密度口径)。 */
  .vpick .form-field-row { --field-label-w: 4.5rem; }
  .vpick .form-label { font-size: var(--text-micro); font-weight: var(--weight-medium); color: var(--muted-foreground); }
  /* 时长这一行的控件是滑块 + 读数,天然比一排胶囊矮 4px —— 一列参数里就它塌下去
     半格(2026-08-28 量到 23px vs 27px)。按同一格高度落位,竖排节奏才是一条线。 */
  .vpick-duration { min-block-size: var(--control-h-dense); }
  .vpick-duration .slider { flex: 1 1 8rem; max-inline-size: 11rem; }
  .vpick-total { font-size: var(--text-heading); font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
  /* 报价条 —— 「多少钱 = 单价 × 用量」是**结论**,不是又一行参数,所以它有自己的
     底色与边框,总价用标题号、算式退成小字,右侧搭这一档的能力标签。图片规格弹层
     同样要这一块,所以它是通用件而不是 vpick 私有(2026-08-15 设计稿「视频规格弹窗 v2」)。 */
  .quote-bar { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-sunken); }
  .quote-total { font-size: var(--text-title); font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; color: var(--foreground); }
  .quote-calc { font-size: var(--text-micro); color: var(--muted-foreground); }
  .quote-bar > .quote-spacer { flex: 1 1 auto; }
  .vpick-snippet { --payload-max-h: 9rem; }
  /* 这里曾经有三条「选择器自己的 chip 语义」(选中染色 / 没货划线 / 禁用 hover)。
     它们存在的唯一原因是:互斥单选本该用 .segmented,而当时的 .segmented 说不出
     「这一档没货」也不会换行 —— 于是绕去用 chip,再给 chip 补一套只在这里成立的
     语义。缺口补进 .segmented 之后,这三条连同那条绕路一起删掉:互斥单选全站
     只有一个长相(2026-08-15)。 */
  /* 窄容器:两栏堆成一栏(家族列在上),索引列的右边框改成下边框。
     断点 = 两栏放得下的最小宽度(索引 13.5rem + gap + 右栏 ~15rem);写高了会让
     34rem 的规格弹层永远堆成一栏(2026-08-15 实测 544px 容器命中 40rem 断点)。 */
  @container (max-width: 29rem) {
    .vpick.picker { flex-direction: column; }
    .vpick-families {
      inline-size: 100%; padding-inline-end: 0; border-inline-end: none;
      padding-block-end: var(--sp-2); border-block-end: 1px solid var(--border);
    }
  }

  /* ---- image picker: `<dev-image-picker>` 的版面 ----
     `.vpick` 的图片孪生:同一副 `.picker` 两栏骨架、同一条 `.quote-bar`,只是右栏
     换成 分辨率 → 画幅 → (宿主自己的参数) 三组。组件渲染在 light DOM(它要和页面
     是同一套 .segmented / .btn / .chip),所以排版类住在这里而不是组件里再抄一份。

     断点同样用 @container 而不是 @media:同一个组件既会铺在创作页里,也会塞进一条
     34rem 的规格弹层 —— 看视口宽度会得出相反的结论。 */
  dev-image-picker { display: block; container-type: inline-size; }
  /* 图片家族名比视频家族名短一档(`gemini-3.1-flash-image` vs `seedance-2.0-fast-open`),
     索引列窄一档;与 AI对话 既有的图片规格弹层同宽,换成组件时版面不跳。 */
  .ipick.picker { --picker-index-w: 12.5rem; }
  /* 家族行两行堆叠:模型 id 单行不折(超长省略号 + hover 出全名,与视频面板同规矩),
     档位/画幅数在下。 */
  .ipick-families .picker-option { display: block; }
  .ipick-families .picker-option > .row-title { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
  .ipick-families .picker-option > .ipick-value { display: block; margin-block-start: 2px; }
  .ipick-detail { gap: 0; }
  .ipick-detail > .quote-bar { margin-block-start: var(--sp-3); }
  /* 一组参数。组间用一条线分隔(不是 gap):三组参数中间留白相同的话,
     「分辨率」与「画幅」读起来像同一组的两行。 */
  .ipick-group { display: flex; flex-direction: column; gap: var(--sp-1); padding-block: var(--sp-2); }
  .ipick-group + .ipick-group { border-block-start: 1px solid var(--border); }
  /* 前一组被藏起来时(单档家族没有分辨率那一组)分隔线要跟着走 —— `.hidden` 是
     display:none,相邻兄弟选择器照样命中,不写这条就会在最上面留一条没有来由的线。 */
  .ipick-group.hidden + .ipick-group { border-block-start: none; }
  /* 标题行:左标签、右当前值(像素 / 量级)。值右对齐是因为它随选择变,
     左对齐时它会跟着标签长度左右跳。 */
  .ipick-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); }
  .ipick-label { font-size: var(--text-micro); font-weight: var(--weight-medium); color: var(--muted-foreground); }
  .ipick-value { font-size: var(--text-micro); color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
  /* 画幅是**形状格**不是文字列表:十个比例名读起来都一样,形状一眼能扫。
     五列 = 10 个画幅正好两行;窄容器降到四列。 */
  .ipick-ratios { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--sp-1); }
  .ipick-ratio { display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); padding-block: var(--sp-1); }
  /* 窄容器:两栏堆成一栏(家族列在上),索引列的右边框改成下边框。
     断点 = 两栏放得下的最小宽度(索引 12.5rem + gap + 右栏 ~13rem)。 */
  @container (max-width: 26rem) {
    .ipick.picker { flex-direction: column; }
    .ipick-families {
      inline-size: 100%; padding-inline-end: 0; border-inline-end: none;
      padding-block-end: var(--sp-2); border-block-end: 1px solid var(--border);
    }
    .ipick-ratios { grid-template-columns: repeat(4, 1fr); }
  }

  /* ---- definition: 标签 / 值 的事实清单 ----
     用途:一组「名字 → 值」的陈述,读的人按标签找值。操作手册顶部的接口地址 /
     认证 / 协议 / 耗时,渠道详情的规格,作品的元信息,都是这一个形状。

     为什么进 lib 而不是各频道自己写:同一件事已经出现过三种做法 —— 横排等宽格
     (把「接口地址」压成五分之一屏宽,而它恰恰是来这一页要看的第一件事)、
     两列 table(语义不对,读屏软件当成数据表)、以及每处各写一份 grid-template。
     标签列定宽,值列自适应;窄屏堆成两行,标签在上。

     用法:
       <dl class="definition">
         <dt>接口地址</dt><dd><code class="code">https://…</code></dd>
       </dl>
     `dt`/`dd` 是语义正确的那对标签;也接受 span 子元素(某些框架不便生成 dl)。 */
  .definition { display: grid; grid-template-columns: var(--definition-label-w, 7rem) 1fr; gap: var(--sp-2) var(--sp-4); align-items: baseline; margin: 0; }
  .definition > dt, .definition > .definition-label { font-size: var(--text-micro); color: var(--muted-foreground); margin: 0; }
  .definition > dd, .definition > .definition-value { margin: 0; min-inline-size: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
  /* 窄屏:两列挤不下一个地址 + 一个复制键。堆成两行,标签在上。 */
  @media (max-width: 40rem) {
    .definition { grid-template-columns: 1fr; gap: var(--sp-1); }
    .definition > dt, .definition > .definition-label { margin-block-start: var(--sp-2); }
  }

  /* ---- detail: 「看一件东西」的两栏骨架 ----
     左主体(成品 / 正文 / 素材)+ 右属性轨(参数与账)。与 `.picker`同形不同义:
     picker 是「选一个」,detail 是「看一件」。右轨里的属性行**用现成的
     `.definition`**（标签→值的事实清单），不另造一套键值行。

     为什么进 lib(2026-08-15 盘点):任务详情 / 资产详情 / 渠道详情 / 评测报告
     都是这一副形状,而 lib 里只有原子 —— 产品拿 `.stack` + `.card` 各拼一份,
     每一份都不一样而 lint 全绿(每个类都是 lib 的类)。 */
  .detail { display: flex; gap: var(--sp-4); align-items: flex-start; }
  .detail-main { flex: 1; min-inline-size: 0; display: flex; flex-direction: column; gap: var(--sp-3); }
  .detail-rail { flex: none; inline-size: var(--detail-rail-w, 15rem); padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
  /* 左栏里一小段的段落标题(提示词 / 参考素材 / 结果)。 */
  .detail-label { margin: 0; font-size: var(--text-micro); font-weight: var(--weight-semibold); color: var(--muted-foreground); }
  /* 窄容器:属性轨掉到主体下面,而不是把两栏都振成一条。 */
  @container (max-width: 34rem) {
    .detail { flex-direction: column; }
    .detail-rail { inline-size: 100%; }
  }

  /* 参考素材条 —— 「这件东西是拿哪几件做的」的紧凑卡行。
     它就是 `.media-grid` 收窄一档 + 方画幅:不另造卡片,只给一个名字,
     免得每个页面自己猜 `--media-card-min` 该填多少。 */
  .ref-strip { --media-card-min: 6.5rem; }
  .ref-strip .media-preview { --media-aspect: 1; }

  /* 上传区的紧凑变体:次要动作(弹窗里多数时候人是来挑一张已有的)。默认那块
     2rem 上下内边距的方块在弹窗里是最大的一件东西,把真正要看的格子挤到折线以下。
     `::part(zone)` 是这个组件对外开的那个接缝,不去动它的 shadow 内部。 */
  .dropzone-compact::part(zone) { flex-direction: row; flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); text-align: start; }

  /* ---- batch 3: scroll-area (drawer/lightbox are web components).
     NOTE: .dropzone intentionally NOT defined — xsvg already uses that class
     name; it returns once that channel adopts lib's version. ---- */
  .scroll-area { overflow: auto; max-block-size: var(--scroll-max, 16rem); scrollbar-width: thin; scrollbar-color: var(--border) transparent; overscroll-behavior: contain; }
  .scroll-area::-webkit-scrollbar { width: 8px; height: 8px; }
  .scroll-area::-webkit-scrollbar-track { background: transparent; }
  .scroll-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-full); border: 2px solid transparent; background-clip: content-box; }
  .scroll-area::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; }

  /* ---- batch 4: slider / stepper / chip (combobox + collapsible are WCs) ---- */
  .slider { -webkit-appearance: none; appearance: none; inline-size: 100%; max-inline-size: 18rem; block-size: 1.25rem; background: transparent; cursor: pointer; }
  .slider::-webkit-slider-runnable-track { block-size: 4px; border-radius: var(--radius-full); background: var(--muted); }
  .slider::-moz-range-track { block-size: 4px; border-radius: var(--radius-full); background: var(--muted); }
  .slider::-moz-range-progress { block-size: 4px; border-radius: var(--radius-full); background: var(--primary); }
  .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; inline-size: 14px; block-size: 14px; margin-block-start: -5px; border-radius: 50%; background: var(--background); border: 2px solid var(--primary); box-shadow: var(--shadow-card); transition: box-shadow 120ms var(--ease-smooth); }
  .slider::-moz-range-thumb { inline-size: 14px; block-size: 14px; border: 2px solid var(--primary); border-radius: 50%; background: var(--background); box-shadow: var(--shadow-card); }
  .slider:focus-visible { outline: none; }
  .slider:focus-visible::-webkit-slider-thumb { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .slider:focus-visible::-moz-range-thumb { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .slider:disabled { opacity: var(--state-disabled-opacity); cursor: not-allowed; }

  .stepper { display: inline-flex; align-items: stretch; border: 1px solid var(--c-input-border); border-radius: var(--radius); overflow: hidden; background: var(--c-input-bg); }
  .stepper > button { appearance: none; border: none; background: var(--secondary); color: var(--foreground); inline-size: var(--control-h); cursor: pointer; font: inherit; font-size: var(--text-body); display: inline-flex; align-items: center; justify-content: center; }
  .stepper > button:hover { background: var(--state-hover); }
  .stepper > button:disabled { opacity: var(--state-disabled-opacity); cursor: not-allowed; }
  .stepper > input { inline-size: 3rem; border: none; text-align: center; background: transparent; color: inherit; font: inherit; font-size: var(--text-body); -moz-appearance: textfield; appearance: textfield; outline: none; }
  .stepper > input::-webkit-outer-spin-button, .stepper > input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
  .stepper:focus-within { border-color: var(--ring); outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }

  /* **紧凑变体**(operator 2026-08-25):基座 `.stepper` 是两颗 `--control-h` 见方的
     方块夹着输入框 —— 一个 4 位小数的判据值被撑成三个控件宽,一张表单里排四行就是
     一片按钮墙。这一版把上下箭头**竖着叠成一列**放在数字左侧:总宽 = 输入框 + 约
     1rem,「只多了一点点箭头的宽度」。
     箭头在**左**是刻意的:数字左对齐时,视线沿左边缘竖着下来,四行的箭头列与数字列
     各自成列;箭头放右边会随每个值的位数左右跳(0.03 与 1.05 不同宽),那一列就散了。
     箭头是**装饰**,可点区域仍是整颗按钮 —— 所以 `aria-label` 必写,▴▾ 本身读屏读不出
     「增加/减少」。 */
  .stepper-tight > .stepper-spin { display: flex; flex-direction: column; inline-size: 1.125rem; flex: 0 0 auto; border-inline-end: 1px solid var(--border); }
  /* 箭头是**画**出来的三角形,不是字符。写 `font-size: .5rem` 会撞 lib 自己那条
     「11px 是字号下限」的守卫,而那条守卫是对的 —— 它管的是**能读的字**;这里要的
     是一枚 5px 的图标,本来就不该走字号。三角形还比字形稳:▴▾ 的字面高度随字体变。 */
  .stepper-tight > .stepper-spin > button { appearance: none; border: none; background: var(--secondary); cursor: pointer; block-size: 50%; display: inline-flex; align-items: center; justify-content: center; padding: 0; transition: background-color 120ms var(--ease-smooth); }
  .stepper-tight > .stepper-spin > button::before { content: ""; inline-size: 0; block-size: 0; border-inline: 3px solid transparent; }
  .stepper-tight > .stepper-spin > button[data-step="1"]::before  { border-block-end: 4px solid var(--muted-foreground); }
  .stepper-tight > .stepper-spin > button[data-step="-1"]::before { border-block-start: 4px solid var(--muted-foreground); }
  .stepper-tight > .stepper-spin > button:hover::before { border-block-end-color: var(--foreground); border-block-start-color: var(--foreground); }
  .stepper-tight > .stepper-spin > button:first-child { border-block-end: 1px solid var(--border); }
  .stepper-tight > .stepper-spin > button:hover { background: var(--state-hover); }
  .stepper-tight > .stepper-spin > button:disabled { opacity: var(--state-disabled-opacity); cursor: not-allowed; }
  /* 数字**左对齐**:居中会让 `2` 与 `0.03` 的起笔各在一处,一列四行读起来是锯齿。 */
  .stepper-tight > input { inline-size: 3.25rem; text-align: start; padding-inline: var(--sp-2); font-size: var(--text-caption); }
  /* `inline-size: fit-content` 是**必须的**,不是保险:`.form-field-row` 是网格,
     网格项默认 `justify-self: stretch`,而 stretch 只对 `width:auto` 生效 —— 不写这一句,
     `inline-flex` 的外框会被拉满整列,输入框虽然仍是 3.25rem,人看到的却是一个通栏的
     空框(2026-08-25 无头截图当场量到)。窄是这个变体存在的全部理由,所以它归原子自己管,
     不能指望每个调用方在外面再包一层。 */
  .stepper-tight { inline-size: fit-content; min-block-size: var(--control-h-dense, var(--control-h)); }
  /* **粗指针下把紧凑度让出去**(2026-08-25 code review 自查):桌面上这两枚箭头各约
     13px 高,而本文件下面那条「粗指针 44px 下限」的名册收的是 `.stepper > button`,
     收不到这里的 `.stepper-spin > button` —— 也就是说触屏上它基本点不中,而且不报错。
     两枚各给 44px 会让控件变成 88px 高(比一行表单还高),所以这里给的是折中:
     箭头列加宽到 44px、控件整体抬到 3.5rem,每枚约 44×28。宽度是竖排箭头对的主要
     命中维度。**这是一个刻意的取舍,不是遗漏** —— 真要满 44×44 得换成横排,那就是
     基座 `.stepper` 本身。 */
  @media (pointer: coarse) {
    .stepper-tight { min-block-size: 3.5rem; }
    .stepper-tight > .stepper-spin { inline-size: 2.75rem; }
  }

  /* 一行**表单/卡片的动作**:一律靠右(operator 2026-08-25 —— 保存跑到左边是错的)。
     `.cluster` 只管排,不表达「这是这张卡的动作区」;各页各写一遍 `justify-end` 的
     结果就是有的页写了、有的页没写,而没写的那一页看起来完全正常。
     `.form-actions-divided` 给「表单直接接动作行」的场景加一条上边框 + 上内距;
     卡内动作(卡尾)不用它 —— 卡本身已经有边框。 */
  .form-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--sp-3); }
  .form-actions-divided { padding-block-start: var(--sp-4); border-block-start: 1px solid var(--border); }
  /* 左侧放说明/次要信息,动作仍在右:`.form-actions > .form-actions-lead { margin-inline-end: auto }` */
  .form-actions > .form-actions-lead { margin-inline-end: auto; }

  .chip { display: inline-flex; align-items: center; gap: var(--sp-1); padding-block: 0.1875rem; padding-inline: var(--sp-2); font-size: var(--text-micro); border: 1px solid var(--border); border-radius: var(--radius); background: var(--secondary); color: var(--foreground); white-space: nowrap; }
  .chip-clickable { cursor: pointer; transition: background-color 120ms var(--ease-smooth), border-color 120ms var(--ease-smooth); }
  .chip-clickable:hover { border-color: var(--border-strong); background: var(--state-hover); }
  /* v3: selected chip = accent TONAL (amber wash + amber ink), not a solid
     fill — solid is reserved for the one primary CTA. `.is-selected` is the
     one global switch; `aria-pressed` / `disabled` are NOT repainted globally —
     see the `.vpick` block above for why they're scoped to the picker. */
  .chip.is-selected { background: var(--btn-tonal-selected-bg); color: var(--btn-tonal-fg); border-color: var(--btn-tonal-border); }
  /* Tinted semantic chips — coloured bg + matching border, but KEEP .chip's
     readable --foreground text (unlike the soft .tag-*, whose saturated text can
     read faintly). For many small coloured chips that must each stay legible. */
  .chip-info    { background: var(--info-bg);    border-color: var(--info-border);    }
  .chip-warning { background: var(--warning-bg); border-color: var(--warning-border); }
  .chip-success { background: var(--success-bg); border-color: var(--success-border); }
  .chip-danger  { background: var(--danger-bg);  border-color: var(--danger-border);  }
  .chip-brand   { background: var(--brand-soft); border-color: var(--brand-soft-hover); }
  /* **结果计数**(`.tally` > `.tally-item`,2026-09-27):一组「结果 ×次数」的事实,例如
     「200 · get_weather ×8」「400 ×2」。只报事实、不下结论 —— 所以默认**不上色**;次数用 `<b>` 等宽数字,
     结果文字用普通墨色。一组里多条结果时各占一颗,换行对齐,不再拼成一长串用「·」隔开的句子。
     `.tally-name` 是这一组属于哪个项目(表格一格里放好几组时用),`.tally-muted` 用于「没发 / 未测」。 */
  .tally { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); min-inline-size: 0; }
  .tally-name { font-size: var(--text-micro); color: var(--muted-foreground); margin-inline-end: var(--sp-1); }
  .tally-item { display: inline-flex; align-items: baseline; gap: var(--sp-1); padding-block: 0.0625rem; padding-inline: var(--sp-2); font-size: var(--text-micro); line-height: var(--leading-tight); color: var(--foreground); background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius-sm); white-space: nowrap; }
  .tally-item > b { font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
  .tally-item.tally-muted { color: var(--muted-foreground); background: transparent; border-style: dashed; }
  /* 多行 tally(一格里好几个项目):每个项目一行,名字列对齐。 */
  .tally-stack { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-1) var(--sp-2); align-items: center; }
  .tally-stack > .tally-name { margin: 0; }
  /* 一格里好几个项目时,项目之间一道发丝线:名字列与结果列成对对齐,纵向扫得出「哪一行是哪一项」。 */
  .tally-stack > :nth-child(n+3) { padding-block-start: var(--sp-1); border-block-start: 1px solid color-mix(in oklab, var(--border) 60%, transparent); }
  /* 引用身份色(设计稿帧 5):正文折叠 token(.cmx-ref-chip)、composer chip 行、
     参考图明细 @句柄一脉同色 —— brand 底 + brand 墨的药丸。文字用 --brand-text
     (72% brand 混 foreground,对比度契约内),不用裸 --brand。此前 .chip-ref
     是幽灵类(消费方挂着、CSS 零定义 → 渲染成灰),颜色链在 chip 行断掉。 */
  .chip-ref { background: var(--brand-soft); color: var(--brand-text); border-color: transparent; font-weight: var(--weight-medium); }
  .chip-ref.chip-clickable:hover { background: var(--brand-soft-hover); border-color: transparent; }
  /* 引用身份色(设计稿「引用身份色」):三类媒体引用三色 —— 图片保持 brand 青,
     视频 info 蓝、音频 success 绿(编辑器正文折叠 token .cmx-ref-chip.is-* 同套色)。
     不用 warning 琥珀:.chip.is-selected 的选中态就是琥珀 wash,同色会混同。 */
  .chip-ref.is-video { background: var(--info-bg); color: var(--info-text); }
  .chip-ref.is-video.chip-clickable:hover { background: color-mix(in oklab, var(--info) 18%, var(--surface)); border-color: transparent; }
  .chip-ref.is-audio { background: var(--success-bg); color: var(--success-text); }
  .chip-ref.is-audio.chip-clickable:hover { background: color-mix(in oklab, var(--success) 18%, var(--surface)); border-color: transparent; }
  /* .is-selected 是全局唯一的选中开关(见上),而变体与它同特异性、排在其后 ——
     不显式让位,选中态会被身份色整个吃掉。灰态(.dim)不受此影响:它在
     lib.utilities 层,跨层恒胜,无须在这里重declare。 */
  .chip-ref.is-selected { background: var(--btn-tonal-selected-bg); color: var(--btn-tonal-fg); border-color: var(--btn-tonal-border); }
  .chip-remove { display: inline-flex; align-items: center; justify-content: center; inline-size: 1rem; block-size: 1rem; margin-inline-end: -3px; padding: 0; border: none; border-radius: 50%; background: none; color: inherit; cursor: pointer; font-size: 0.9em; line-height: 1; opacity: 0.65; }
  .chip-remove:hover { opacity: 1; background: color-mix(in oklab, currentColor 18%, transparent); }

  /* ---- batch 5: sidebar / carousel / command-item (tabs + command are WCs) ---- */
  /* Items in a section are tight (2px); each .sidebar-label opens a new section —
     the first hugs the top, the rest get a hairline rule + breathing room so the
     groups (e.g. Console vs Admin) are unmistakably separate. Items carry a
     leading icon slot (any inline svg) that follows the row colour. */
  /* min-block-size:100% so the rail (and its border-inline-end divider) fills a
     stretched parent all the way down instead of stopping at content height —
     null-effect when the parent has no definite height, so it never over-stretches
     a non-app-shell usage. */
  .sidebar { display: flex; flex-direction: column; min-block-size: 100%; gap: 2px; inline-size: var(--sidebar-w, 15rem); padding: var(--sp-3) var(--sp-2); background: var(--sidebar, var(--surface-sunken)); color: var(--sidebar-foreground, var(--foreground)); border-inline-end: 1px solid var(--border); }
  .sidebar-label { padding: var(--sp-1) var(--sp-2); font-size: var(--text-micro); font-weight: var(--weight-bold); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  .sidebar-label:first-child { margin-block-start: 0; }
  .sidebar-label:not(:first-child) { margin-block-start: var(--sp-3); padding-block-start: var(--sp-4); border-block-start: 1px solid var(--border); }
  /* Item text uses --text-menu (13px / medium) — the platform-wide left-nav
     standard shared with the AI对话 and AI短剧 sidebars. */
  .sidebar-item { position: relative; display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: none; background: none; border-radius: var(--radius); font: inherit; font-size: var(--text-menu, 0.8125rem); font-weight: var(--weight-medium); color: inherit; text-decoration: none; text-align: start; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-smooth), color var(--duration-fast) var(--ease-smooth); }
  /* 复合行(项目行、生成任务行)里那块「主点击区」要撑满整行高度。默认
     align-items:center 会把它缩到文字高度(实测 42px 的行里只有中间 20px 可点),
     上下 padding 成了死区 —— 看着是一整行,点上去一半没反应
     (operator 2026-08-09「其他 UI 细节」)。 */
  .sidebar-item > .btn-bare { align-self: stretch; align-items: center; }
  .sidebar-item > svg { inline-size: 1rem; block-size: 1rem; flex-shrink: 0; opacity: 0.8; transition: opacity var(--duration-fast) var(--ease-smooth), color var(--duration-fast) var(--ease-smooth); }
  .sidebar-item:hover { background: var(--state-hover); color: var(--foreground); }
  .sidebar-item:hover > svg { opacity: 0.95; }
  /* Row-hover-revealed inline action (delete/rename on list rows): invisible
     until the row is hovered or the control is keyboard-focused; always
     faintly visible on touch devices where hover cannot reveal it. */
  /* Bare button — a clickable region with ZERO native chrome, for rows/cards
     whose visual identity comes from the parent (.sidebar-item project rows). */
  /* text-decoration 也要清:.btn-bare 常被套在 <a> 上当「一行里的主点击区」
     (侧栏的项目行、生成任务行都是),不清的话那一行就带着浏览器默认下划线,
     混在一排菜单项里像个外来的链接 —— 线上实测「生成任务」就是这样
     (operator 2026-08-09「其他 UI 细节」)。 */
  .btn-bare { appearance: none; border: none; background: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: inherit; text-decoration: none; cursor: pointer; }
  /* **可点的结果计数**:`<button class="tally-item">`(点开这一项的全部结果)。写在 `.btn-bare` 之后、选择器更具体 ——
     否则按钮一加 `btn-bare` 就被剥掉底色、边框与内边距,药丸在页面上变成一行裸字(2026-09-27 线上量到的)。 */
  button.tally-item, a.tally-item { appearance: none; cursor: pointer; font: inherit; font-size: var(--text-micro); padding-block: 0.0625rem; padding-inline: var(--sp-2);
    background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--foreground); text-decoration: none; }
  button.tally-item:hover, a.tally-item:hover { border-color: var(--border-strong); background: var(--state-hover); }
  button.tally-item:focus-visible, a.tally-item:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .row-action { opacity: 0; transition: opacity var(--duration-fast) var(--ease-smooth); }
  :hover > .row-action, .row-action:focus-visible { opacity: 1; }
  @media (hover: none) { .row-action { opacity: 0.55; } }
  .sidebar-item:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  /* Active item — the v3 design language (paper mockup): a soft tonal block
     with the icon speaking the accent color. NO leading rail bar (that was
     the v2 tell); the rounded wash itself is the active marker. */
  .sidebar-item.is-active, .sidebar-item[aria-current="page"] { background: var(--accent); color: var(--foreground); font-weight: var(--weight-semibold); }
  .sidebar-item.is-active > svg, .sidebar-item[aria-current="page"] > svg { opacity: 1; color: var(--ui-accent); }
  /* 悬停「当前页」时不能退回中性悬停色 —— 那会让当前项在鼠标下丢掉自己的身份。
     当前态在悬停时只是更重一点。 */
  .sidebar-item.is-active:hover, .sidebar-item[aria-current="page"]:hover {
    background: color-mix(in oklab, var(--ui-accent) 6%, var(--accent)); color: var(--foreground);
  }
  .sidebar-item > .tag, .sidebar-item > .chip { margin-inline-start: auto; }

  /* ---- sidebar template: fixed head / scrolling middle / fixed foot ----
     For a rail whose middle section is a LIST OF DATA (chat sessions, drama
     episodes, saved queries) that can reach dozens of rows. Scrolling the whole
     rail is the bug this replaces: the channel entries and the primary "new …"
     action scroll away with the 50th row, so the rail's navigation disappears
     exactly when a long history makes it most needed.
     Requires a definite height on the rail — inside .app-shell-fixed it has one;
     .sidebar-scroll is a no-op (content-height) rail otherwise, which is the
     right degradation for a document-scrolling page. */
  .sidebar-head { flex: none; display: flex; flex-direction: column; gap: 2px; }
  .sidebar-scroll { flex: 1; min-block-size: 0; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
  .sidebar-scroll::-webkit-scrollbar { width: 8px; }
  .sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
  .sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-full); border: 2px solid transparent; background-clip: content-box; }
  /* margin-block-start:auto keeps the account/identity block pinned to the
     bottom even when the middle section is short (a fresh account with 2 rows). */
  .sidebar-foot { flex: none; margin-block-start: auto; padding-block-start: var(--sp-2); border-block-start: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }

  /* ---- canonical app-shell — the ONE full-page layout every product emits ----
     Gives the lib .sidebar a definite full height so its border-inline-end divider
     runs edge-to-edge to the bottom, without each product re-inventing min-h-screen /
     grid / items-start glue. Document-scroll preserving (NO internal scroll, NO pinned
     footer): the shell is min 100dvh and GROWS with tall content. Structure:
       .app-shell > [header] + .app-shell-body( .sidebar + .app-main ) + [footer / mobile-nav]
     Short page → the body-row fills the viewport (rail reaches the bottom); tall page →
     it grows to content height (rail reaches the content's bottom) and the page scrolls
     normally. 100dvh is mobile-URL-bar correct; logical props keep it writing-mode safe. */
  .app-shell { display: flex; flex-direction: column; min-block-size: 100dvh; }
  /* The row fills the height left under the header (flex:1) but never shrinks below its
     content — so the rail reaches the bottom on short AND tall pages. */
  .app-shell-body { display: flex; flex: 1; }
  .app-shell-body > .sidebar, .app-shell-body > .app-sidebar { align-self: stretch; }
  /* Content pane: fills the row inline; min-inline-size:0 lets long content shrink
     instead of overflowing. Padding is opt-in via --app-main-pad (default 0 so
     full-bleed pages and padded consoles both compose without per-product overrides). */
  .app-main { flex: 1; min-inline-size: 0; padding: var(--app-main-pad, 0); }

  /* Opt-in fixed-viewport console/editor shell. The header stays in flow while
     the body consumes the remaining viewport and only the main pane scrolls.
     Products select the behavior; they do not reimplement height/overflow CSS. */
  .app-shell.app-shell-fixed { block-size: 100dvh; min-block-size: 0; overflow: hidden; }
  .app-shell.app-shell-fixed > .app-shell-body { min-block-size: 0; overflow: hidden; }
  .app-shell.app-shell-fixed > .app-shell-body > .app-main { display: flex; flex-direction: column; min-block-size: 0; overflow: auto; }

  /* ---- responsive app-shell — phone sidebar drawer (< the header's 768px PC
     breakpoint) ----------------------------------------------------------------
     Desktop: .sidebar is the in-flow rail. Phone: it becomes an off-canvas drawer
     toggled by a product-rendered hidden checkbox (.app-nav-toggle-input) — pure
     CSS via :has(), NO JS, and picking a nav item (which re-renders the shell)
     closes it. Products emit, as children of .app-shell-body: the hidden checkbox,
     .sidebar, <main.app-main> (whose first child is a label.app-nav-toggle
     hamburger), then a label.app-nav-backdrop. All the off-canvas chrome is hidden
     on desktop, so adding the markup is inert above the breakpoint. */
  /* ---- desktop rail: collapse to icons + drag to resize ----------------------
     Orthogonal to the phone drawer below (that engages only ≤48rem). Collapsing
     narrows the rail to an icon strip instead of removing it: navigation stays
     reachable and the re-open handle is still where the rail was — a rail that
     vanishes takes its own toggle with it. Width is the single --sidebar-w knob
     the rail already reads, so <dev-rail-resizer> writes one property and
     nothing else in the shell needs to know. */
  .app-shell-body[data-nav="collapsed"] > :is(.sidebar, .app-sidebar) { --sidebar-w: var(--sidebar-rail-w, 3.25rem); padding-inline: var(--sp-1); overflow: hidden; }
  .app-shell-body[data-nav="collapsed"] > :is(.sidebar, .app-sidebar) .sidebar-item { justify-content: center; padding-inline: 0; gap: 0; }
  /* NO transition on the rail's inline-size. It looks nice and it breaks the drag:
     with a running width transition the dragged value never lands (measured in
     production — the rail visibly resized, then went back to 240px), and every
     frame of it relayouts the whole canvas. Collapse/expand snaps instead. */
  /* The grabber is a 10px hit area with a 2px visual — wide enough to grab,
     thin enough not to read as a divider of its own. 它同时是**收起/展开的唯一入口**
     (双击),所以收起状态下绝不能消失 —— 藏了就再也展不开(operator 2026-08-09
     把收起按钮撤掉后,这条线就是那颗按钮)。 */
  .rail-resizer { flex: 0 0 auto; inline-size: 10px; margin-inline-start: -5px; cursor: col-resize; background: transparent; position: relative; z-index: var(--z-raised); touch-action: none; }
  .rail-resizer::after { content: ""; position: absolute; inset-block: 0; inset-inline-start: 4px; inline-size: 2px; background: var(--ui-accent); opacity: 0; transition: opacity var(--duration-fast, 0.12s) var(--ease-smooth, ease); }
  .rail-resizer:hover::after, .rail-resizer:focus-visible::after, .rail-resizer.is-dragging::after { opacity: 1; }
  .rail-resizer:focus-visible { outline: none; }
  /* 收起态:线还在原地,但拖不动了(JS 侧也挡着),所以光标不再是「可拖宽」。 */
  .app-shell-body[data-nav="collapsed"] .rail-resizer { cursor: pointer; }
  @media (max-width: 48rem) { .rail-resizer, dev-rail-resizer { display: none; } }
}

/* The rail's collapsed state has to WIN over the layout utilities that put those
   rows on screen (.cluster/.stack are `display:flex`), and `lib.utilities` is the
   last layer — inside a later layer even a one-class rule beats any specificity
   from an earlier one. So these two live here, next to what they override,
   instead of in lib.components with the rest of the shell. */
@layer lib.utilities {
  .app-shell-body[data-nav="collapsed"] > :is(.sidebar, .app-sidebar) :is(.sidebar-label, .rail-hide) { display: none; }
  /* Keep the icon, drop the words: everything after the leading glyph is text. */
  .app-shell-body[data-nav="collapsed"] > :is(.sidebar, .app-sidebar) .sidebar-item > *:not(svg):not(.rail-keep) { display: none; }

  /* ---- .hide-narrow — drop a redundant control on a phone -------------------
     For a control whose information is ALSO carried by a neighbour (a title next
     to a switcher that names the same thing). On a phone the row runs out of
     width and the two start overlapping; hiding the redundant one is honest,
     shrinking both until neither is readable is not. `display:none` (not
     visibility) so it leaves the flex row entirely. */
  @media (max-width: 48rem) { .hide-narrow { display: none; } }
  /* ---- .show-narrow — the other half of the pair ----------------------------
     Exists ONLY on a phone (a short label that replaces a wide one, a hint that
     makes sense only once a table has stacked). Same breakpoint as .hide-narrow
     (--bp-md, 48rem), written as its exact complement so at every width exactly
     one of a hide/show pair is on screen. Below the breakpoint it keeps whatever
     display its other classes give it (.cluster stays flex). */
  @media not all and (max-width: 48rem) { .show-narrow { display: none; } }

  /* ---- .table-stack-narrow — a table that becomes one block per row on a phone
     For a table whose cells are long prose (a storyboard: shot / scene / action /
     line), where .table-wrap's sideways scroll would hide most of every row.
     At --bp-md (48rem) and below: the header row goes, each <tr> becomes a block,
     its cells run top to bottom, the first cell (the row's name) is bold, and a
     cell's optional `data-label` prints before it as a quiet field name. Above
     the breakpoint NOTHING here applies — it is an ordinary .table.
     Lives in lib.utilities so it wins over the utility-layer `.table td.num`
     end-alignment (a right-aligned number in a stacked row reads as a misprint). */
  @media (max-width: 48rem) {
    .table-stack-narrow thead { display: none; }
    .table-stack-narrow, .table-stack-narrow > :is(tbody, tfoot), .table-stack-narrow > :is(tbody, tfoot) > tr { display: block; }
    .table-stack-narrow > :is(tbody, tfoot) > tr { block-size: auto; padding-block: var(--sp-2); border-block-end: 1px solid color-mix(in oklab, var(--border) 62%, transparent); }
    .table-stack-narrow > :is(tbody, tfoot):last-child > tr:last-child { border-block-end: none; }
    .table-stack-narrow > :is(tbody, tfoot) > tr > :is(td, th) { display: block; padding: var(--sp-1) var(--sp-3); border: none; text-align: start; }
    /* `.table td.num` is (0,2,1); this one class more is what outranks it. */
    .table-stack-narrow > :is(tbody, tfoot) > tr > .num { text-align: start; }
    .table-stack-narrow > :is(tbody, tfoot) > tr > :is(td, th):first-child { font-weight: var(--weight-semibold); }
    /* The field name hangs in the cell's start padding (a float pulled back by a
       negative margin), so a long value that wraps stays aligned with its first
       line instead of running back under the name. */
    .table-stack-narrow > :is(tbody, tfoot) > tr > [data-label] { padding-inline-start: calc(var(--sp-3) + 4rem + var(--sp-2)); }
    .table-stack-narrow > :is(tbody, tfoot) > tr > [data-label]::before { content: attr(data-label); float: inline-start; inline-size: 4rem; margin-inline-start: calc(-4rem - var(--sp-2)); font-size: var(--text-micro); font-weight: var(--weight-normal); color: var(--muted-foreground); }
  }

  /* Showing exactly one studio pane has to win over the same layout utilities for
     the same reason the rail rules above do: the panes are .stack/.cluster, and a
     rule in lib.components can never hide a utility from the later layer. */
  @media (max-width: 48rem) {
    .studio-shell > .studio-pane { display: none; }
    /* `flex:1` + `min-block-size:0` is what keeps a canvas pane from collapsing to
       zero height — the bug this whole block exists for. */
    .studio-shell[data-pane="side"] > .studio-pane[data-pane-name="side"],
    .studio-shell[data-pane="main"] > .studio-pane[data-pane-name="main"],
    .studio-shell[data-pane="aside"] > .studio-pane[data-pane-name="aside"],
    /* No data-pane yet (first paint, or a product that forgot it) must not render
       a blank screen — fall back to the working pane. */
    .studio-shell:not([data-pane]) > .studio-pane[data-pane-name="main"] {
      display: flex; flex: 1; inline-size: 100%; max-inline-size: 100%; min-block-size: 0;
    }
  }

  /* Chrome 面:与 <dev-site-header> 同一张纸。
     频道自己的页头贴在平台顶栏正下方时,必须和它共面 —— 否则叠出「顶栏 --card /
     页头 --background / 侧栏 --card」三级近白,中间那条最暗,整片 chrome 看着脏
     (operator 2026-08-04 在 AI短剧 上点名)。`.document-workspace-topbar` 的
     `--background` 是给**整页文档工作台**用的(顶栏坐在文档底色上);贴着平台顶栏
     的那种页头加这个类即可,不必让每个频道各自写一遍背景色。
     必须在 lib.utilities 里:层序是 reset → components → utilities,放进
     components 会被 utilities 整层压掉(2026-08-04 线上实测)。 */
  .surface-chrome { background: var(--card); }
}

@layer lib.components {

  .app-nav-toggle-input { position: absolute; inline-size: 1px; block-size: 1px; margin: -1px; opacity: 0; pointer-events: none; }
  .app-nav-toggle { display: none; align-self: flex-start; align-items: center; justify-content: center; gap: var(--sp-2); block-size: 2.25rem; min-inline-size: 2.25rem; padding: 0 var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); color: var(--foreground); font: inherit; font-size: var(--text-caption); font-weight: var(--weight-medium); cursor: pointer; user-select: none; }
  .app-nav-toggle:hover { background: var(--state-hover); }
  .app-nav-toggle > svg { inline-size: 1.1rem; block-size: 1.1rem; flex: none; }
  .app-nav-backdrop { display: none; }
  /* Bottom tab bar alternative to the drawer: a shell that emits an .app-mobilenav hides its
     .app-sidebar under 48rem and shows the bar; ≥48rem the bar is gone. A bar with ~10 items
     scrolls sideways without a visible scrollbar (it is a swipe strip). Opt-in by presence,
     like the drawer above, so shells without it are untouched. */
  .app-mobilenav { display: none; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .app-mobilenav::-webkit-scrollbar { display: none; }
  .app-mobilenav > * { flex: 0 0 auto; }
  @media (max-width: 48rem) {
    .app-mobilenav { display: flex; }
    .app-shell:has(> .app-mobilenav) > .app-shell-body > .app-sidebar { display: none; }
  }
  @media (max-width: 48rem) {
    /* Strictly OPT-IN: the off-canvas behavior only engages when the page emits
       the .app-nav-toggle-input checkbox (+ a .app-nav-toggle hamburger). A shell
       without the toggle keeps its in-flow sidebar unchanged, so this is safe for
       every channel that hasn't adopted the drawer yet. */
    .app-shell-body:has(> .app-nav-toggle-input) > .sidebar,
    .app-shell-body:has(> .app-nav-toggle-input) > .app-sidebar {
      position: fixed; inset-block: 0; inset-inline-start: 0; z-index: var(--z-dropdown);
      /* 手机抽屉的宽度**不是**标准栏宽(--rail-w),别顺手改成它:这里是覆盖在内容上的
         浮层,由 82vw 兜住,和桌面并排的那条侧栏不是同一件事。数字曾经巧合相同。 */
      inline-size: min(17rem, 82vw); max-inline-size: 82vw;
      transform: translateX(-100%);
      transition: transform var(--duration-medium, 0.22s) var(--ease-smooth, ease);
      box-shadow: var(--shadow-float, 0 10px 30px rgba(0, 0, 0, 0.22));
      overflow-y: auto;
    }
    .app-shell-body:has(> .app-nav-toggle-input:checked) > .sidebar,
    .app-shell-body:has(> .app-nav-toggle-input:checked) > .app-sidebar { transform: translateX(0); }
    .app-shell-body:has(> .app-nav-toggle-input) .app-nav-backdrop { display: block; position: fixed; inset: 0; z-index: calc(var(--z-dropdown) - 5); border: 0; background: rgba(0, 0, 0, 0.45); opacity: 0; pointer-events: none; transition: opacity var(--duration-medium, 0.22s) var(--ease-smooth, ease); }
    .app-shell-body:has(> .app-nav-toggle-input:checked) .app-nav-backdrop { opacity: 1; pointer-events: auto; }
    /* The hamburger is phone-only, so it always receives the shared 44px touch
       floor even when desktop devtools emulate width without a coarse pointer. */
    .app-nav-toggle { display: inline-flex; min-block-size: var(--sp-11); }
    /* 桌面那条「收起」在抽屉里没有意义 —— 抽屉本身就是收起态。收起入口现在是
       分割线(.rail-resizer),它在窄屏本来就已经整条隐藏(见上面的 48rem 规则),
       所以这里不需要再单独藏什么。 */
  }

  /* ---- studio-shell — the three-pane workbench, phone-collapsible -------------
     A workbench page (canvas + conversation + asset rail) is NOT the app-shell:
     app-shell is nav+content, while this is 2-3 CO-EQUAL working panes inside the
     content. Stuffing three panes into one narrow column is what breaks a
     workbench on a phone — panes stack, the composer lands on top of the rail,
     and a canvas pane whose height came from the row collapses to zero.

     So on a phone we show ONE pane at a time and switch with a bottom segmented
     control. The page declares only WHICH pane is showing (data-pane on the
     shell); every breakpoint, the switcher's touch floor, and the pane hiding
     live here — a channel that reimplements this grows a second look.

     Structure:
       .studio-shell[data-pane="main"] >
          .studio-pane[data-pane-name="side"]     (optional leading pane)
          .studio-pane[data-pane-name="main"]
          .studio-pane[data-pane-name="aside"]
          .studio-switch                          (phone-only, product-rendered)
     Desktop: every .studio-pane is visible in a row and .studio-switch is gone,
     so the markup is inert above the breakpoint. */
  .studio-shell { display: flex; flex: 1; min-block-size: 0; min-inline-size: 0; }
  .studio-pane { display: flex; flex-direction: column; min-block-size: 0; min-inline-size: 0; }
  /* The working pane absorbs slack; the rails take the SAME standard rail width as
     .grid-rail-end, so a workbench and a rail page put their rails on the same
     line — that shared width is the whole reason it is one token (--rail-w) and
     not a per-page number. */
  .studio-pane[data-pane-name="main"] { flex: 1; }
  .studio-pane[data-pane-name="side"],
  .studio-pane[data-pane-name="aside"] { flex: none; inline-size: clamp(var(--rail-w-min, 13rem), 26vw, var(--rail-w, 22rem)); }
  .studio-switch { display: none; }

  @media (max-width: 48rem) {
    .studio-shell { flex-direction: column; }
    .studio-switch {
      display: flex; flex: none; gap: var(--sp-1); padding: var(--sp-1);
      border-block-start: 1px solid var(--border); background: var(--card);
      /* Clears the phone home indicator so the last row isn't half a thumb tall. */
      padding-block-end: max(var(--sp-1), env(safe-area-inset-bottom));
    }
    .studio-switch > button {
      flex: 1; min-block-size: var(--sp-11); display: inline-flex; align-items: center; justify-content: center;
      gap: var(--sp-1); border: none; border-radius: var(--radius-sm); background: none;
      font: inherit; font-size: var(--text-caption); color: var(--muted-foreground); cursor: pointer;
    }
    .studio-switch > button[aria-current="true"] { background: var(--state-selected); color: var(--foreground); font-weight: 600; }
  }

  /* ---- toolbar-scroll — a control row that scrolls instead of wrapping --------
     A composer toolbar (mode, model, template, attach, send) has no useful wrap
     point: wrapping doubles its height and pushes the working area off a phone
     screen, and clipping silently eats the last control. Scrolling keeps every
     control reachable at full size. The send action opts out with .toolbar-end so
     it stays pinned where the thumb expects it. */
  .toolbar-scroll {
    display: flex; align-items: center; gap: var(--sp-2);
    overflow-x: auto; overflow-y: hidden; flex-wrap: nowrap;
    scrollbar-width: none; overscroll-behavior-inline: contain;
  }
  .toolbar-scroll::-webkit-scrollbar { display: none; }
  .toolbar-scroll > * { flex: none; }
  .toolbar-scroll > .toolbar-end { margin-inline-start: auto; position: sticky; inset-inline-end: 0; }

  .carousel { display: flex; gap: var(--sp-3); overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin; padding-block-end: var(--sp-2); overscroll-behavior-inline: contain; }
  .carousel > * { scroll-snap-align: start; flex: 0 0 auto; }
  .carousel-marquee { overflow: hidden; }
  .carousel-marquee > .carousel-track { display: flex; gap: var(--sp-3); inline-size: max-content; animation: lib-marquee 24s linear infinite; }
  .carousel-marquee:hover > .carousel-track { animation-play-state: paused; }

  .cmd-item { display: flex; align-items: center; gap: var(--sp-2); inline-size: 100%; box-sizing: border-box; padding: var(--sp-2) var(--sp-3); border: none; background: none; border-radius: var(--radius-sm); font: inherit; font-size: var(--text-caption); color: var(--foreground); text-align: start; cursor: pointer; }
  /* 键盘当前项 ≠ 鼠标悬停。它们必须能**同时存在而不混淆**:鼠标停在一行、方向键
     又在另一行时,两行同色就看不出回车会落到哪。悬停只给底色,键盘游标额外多一道
     内侧强调条 —— 分量比悬停重一档,且两者叠加时仍分得清。 */
  .cmd-item:hover { background: var(--state-hover); }
  .cmd-item.active { background: var(--state-selected); box-shadow: inset 2px 0 0 var(--ui-accent); }
  .cmd-item .menu-shortcut { margin-inline-start: auto; }

  /* ---- batch 6: tree + diff (rich-textarea/calendar/image-annotate are WCs) ---- */
  .tree, .tree-group { list-style: none; margin: 0; padding: 0; }
  .tree { font-size: var(--text-caption); }
  .tree-group { padding-inline-start: var(--sp-4); }
  [role="treeitem"][aria-expanded="false"] > .tree-group { display: none; }
  .tree-item { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); color: var(--foreground); cursor: pointer; }
  .tree-item:hover { background: var(--state-hover); }
  .tree-item:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  [role="treeitem"][aria-selected="true"] > .tree-item { background: var(--ui-accent-soft); color: var(--accent-foreground, var(--foreground)); font-weight: var(--weight-medium); }
  .tree-twisty { display: inline-flex; inline-size: 1rem; justify-content: center; flex: none; color: var(--muted-foreground); transition: transform 150ms var(--ease-smooth); }
  [role="treeitem"][aria-expanded="false"] > .tree-item .tree-twisty { transform: rotate(-90deg); }
  [role="treeitem"]:not([aria-expanded]) > .tree-item .tree-twisty { visibility: hidden; }

  .diff { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; font-family: var(--font-mono); font-size: var(--text-caption); line-height: var(--leading-normal); }
  /* 左右两栏原本各自堆行、各自滚动 —— 任何一行折行,从那里往下整篇就错位了。
     现在一行一对、共用同一条网格:.diff-row 用 display:contents 把两个单元格
     直接交给网格,同一行的左右天然等高;滚动也只剩一条(挂在 .diff 上)。 */
  .diff { max-block-size: 22rem; overflow: auto; }
  .diff-row { display: contents; }
  .diff-pane { background: var(--card); }
  .diff-pane-head { padding: var(--sp-1) var(--sp-3); font-family: var(--font-sans); font-size: var(--text-micro); font-weight: var(--weight-semibold); color: var(--muted-foreground); background: var(--surface-sunken); border-block-end: 1px solid var(--border); position: sticky; inset-block-start: 0; }
  .diff-line { display: block; background: var(--card); padding-inline: var(--sp-3); white-space: pre-wrap; word-break: break-word; }
  .diff-add { background: var(--success-bg); }
  .diff-del { background: var(--danger-bg); }
  .diff-empty { background: color-mix(in oklab, var(--muted) 60%, transparent); min-block-size: 1.55em; }

  /* ---- vP7: channel-migration gaps (collision-safe names) ---- */
  /* Metric tile — generic key/value stat (replaces channels' bespoke
     .stat-card/.metrics/.buy-stats; the bare .stat-card name stays withheld). */
  .metric { display: grid; gap: var(--sp-1); padding: var(--c-card-pad); border: 1px solid var(--border); border-radius: var(--c-card-radius); background: var(--card); min-inline-size: 0; }
  .metric-label { font-size: var(--text-micro); font-weight: var(--weight-medium); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  .metric-value { font-size: var(--text-title); font-weight: var(--weight-semibold); line-height: var(--leading-tight); color: var(--foreground); font-variant-numeric: tabular-nums; }
  .metric-delta { display: inline-flex; align-items: center; gap: 2px; font-size: var(--text-micro); color: var(--muted-foreground); }
  /* 11px delta figures are TEXT on a card — the fill tones read 3.14:1 (green)
     and 4.60:1 (red) there, so both take the ink step. */
  .metric-delta.is-up { color: var(--success-text); }
  .metric-delta.is-down { color: var(--danger-text); }
  /* Dashboard-grade KPI variant — the number carries the tile. */
  .metric-lg .metric-value { font-size: var(--text-display); }
  /* horizontal key:value pill variant (e.g. timing rows) */
  .metric-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-sunken); min-inline-size: 0; }
  .metric-row .metric-value { font-size: var(--text-body); text-align: end; }

  /* Report figures — borderless KPI figures for the usage report (`<dev-usage-report>`):
     a micro uppercase label over a display-size tabular number, optional hint under it.
     `.metric` is the bordered tile for dashboards; this is the in-card headline row. */
  .figures { display: flex; flex-wrap: wrap; gap: var(--sp-8); margin-block: var(--sp-3); }
  .figure { display: grid; gap: 2px; min-inline-size: 0; }
  .figure-label { font-size: var(--text-micro); text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--muted-foreground); }
  .figure-value { font-size: var(--text-display); font-family: var(--font-mono); font-variant-numeric: tabular-nums; line-height: var(--leading-tight); color: var(--foreground); }
  .figure-hint { font-size: var(--text-micro); color: var(--muted-foreground); }
  /* The usage report's own small pieces: facet popover panel + row, fixed-width pickers,
     the row-detail thumbnail, a narrow trailing header cell, a 16rem chart. */
  .facet-panel { inline-size: 17rem; max-block-size: 20rem; overflow: auto; }
  .facet-row { cursor: pointer; }
  .datetime-fit { inline-size: 13rem; }
  .page-input { inline-size: 4.5rem; }
  .thumb-row { display: block; block-size: 7rem; inline-size: auto; border: 1px solid var(--border); border-radius: var(--radius); cursor: zoom-in; }
  .th-narrow { inline-size: 2.5rem; }
  .rounded { border-radius: var(--radius); }
  .definition-narrow { --definition-label-w: 6.5rem; }
  .chart-md { --dev-chart-height: 16rem; }
  .chart-sm { --dev-chart-height: 14rem; }
  /* Fixed square thumbnail (candidate strips, anchor images): a bare <img> or an empty-state
     placeholder of the same footprint. --thumb-size overrides the 5rem default; -xs is the
     1.25rem round chip thumb. .thumb-fill is the fluid square (a style card's sample). */
  .thumb-sq { inline-size: var(--thumb-size, 5rem); block-size: var(--thumb-size, 5rem); flex: 0 0 auto; object-fit: cover; border-radius: var(--radius-sm); }
  .thumb-sq.empty-state { display: flex; align-items: center; justify-content: center; text-align: center; padding: var(--sp-1); }
  .thumb-sq-xs { --thumb-size: 1.25rem; border-radius: var(--radius-full); }
  .thumb-fill { display: block; inline-size: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-sm); }
  /* **一排带标签的小图**(`.thumb-strip` > `.thumb-cell`,2026-09-27):同一件东西在几个取值下各一张
     (鹈鹕 · 各思考等级),标签在图下、分数 / 状态在标签旁,一排对齐、放不下就换行。
     没有图的格子(那一档被拒 / 没画出来)用 `.thumb-cell-empty`:同尺寸的虚线框 + 一行原因,排版不塌。 */
  .thumb-strip { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
  .thumb-cell { display: grid; gap: 2px; justify-items: center; inline-size: var(--thumb-size, 4.5rem); font-size: var(--text-micro); color: var(--muted-foreground); text-align: center; }
  .thumb-cell > img { inline-size: 100%; aspect-ratio: 1; object-fit: contain; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--card); cursor: zoom-in; }
  .thumb-cell > .thumb-cell-empty { inline-size: 100%; aspect-ratio: 1; display: grid; place-items: center; padding: var(--sp-1); border: 1px dashed var(--border); border-radius: var(--radius-sm); line-height: var(--leading-tight); overflow: hidden; }
  .thumb-cell > b { color: var(--foreground); font-weight: var(--weight-semibold); }
  .thumb-cell.thumb-cell-lg { --thumb-size: 100%; }
  /* Inline SVG icons inside a button take the small icon size (a bare <svg> is 300×150 by
     default and would blow the button up); a link styled as a button that is not yet usable
     (nothing to download yet) reads disabled via aria-disabled. */
  .btn > svg { inline-size: var(--icon-sm, 16px); block-size: var(--icon-sm, 16px); flex: none; }
  .btn[aria-disabled="true"] { opacity: var(--state-disabled-opacity); pointer-events: none; }
  /* An inline SVG glyph sitting in running text (a platform icon before a name, a lock after a
     title): baseline-aligned like a character, sized by its own width/height attributes. */
  .icon-inline { display: inline-block; vertical-align: -0.125em; flex: none; }
  /* Choice card — a radio/checkbox option rendered as a small card (title + subtitle),
     for a 2–4 way choice too wordy for .segmented (lipu's 整活版 / 写实版 workflow picker,
     a plan picker). `<label class="choice-card"><input type="radio"> <strong>…</strong>
     <small>…</small></label>`; the checked one takes the accent pair like .card-active. */
  .choice-card { position: relative; display: grid; gap: var(--sp-1); min-inline-size: 0; padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); cursor: pointer; transition: border-color var(--duration-fast, 120ms), background-color var(--duration-fast, 120ms); }
  .choice-card > input { position: absolute; inset: 0; opacity: 0; pointer-events: none; }
  .choice-card > strong { font-size: var(--text-heading); font-weight: var(--weight-semibold); }
  .choice-card > small { font-size: var(--text-caption); color: var(--muted-foreground); }
  .choice-card:hover { border-color: var(--border-strong); }
  .choice-card:has(input:checked) { border-color: var(--accent); background: color-mix(in oklab, var(--accent) 10%, var(--card)); box-shadow: inset 0 0 0 1px var(--accent); }
  .choice-card:focus-within { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }

  /* Drop zone — the dashed "drop files here or click" area (clipboard's file panel, xsvg's
     upload). Pattern: a <label class="dropzone"> wrapping a visually-hidden <input type=file>;
     the host toggles `.is-dragover` while a drag hovers. `<dev-dropzone>` is the component
     form (fires dev-dropzone-files); this is the class form for pages that own their upload. */
  .dropzone { position: relative; display: grid; gap: var(--sp-1); justify-items: center; text-align: center; min-block-size: 8rem; padding: var(--sp-5) var(--sp-4); border: 1px dashed var(--border); border-radius: var(--radius); background: var(--muted); cursor: pointer; }
  .dropzone.is-dragover { border-color: var(--ring); background: var(--accent); }
  .dropzone:focus-within { border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 35%, transparent); }
  .dropzone-title { font-size: var(--text-caption); font-weight: var(--weight-semibold); }
  .dropzone-hint { font-size: var(--text-micro); color: var(--muted-foreground); }
  .dropzone-hint:empty { display: none; }
  .dropzone > input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
  /* Mobile action dock — a working page's 2–3 primary actions pinned to the bottom of a phone
     screen (the page's own buttons scroll away). Hidden on wide screens; `.has-action-dock` on
     the scrolling column leaves room under the content. Same breakpoint as the shell drawer. */
  .action-dock { display: none; }
  @media (max-width: 48rem) {
    .action-dock { position: fixed; inset-inline: var(--sp-4); inset-block-end: calc(var(--sp-4) + env(safe-area-inset-bottom, 0px)); z-index: var(--z-raised-2); display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--sp-3); padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); box-shadow: var(--shadow-float); }
    .action-dock > * { min-inline-size: 0; }
    .has-action-dock { padding-block-end: calc(9rem + env(safe-area-inset-bottom, 0px)); }
  }

  /* Dark-glass badge for labeling content OVER media (theme-independent,
     legible on any image — token .tag/.chip are invisible over photos). */
  .media-badge { display: inline-flex; align-items: center; gap: var(--sp-1); min-block-size: 1.5rem; padding: 0.3125rem 0.625rem; border-radius: var(--radius); border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.6); color: #fff; font-size: var(--text-micro); line-height: 1; letter-spacing: 0.02em; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }

  /* Generic media-library composition. Consumers provide only media and data;
     lib owns the preview ratio, selection state, card spacing, and actions. */
  .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--media-card-min, 14rem), 1fr)); gap: var(--sp-4); align-items: start; }
  .media-card { position: relative; min-inline-size: 0; overflow: hidden; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); appearance: none; background: var(--card); color: var(--card-foreground); box-shadow: var(--shadow-card); font: inherit; text-align: inherit; }
  button.media-card { cursor: pointer; }
  .media-card[aria-selected="true"], .media-card.is-selected { border-color: var(--ui-accent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--ui-accent) 26%, transparent); }
  .media-preview { position: relative; display: grid; place-items: center; aspect-ratio: var(--media-aspect, 16 / 10); overflow: hidden; background: var(--surface-sunken); border-block-end: 1px solid var(--border); }
  .media-preview.media-preview-video { --media-aspect: 16 / 9; }
  .media-preview.media-preview-portrait { --media-aspect: 3 / 4; }
  .media-preview > img, .media-preview > video { inline-size: 100%; block-size: 100%; object-fit: cover; }
  .media-preview > object { inline-size: 100%; block-size: 100%; }
  .media-preview.media-thumb-contain > object { object-fit: contain; }
  /* Transparency checkerboard behind a preview whose alpha matters (an SVG / PNG converter's
     before-after panes). Pair with .media-preview. */
  .media-checker { background-color: var(--card); background-image: linear-gradient(45deg, var(--muted) 25%, transparent 25%), linear-gradient(-45deg, var(--muted) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--muted) 75%), linear-gradient(-45deg, transparent 75%, var(--muted) 75%); background-position: 0 0, 0 10px, 10px -10px, -10px 0; background-size: 20px 20px; }
  .media-preview.media-preview-contain > img, .media-preview.media-preview-contain > video { object-fit: contain; }
  .media-preview > audio { inline-size: calc(100% - (2 * var(--sp-3))); max-inline-size: 100%; }
  .media-preview-placeholder { display: grid; place-items: center; inline-size: 100%; block-size: 100%; color: var(--muted-foreground); font-size: var(--text-title); }
  .media-preview > .media-badge { position: absolute; inset-block-start: var(--sp-2); inset-inline-end: var(--sp-2); transition: opacity var(--duration-fast, 120ms) var(--ease-smooth, ease); }
  /* 类型徽章与放大角标同住右上角:hover(角标浮现)时徽章暂时让位 ——
     与画布节点的候选徽章同一规矩,两个标叠在一起谁都看不清。 */
  .media-preview:has(.media-zoom):hover > .media-badge { opacity: 0; }
  /* Anything wired to the singleton <dev-lightbox> (declarative data-lightbox attr
     or a channel click handler) advertises the full-view affordance. */
  [data-lightbox] { cursor: zoom-in; }
  .media-player { inline-size: 100%; max-block-size: var(--media-player-max, 24rem); border-radius: var(--radius); background: var(--surface-sunken); }
  .media-card-select { position: absolute; inset-block-start: var(--sp-2); inset-inline-start: var(--sp-2); z-index: var(--z-raised); display: inline-flex; padding: var(--sp-1); border-radius: var(--radius-sm); background: color-mix(in oklab, var(--card) 86%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
  .media-card-body { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
  .media-card-title { min-inline-size: 0; overflow: hidden; color: var(--foreground); font-size: var(--text-body); font-weight: var(--weight-semibold); text-overflow: ellipsis; white-space: nowrap; }
  .media-card-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); color: var(--muted-foreground); font-size: var(--text-micro); }
  .media-card-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); padding-block-start: var(--sp-1); border-block-start: 1px solid var(--border); }
  /* Compact thumbnail for horizontal asset rows (asset rails, mention lists).
     Size/aspect are tokens: override --media-thumb-w / --media-aspect per list. */
  .media-thumb { position: relative; flex: none; inline-size: var(--media-thumb-w, 3.75rem); aspect-ratio: var(--media-aspect, 3 / 4); display: grid; place-items: center; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-sunken); color: var(--muted-foreground); font-size: var(--text-micro); }
  .media-thumb > img, .media-thumb > video { inline-size: 100%; block-size: 100%; object-fit: cover; }
  /* 需要看清「这是哪一张」的地方(资产栏、聊天回显)用 contain:cover 会把竖版
     画面裁成中间一条。列表默认仍是 cover —— 那里要的是整齐,不是完整。 */
  .media-thumb-contain > img, .media-thumb-contain > video { object-fit: contain; }
  /* 缩略图网格:等宽自动折行,格子宽度由 --tile-w 调。有了它,一列资产栏里的
     「图 + 名字」不必各自算宽度,名字也才有个边界可以 truncate。 */
  .tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--tile-w, 4.5rem), 1fr)); gap: var(--sp-2); }
  .tile-grid > * { min-inline-size: 0; }
  /* 侧栏组件装进弹窗当选择器用时的两处差别(xshort 的「引用资产」= 资产栏进弹窗):
     格子小一号(弹窗比 22rem 的侧栏宽得多,7rem 的下限会排出一堆巨图),
     并且给它一个高度上限,让它自己那块内容区滚、而不是把弹窗顶穿。 */
  /* 装进浮层的资产栏:不画自己的面(浮层就是那张卡),高度定死一个窗口、内容自己滚。
     只封上限的话,空的那一档会把它缩成一条、换一档又长回去,盒子在人眼前忽大忽小;
     而给整块加下限又会在内容少时留一片死白 —— 定高 + 内部滚动两样都不会发生。 */
  .rail-in-dialog { block-size: min(62vh, 30rem); }

  /* 贴着光标 / 贴着那颗按钮开的浮层(不是居中的模态)。它属于你正在敲的那一行字,
     所以要窄、要浅、要能一眼看出「浮在上面」:宽度按一屏能排 4 列缩略图给,
     高度由内容那一块自己定,层次靠 --shadow-float(没有压暗背景可倚仗)。 */
  .pop-anchored { inline-size: 26.25rem; max-inline-size: calc(100vw - 1rem); box-shadow: var(--shadow-float); border-radius: var(--radius-lg); overflow: hidden; }
  /* 浮层的高度**跟着内容走**,只封上限:定高会在只有三张图时留一截死白,而浮层
     本来就该是「多大内容多大盒子」。上限保证它不会长到把正文全盖住。 */
  .pop-anchored .rail-in-dialog { block-size: auto; max-block-size: min(52vh, 22.5rem); }
  /* 浮层里格子再小一号:26rem 宽排四列,一屏看得见更多(侧栏是 5rem 排两列)。 */
  .pop-anchored .tile-grid-lg { --tile-w: 4.25rem; }
  /* 分组标题在弹窗里是**分节线**,不是卡头:贴住上一节,和网格之间留一口气。 */
  .rail-in-dialog .deck-head-flat { padding-block-start: var(--sp-1); border-block-start: 1px solid var(--border); padding-block-end: var(--sp-1); }
  .rail-in-dialog > :first-child .deck-head-flat, .rail-in-dialog .stack > :first-child > .deck-head-flat { border-block-start: 0; }
  /* `--tile-w` 要落在**格子自己**身上:`.tile-grid-lg` 是元素自带的类,祖先上设的
     那份变量压不过它(2026-08-28 量到:弹窗里仍按 7rem 排)。 */
  .rail-in-dialog .tile-grid-lg { --tile-w: 5rem; }
  /* 侧栏里的「大格子」。7rem 是**下限**(auto-fill + 1fr,格子会撑满剩余宽度):
     标准栏宽下排两列;栏宽 2026-08-27 从 17rem 加到 22rem 后仍是两列,只是每格更大
     (三列要 3×7rem+2×gap,放不下)。这里跟着 --rail-w 走,不要再写死一个反推出来的
     数字 —— 上一版注释就是按 17rem 算的,栏宽一变那句话立刻过期。 */
  .tile-grid-lg { --tile-w: 7rem; }
  /* 封面格用的网格:一格要放得下两行字 + 一排小头像,下限比 .tile-grid-lg 宽;手机 390 宽的对话栏(约 17rem)仍排两列。 */
  .tile-grid-xl { --tile-w: 8rem; }
  /* 封面格:暗化底图 + 叠在上面的文字层(分镜拼图 / 预演、带封面的卡片格)。
       <div class="cover-tile" style="--media-aspect: 9 / 16">
         <img class="cover-tile-bg" src="…" alt="">
         <div class="cover-tile-body">… 标签 / .avatar-stack / 文字 …</div>
       </div>
     底图可以没有 —— 那就是一块下沉面、字照常。有底图时压一层 --media-scrim、字转 --on-media-foreground
     (底图的亮暗不随主题变,所以这两样两种主题相同)。格里别给字挂 .dim:它在 utilities 层,压过这里的反白,
     在暗层上是一行读不出的灰字。比例走 --media-aspect(与 .media-thumb 同一个令牌),
     它只是下限:字多时格子往下长,不裁字 —— 所以格子本身不 overflow:hidden,底图与暗层自己带圆角。
     叠放次序:底图 → 暗层(::after)→ 文字层(格子自成层叠上下文,z-index 不外溢)。 */
  .cover-tile { position: relative; isolation: isolate; display: grid; align-content: end; aspect-ratio: var(--media-aspect, 16 / 9); min-inline-size: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-sunken); color: var(--foreground); }
  .cover-tile > .cover-tile-bg { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
  .cover-tile:has(> .cover-tile-bg) { color: var(--on-media-foreground); }
  .cover-tile:has(> .cover-tile-bg)::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--media-scrim); pointer-events: none; }
  .cover-tile-body { position: relative; z-index: var(--z-raised); display: flex; flex-direction: column; gap: var(--sp-1); min-inline-size: 0; padding: var(--sp-2); font-size: var(--text-micro); line-height: var(--leading-snug); overflow-wrap: anywhere; }
  /* 叠放的小头像:一排人像互相压半个身位,每个带一圈底色描边,看得出是几个人。
     没图的就是 .avatar 里放首字(与 .avatar 同一个占位)。 */
  .avatar-stack { display: inline-flex; align-items: center; }
  .avatar-stack > .avatar { flex: none; box-shadow: 0 0 0 2px var(--card); }
  .avatar-stack > .avatar + .avatar { margin-inline-start: calc(var(--sp-2) * -1); }
  /* 缩略图撑满它所在的格子(默认是 3.75rem 的固定小图)。 */
  .media-thumb-fill { --media-thumb-w: 100%; }
  /* 空的素材位:虚线框 + 居中的一句「添加」,点它去挑素材(上传 / 图库)。
     每个有素材位的频道都在自己那边画过一遍这个方格(图片的参考图、视频的参考图与
     首尾帧),形状还各不相同 —— 收进 lib,和已挂素材的 .media-thumb 天然等大等圆角。 */
  .media-thumb-add { border-style: dashed; cursor: pointer; background: none; }
  .media-thumb-add:hover { border-color: var(--primary); color: var(--foreground); }
  .media-thumb-add:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .media-thumb-add:disabled { opacity: var(--state-disabled-opacity); pointer-events: none; }


  /* 「这一轮的素材摆位」:一排等大方位,每个方位有固定名分(首帧 / 尾帧 / 参考图N),
     空的是 .media-thumb-add,填了的是 .media-thumb,方位之间可以有一个关系符
     (→ 4 秒 → 表示「从这张走到那张」)。生成模式说的就是「素材怎么摆」,而摆位
     只有画成方位才看得见 —— 一张「缩略图 + 用途下拉」的表读不出谁是头谁是尾。
     所有频道共用:视频的首尾帧/参考图、图片的参考图都是同一种摆位。 */
  .slot-row { display: flex; align-items: flex-start; gap: var(--sp-3); flex-wrap: wrap; }
  /* 方位按缩略图定下限,再按里面最宽的那件(通常是用途下拉)长到内容宽,并封顶 ——
     钉死一个宽度的话,「无(不限定用途)」这样的用途名会被截成「无(不…」。 */
  .slot { position: relative; display: flex; flex-direction: column; align-items: center; gap: var(--sp-1); min-inline-size: var(--slot-w, 5.5rem); inline-size: max-content; max-inline-size: 8.5rem; }
  .slot > .media-thumb { --media-thumb-w: var(--slot-w, 5.5rem); --media-aspect: 1; }
  /* 方位下的用途下拉:按自己的内容取宽(.input 自带 width:100%,那会让它对方位的
     内在宽度贡献为 0 —— 方位于是停在缩略图宽度上,用途名被截成「无(不…」),
     再由方位的 max-inline-size 封顶。 */
  .slot > select { inline-size: auto; max-inline-size: 100%; }
  /* 名字**不参与**方位的内在宽度(`inline-size: 0` + `min-inline-size: 100%`):
     一条长标题会把它那一格撑到封顶,于是同一排里的缩略图间距忽宽忽窄,一排等大
     方位看起来是歪的。宽度只由用途下拉说了算,名字长了就省略号。 */
  .slot-name { inline-size: 0; min-inline-size: 100%; font-size: var(--text-micro); color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  /* 必填的方位(首尾帧缺一头,所有上游都拒)——星号跟表单里的 .required 同义。 */
  .slot-name-required::after { content: "*"; color: var(--primary); margin-inline-start: 2px; }
  /* 这一排末尾那句话(还差什么 / 还能加几个):与关系符同一条中线,但它不是关系符 ——
     借用 `.slot-link` 就是借了一个带副作用的名字(那条规则的外边距是为「夹在两个
     方位之间」算的)。 */
  .slot-note { align-self: flex-start; margin-block-start: calc(var(--slot-w, 5.5rem) / 2 - 0.6rem); font-size: var(--text-micro); color: var(--muted-foreground); }
  /* 两个方位之间的关系符:它与缩略图顶对齐会浮在半空,按缩略图中线落位。 */
  .slot-link { align-self: flex-start; margin-block-start: calc(var(--slot-w, 5.5rem) / 2 - 0.6rem); display: inline-flex; align-items: center; gap: var(--sp-1); font-size: var(--text-micro); color: var(--muted-foreground); white-space: nowrap; }
  /* 已填方位上的动作(替换 / 移除):hover 或键盘聚焦到方位内部时,从**底边**浮出
     一条窄条。盖满整格的话缩略图本身就再也点不到了 —— 而点它是「放大看看我挂的是
     哪张」的唯一手势(2026-08-28 真机验到:盖满时连 lightbox 都被吃掉)。 */
  /* 宽度**跟着缩略图**,不跟着方位:方位可以被用途下拉撑到 8.5rem,而图恒是
     --slot-w 且居中 —— 铺满方位的话,那条深色底会从图两侧各伸出去一截悬在空白上。
     换行也是无条件的:德/法的「替换 / 删除」在 88px 里一行放不下,不换行就横向
     溢出压到旁边那个方位上(触屏那一档修过一次,鼠标端同一个 bug)。 */
  .slot-actions { position: absolute; inset-inline-start: 50%; inset-block-start: var(--slot-w, 5.5rem); translate: -50% -100%; inline-size: var(--slot-w, 5.5rem); max-inline-size: 100%; flex-wrap: wrap; display: flex; align-items: center; justify-content: center; gap: var(--sp-1); padding: 2px; background: color-mix(in oklab, var(--foreground) 62%, transparent); opacity: 0; pointer-events: none; transition: opacity var(--motion-fast, 120ms) ease; }
  .slot:hover .slot-actions, .slot:focus-within .slot-actions { opacity: 1; pointer-events: auto; }
  /* 文字颜色必须**跟着主题翻**:scrim 用的是 `--foreground` 的 62%,暗色主题下它
     本身接近白 —— 钉死 #fff 就是白底白字(紧挨着的 .slot-badge 用的正是 surface-raised,
     两条相邻规则不能各用一套约定)。 */
  .slot-actions > button { border: 0; background: transparent; color: var(--surface-raised); font-size: var(--text-micro); padding: 1px var(--sp-1); }
  .slot-actions > button:hover { text-decoration: underline; }
  @media (hover: none) {
    /* 常显**不等于**常盖:88px 的方位上压一条 62% 的深色条,缩略图下半截在手机上
       就永远看不清、也点不开大图。触屏上它落回文档流,排在图和名字之间。 */
    .slot-actions { position: static; translate: none; inline-size: auto; opacity: 1; pointer-events: auto; background: none; padding: 0; }
    .slot-actions > button { color: var(--muted-foreground); padding: var(--sp-1); }
  }
  /* 方位左上角的名分角标(首 / 尾 / 1 / 2):缩略图占满方位时,名字在下面,
     角标让人在缩略图上就认出这是哪一位。 */
  .slot-badge { position: absolute; inset-block-start: 2px; inset-inline-start: 2px; padding: 0 var(--sp-1); border-radius: var(--radius-sm); background: color-mix(in oklab, var(--foreground) 70%, transparent); color: var(--surface-raised); font-size: var(--text-micro); }
  /* 「这一轮用不上它」要**看得出来**:`.dim` 只淡了名字那行字,缩略图仍是满不透明,
     于是灰态方位和能用的方位长得一模一样(文字轮里挂着的参考图就是这种)。 */
  .slot.dim > .media-thumb, .slot.dim > .slot-name { opacity: var(--state-disabled-opacity); }

  /* 拖拽换位时的落点提示(与 .drop-hint 同一种语言,只是画在方位上)。 */
  .slot-drop { outline: 2px dashed var(--ui-accent, var(--primary)); outline-offset: 2px; border-radius: var(--radius); }

  /* 画幅形状小框(与 AI对话 AspectShape 同款):regular 卡片/规格键上按真实比例
     画的小矩形,宽高由宿主内联 size 给,这里只管描边。圆角写死 2px:系统圆角在
     19×11 的小框上会变成胶囊,看不出是个画幅。 */
  .aspect-shape { display: block; flex: none; border: 1.5px solid currentColor; border-radius: 2px; opacity: .75; }
  /* 画幅九宫格里的对齐槽:不同画幅的形状高矮不一,套一个定高槽让整排按钮
     基线齐平 —— 消费方此前各写 inline height 兜这件事。 */
  .aspect-shape-slot { display: inline-flex; align-items: center; justify-content: center; block-size: 1.25rem; }
  /* 可放大的缩略图(挂 data-lightbox 的 .media-thumb / .media-card 图区):hover 时
     右上角浮出一个「放大 / 播放」小圆标,提示点击可看大图。纯 CSS 角标
     (pointer-events:none)—— 点击仍由 dev-lightbox 的全局委托接管,页面零脚本。
     图标是 lucide zoom-in / play 的白描边 SVG(不手画,见 lib-frontend-spec)。 */
  .media-thumb[data-lightbox]::after, .media-card [data-lightbox]::after, .media-zoom {
    position: absolute; inset-block-start: 4px; inset-inline-end: 4px;
    inline-size: 1.375rem; block-size: 1.375rem; border-radius: var(--radius-full, 9999px); border: 0; padding: 0;
    background: rgba(0, 0, 0, 0.55) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3Cline x1='11' y1='8' x2='11' y2='14'/%3E%3Cline x1='8' y1='11' x2='14' y2='11'/%3E%3C/svg%3E") center / 0.875rem no-repeat;
    opacity: 0; transition: opacity var(--duration-fast, 120ms) var(--ease-smooth, ease);
  }
  .media-thumb[data-lightbox]::after, .media-card [data-lightbox]::after { content: ""; pointer-events: none; }
  /* 真按钮版:缩略图本体的点击另有语义(选版本 / 挑素材)时,角标自己就是
     lightbox 触发器 —— <button class="media-zoom" data-lightbox> 放进 .media-thumb,
     大图源自动取同一宿主里的 img/video(dev-lightbox 委托的宿主回溯)。 */
  .media-zoom { cursor: zoom-in; }
  .media-zoom:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); opacity: 1; }
  .media-thumb[data-lightbox]:hover::after, .media-card [data-lightbox]:hover::after,
  .media-thumb:hover .media-zoom, :hover > .media-zoom, .media-zoom:hover { opacity: 1; }
  /* 视频缩略图的角标换成播放三角(声明式 type 或宿主里就是 <video> 都认)。 */
  .media-thumb[data-lightbox][data-lightbox-type="video"]::after,
  .media-thumb[data-lightbox]:has(video)::after,
  .media-card [data-lightbox][data-lightbox-type="video"]::after,
  .media-card [data-lightbox]:has(video)::after,
  .media-zoom[data-lightbox-type="video"], .media-thumb:has(video) > .media-zoom {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolygon points='6 3 20 12 6 21 6 3'/%3E%3C/svg%3E");
  }
  /* 列表里的小圆点标记:比图标轻,又比纯文字有锚点。 */
  /* 侧栏面板的底:自带卡面与一条起始分隔线。共享组件的外观必须由组件自己带,
     宿主只决定它放在哪 —— 一个宿主传 card、另一个不传,同一个组件就会长成两个样子。 */
  .surface-panel { background: var(--card); border-inline-start: 1px solid var(--border); }
  /* 消费方长期在用、lib 却没有的几档 —— 写了等于没写,补齐: */
  .surface-muted { background: var(--surface-sunken); border-radius: var(--radius); }
  /* 全出血面板(surface-panel)套沉底色时只要「色」不要「圆角」:贴边面板带
     圆角会把四角剪出缺口(xshort 项目资产栏,2026-08-06 像素巡检)。 */
  .surface-panel.surface-muted { border-radius: 0; }
  .card-pad-sm { padding: var(--sp-2) var(--sp-3); }
  .media-card-thumb { inline-size: 100%; aspect-ratio: var(--media-aspect, 3 / 4); object-fit: cover; border-radius: var(--radius) var(--radius) 0 0; display: block; }

  .dot-mark { inline-size: .375rem; block-size: .375rem; border-radius: 999px; background: var(--muted-foreground); opacity: .45; }
  /* 状态点变体:检视面板头部「已就绪/待处理」一类的 6px 状态灯(画布右栏首用)。 */
  .dot-mark-ok { background: var(--success); opacity: 1; }
  .dot-mark-warn { background: var(--warning, var(--accent)); opacity: 1; }
  /* .media-thumb-duration 搬去了 recipes.css —— <dev-audio-thumb> 的 shadow root
     要画同一颗角标,而 shadow 读不到 system.css。页面侧输出不变(build.mjs 把
     recipes.css 拼进 /v3/system.css)。 */

  /* Compact media editor slot used wherever one immutable image/video input
     owns upload, generate, edit, and history actions. This is deliberately a
     generic role: channels provide the media and commands, lib owns layout,
     overlay contrast, and narrow-container sizing. */
  .media-slot { display: flex; flex-direction: column; gap: var(--sp-2); inline-size: min(100%, var(--media-slot-width, 12.5rem)); flex: none; }
  .media-slot-preview { position: relative; display: grid; place-items: center; inline-size: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-sunken); }
  .media-slot-preview > img, .media-slot-preview > video { inline-size: 100%; block-size: 100%; object-fit: cover; }
  .media-overlay-action { position: absolute; inset-block-start: var(--sp-1); inset-inline-start: var(--sp-1); z-index: var(--z-raised); display: inline-flex; align-items: center; justify-content: center; inline-size: var(--control-h-sm); block-size: var(--control-h-sm); padding: 0; border: 0; border-radius: var(--radius-sm); background: rgba(0,0,0,0.55); color: #fff; cursor: pointer; }
  .media-overlay-action:hover { background: rgba(0,0,0,0.76); }
  .media-overlay-action:focus-visible { outline: 2px solid transparent; box-shadow: 0 0 0 2px #fff; }
  .media-progress-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); background: rgba(0,0,0,0.55); color: #fff; }
  .media-progress-overlay > .spinner { border-color: rgba(255,255,255,0.35); border-right-color: #fff; }
  .media-preview-action { position: absolute; inset: 0; display: grid; place-items: center; inline-size: 100%; block-size: 100%; padding: 0; border: 0; background: rgba(0,0,0,0.22); color: #fff; cursor: pointer; transition: background var(--duration-fast) var(--ease-standard); }
  .media-preview-action:hover { background: rgba(0,0,0,0.45); }
  .media-preview-action:focus-visible { outline: 2px solid transparent; box-shadow: inset 0 0 0 3px #fff; }

  /* Fill-height card — same surface as .card but stretches in a flex/grid row
     (dev-card's shadow body is content-height and cannot). */
  .card-stretch { display: flex; flex-direction: column; min-block-size: 0; background: var(--card); color: var(--card-foreground, var(--foreground)); border: 1px solid var(--border); border-radius: var(--c-card-radius); box-shadow: var(--c-card-shadow); overflow: hidden; }
  .card.card-selected { background: var(--ui-accent-soft); }

  /* Conversation messages are generic content roles. Compose with .card and
     the type/spacing utilities; the channel supplies only message content. */
  .chat-message { inline-size: min(82%, 42rem); }
  .chat-message-user { align-self: flex-end; background: var(--state-selected); }
  .chat-message-assistant { align-self: flex-start; }

  /* Conversation workbench bubbles — accent-tonal user speech with a keyed
     corner, a square AI mark, and entity reference chips. Skin decides the
     hues (paper: amber speech / teal references). */
  .chat-bubble-user { max-inline-size: 78%; align-self: flex-end; background: color-mix(in oklab, var(--p-accent) 16%, var(--surface-raised)); border: 1px solid color-mix(in oklab, var(--p-accent) 34%, var(--surface-raised)); color: color-mix(in oklab, var(--p-accent) 28%, var(--foreground)); padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-xl) var(--radius-xl) 0 var(--radius-xl); line-height: var(--leading-normal); white-space: pre-wrap; font-size: var(--text-body); }
  .chat-avatar-mark { display: inline-flex; align-items: center; justify-content: center; inline-size: 1.75rem; block-size: 1.75rem; flex: 0 0 1.75rem; border-radius: var(--radius-sm); background: linear-gradient(135deg, var(--p-gray-7), var(--p-gray-5)); border: 1px solid var(--border-strong); color: var(--p-accent); font-size: var(--text-micro); font-weight: var(--weight-bold); }
  .chip-ref { background: color-mix(in oklab, var(--chart-2) 13%, var(--surface-raised)); border-color: color-mix(in oklab, var(--chart-2) 36%, var(--surface-raised)); color: color-mix(in oklab, var(--chart-2) 72%, var(--foreground)); }
  .chip-ref:hover { background: color-mix(in oklab, var(--chart-2) 22%, var(--surface-raised)); }
  /* Conversation typing indicator — three pulsing accent dots. */
  .typing-dots { display: inline-flex; align-items: center; gap: 5px; padding-block: var(--sp-2); }
  .typing-dots > span { inline-size: 7px; block-size: 7px; border-radius: var(--radius-full); background: var(--p-accent); animation: lib-dot-pulse 1.2s infinite; }
  .typing-dots > span:nth-child(2) { animation-delay: 0.2s; }
  .typing-dots > span:nth-child(3) { animation-delay: 0.4s; }
  @keyframes lib-dot-pulse { 0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); } 40% { opacity: 1; transform: none; } }
  /* Chat body copy breathes looser than tool-grade text. */
  .leading-chat { line-height: 1.8; }
  /* Identity swatches — small colored initial squares for list identity. */
  .swatch { display: inline-flex; align-items: center; justify-content: center; inline-size: 1.625rem; block-size: 1.625rem; flex: 0 0 1.625rem; border-radius: var(--radius-sm); color: var(--primary-foreground); font-size: var(--text-micro); font-weight: var(--weight-bold); }
  .swatch-1 { background: var(--chart-1); } .swatch-2 { background: var(--chart-2); } .swatch-3 { background: var(--chart-3); } .swatch-4 { background: var(--chart-4); } .swatch-5 { background: var(--chart-5); }

  /* Structured editable document: one framed surface with selectable blocks
     and seam-free shared editors. Product channels provide only content. */
  .document-surface { margin: 0 var(--sp-3) var(--sp-3); overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); }
  .document-block { position: relative; overflow: hidden; border-block-end: 1px solid var(--border); background: var(--surface-raised); transition: background var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
  .document-block:last-child { border-block-end: 0; }
  .document-block.is-selected { background: color-mix(in srgb, var(--brand) 6%, var(--surface-raised)); box-shadow: inset var(--sp-1) 0 0 var(--brand); }
  .document-block:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .editor-seamless { inline-size: 100%; margin: 0; }
  .editor-seamless::part(box) { border-inline: 0; border-radius: 0; background: transparent; }

  /* <dev-captcha> — light-DOM host for the vendored ALTCHA widget. ALTCHA
     injects its stylesheet into document.head, which cannot cross a shadow
     boundary, so the widget MUST live in light DOM; these rules map the
     design tokens onto ALTCHA's theming variables (light/dark follow the
     host palette automatically). min-block-size reserves the widget's
     resting height so the form doesn't jump when the lazy vendor bundle
     arrives. */
  dev-captcha { display: block; min-block-size: 52px; }
  dev-captcha altcha-widget {
    --altcha-color-base: var(--card);
    --altcha-color-base-content: var(--card-foreground, var(--foreground));
    --altcha-color-neutral: var(--muted-foreground);
    --altcha-color-neutral-content: var(--card);
    --altcha-color-primary: var(--primary);
    --altcha-color-primary-content: var(--primary-foreground);
    --altcha-color-error: var(--danger);
    --altcha-color-success: var(--success);
    --altcha-border-color: var(--border);
    --altcha-border-radius: var(--radius);
    --altcha-input-background-color: var(--background);
    --altcha-input-color: var(--foreground);
    --altcha-max-width: 100%;
  }
}

/* ============================================================
   控件状态层 —— 与控件无关的一层(2026-08-15)
   ============================================================
   operator「我们不是靠 spec 限制样式,而是靠 design system」。

   病根实测:焦点环 33 处各写一遍、禁用态 21 处、选中态 31 处。没有一层是
   系统级的 —— 于是新控件作者不补就没有(实测 `.picker-option` 的禁用态
   opacity 就是 1,压根没有),补了也可能和别人不一样。

   机制:`:where()` 特异性为 0 —— 任何控件自己写过的规则照样赢,所以这是
   **纯加法**,只在「这个控件没定义过这个状态」时生效。新控件只要加进下面
   那份名册,四个状态默认就对了。

   为什么不把「选中」也做成全局一条:试过,出过事 —— 任何 chip 上的
   `aria-pressed` 被染成琢珀选中,而 xshort 主控条那里的选中长相是 `.chip-brand`。
   一个属性在全站有多种意思时,lib 不能替所有人把它画死。选中改成「每个控件
   接受同一组写法、各自保留自己的外观」(见各控件处的选择器)。

   名册的一致性由 tests/control-state-contract.test.mjs 钉死。 */
@layer lib.components {
  /* 焦点环 —— 键盘用户看得见自己在哪里,不靠每个控件各记一次。 */
  :where(.control, .btn, .chip-clickable, .input, .textarea, select.input,
         .segmented > button, .segmented > label, .picker-option, .menu-item,
         .cmd-item, .list-row, .media-card, .asset-leaf, .state-switch):focus-visible {
    outline: var(--focus-outline); outline-offset: var(--focus-outline-offset);
  }

  /* 禁用 —— 「这个动作现在不能做」。 */
  :where(.control, .btn, .chip-clickable, .input, .textarea, select.input,
         .segmented > button, .segmented > label, .picker-option, .menu-item,
         .cmd-item, .asset-leaf, .state-switch):disabled,
  :where(.control, .btn, .chip-clickable, .input, .textarea, select.input,
         .segmented > button, .segmented > label, .picker-option, .menu-item,
         .cmd-item, .asset-leaf, .state-switch)[aria-disabled="true"] {
    opacity: var(--state-disabled-opacity); cursor: not-allowed;
  }

  /* 没货 —— 不是禁掉一个动作,是这个**取值**此刻没供应:留在原位划掉,
     人才知道「有这一档,只是现在选不了」。这一条一度只在 `.segmented` 里有
     (2026-08-15 手补),现在是全名册的。用 [data-unavailable] 而不是单靠
     :disabled —— 两件事要分得开(权限不够 vs 此刻无货)。 */
  :where(.control, .btn, .chip-clickable, .segmented > button, .segmented > label,
         .picker-option, .menu-item, .cmd-item)[data-unavailable] {
    text-decoration: line-through;
  }
}

/* Coarse-pointer target floor. Compact desktop controls keep their 26–40px
   visual rhythm; touch input gets the platform 44px spacing step without any
   product breakpoint or one-off override. */
@layer lib.components {
  @media (pointer: coarse) {
    /* .btn-bare belongs here too: it IS the tap target of a composite row (a
       project row's name area next to its row-actions). Without the floor it
       renders at its own 36px text height while the .btn siblings in the same
       row are 44 — i.e. the biggest target in the row is the smallest one. */
    :is(
      .btn, .btn-bare, .input, .textarea, select.input, .tab, .sidebar-item,
      .menu-item, .cmd-item, .pagination .page, .segmented > button,
      /* `> a` 也是一段:段可以是链接(跨页的那种切换器就该能中键/⌘点开),
         名册漏了它,那种切换器在触屏上停在 24px —— 而它长得和旁边的段一模一样。 */
      .segmented > label, .segmented > a, .stepper > button, .chip-clickable,
      .app-nav-toggle, .workspace-context-toggle, .workspace-context-close,
      .action-card, .media-overlay-action
    ) { min-block-size: var(--sp-11); }
    /* Width matters as much as height for a two-glyph chip: 文字/图片/视频 come out
       40px wide and sit 4px apart, which is the miss-tap geometry. */
    .btn-icon, .pagination .page, .media-overlay-action, .chip-clickable { min-inline-size: var(--sp-11); }
    /* A short `.btn-bare` (a price like "$0.20" next to its button) came out 30px wide on
       phones — tall enough, too narrow. Same floor on width; `min` only, so long ones
       (a project row's name area) are untouched. */
    .btn-bare { min-inline-size: var(--sp-11); }
  }
}

/* ===== vP4 — container-query responsive (no JIT, no viewport breakpoints).
   Opt in with .cq on a wrapper; descendants use md-/lg-prefixed classes that
   react to the wrapper's width, not the viewport. ===== */
.cq { container-type: inline-size; }
@container (min-width: 30rem) {
  .md-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .md-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .md-row { flex-direction: row; }
  .md-flex { display: flex; }
  .md-hidden { display: none; }
  .md-block { display: block; }
}
/* `.md-only` is the exact mirror of `.md-hidden`: present only once the
   container is wide enough. A responsive toolbar needs BOTH halves — the wide
   inline row and the narrow overflow menu — and exactly one of them may exist
   at a time, in the accessibility tree as well as on screen. `.hidden` can't
   play this role (it is `!important`, so no `md-` rule can bring the element
   back), and a bare `display:none` on the product side is channel CSS. */
@container (width < 30rem) {
  .md-only { display: none; }
}
@container (width < 48rem) {
  .lg-only { display: none; }
}
@container (min-width: 48rem) {
  .lg-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .lg-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .lg-hidden { display: none; }
}

/* ── 状态点 / 24 格健康条 ──────────────────────────────────────────────
   监控类页面共用的两个原子。放在 lib 而不是某个产品里:图片监控、渠道监控、
   节点健康问的是同一件事(「现在什么状态」「这一天什么形状」),各写一份的结果
   是同一个绿在三个页面上是三个绿。 */

.status-dot {
  inline-size: .5rem; block-size: .5rem; border-radius: 99px;
  display: inline-block; flex: none;
  background: var(--muted-foreground);
}
.status-dot-ok { background: var(--success); }
.status-dot-degraded { background: var(--warning); }
.status-dot-fail { background: var(--destructive); }
/* 「没测过」刻意不是绿色 —— 把未验证渲染成健康是监控页最容易犯的谎。 */
.status-dot-idle { background: var(--muted-foreground); opacity: .45; }

.bar24 { display: flex; gap: 2px; align-items: center; }
.bar24 > i {
  inline-size: 5px; block-size: 16px; border-radius: 1px;
  background: var(--success);
}
.bar24 > i.bar24-warn { background: var(--warning); }
.bar24 > i.bar24-bad { background: var(--destructive); }
/* 没流量的那一格留成中性色而不是消失 —— 压缩掉空档,一段断服在时间轴上会
   被挤没,而「什么时候开始坏的」正是看这条的唯一理由。 */
.bar24 > i.bar24-none { background: var(--border); }
/* 一格带上 `data-cell-detail` = 它身上真挂着一段详情、点得开。**光标跟着这个属性走**,
   不给整条 `.bar24` —— 只有悬停 title 的那些条点了不会有任何反应,把它们也画成可点
   就是一句假话。触屏与 Firefox 没有悬停,点开是那两处唯一的读法。 */
.bar24 > i[data-cell-detail] { cursor: pointer; }

/* ── 迷你柱(sparkbar)────────────────────────────────────────────────
   一段时间序列的**量 + 其中坏的那部分**,压在一行高度里。`.bar24` 回答「每一格
   是好是坏」(等高、看颜色),这一条回答「每一格有多少、其中坏了多少」(看高度)——
   两件事,所以是两个件。首个落地面是 AI网关图片渠道管理顶上的近 24 小时。

   用法:`<span class="sparkbar"><i style="--v:.75"><b style="--v:.65"></b></i>…</span>`
   —— `--v` 是 0..1 的占比,外层 `i` 是总量、内层 `b` 是其中失败的那部分(从底部长上来)。

   **有量的格子有下限高度**:一个只有 3 单的小时按比例算不到一个像素,画出来是空的,
   而「这一小时有没有人用」与「这一小时没人用」是两个答案。零量的格子才真的空。 */
.sparkbar { display: flex; align-items: flex-end; gap: 2px; block-size: 2.5rem; }
.sparkbar > i {
  flex: 1; min-inline-size: 3px; position: relative; display: block;
  block-size: max(2px, calc(var(--v, 0) * 100%));
  border-radius: 1px 1px 0 0; background: var(--success-border);
}
/* 零量的格子:1px 的中性线。**不是不画** —— 不画就没有那一格,时间轴会被压缩。 */
.sparkbar > i.sparkbar-empty { block-size: 1px; background: var(--border); }
/* 矮一档:压在一段历史表格上方的趋势条(评测渠道页),不抢表格的视线。 */
.sparkbar.sparkbar-sm { block-size: 1.75rem; }
.sparkbar > i > b {
  position: absolute; inset-inline: 0; inset-block-end: 0; display: block;
  block-size: calc(var(--v, 0) * 100%);
  border-radius: 1px 1px 0 0; background: var(--destructive);
}
/* 轴标签:两端 + 中间,与柱同宽。写清楚窗口的两头,免得有人把它当成「最近一小时」。 */
.sparkbar-axis {
  display: flex; justify-content: space-between; gap: var(--sp-2);
  font-size: var(--text-micro); color: var(--muted-foreground);
}

/* ── 占比条 .sharebar(2026-10-06,随 xdata 产出页沉淀进 lib)────────────
   一根横条按份额切成几段(S / A / B / C 档、证据来源…),段宽 = `--share`(0..1,与 .sparkbar 的
   --v 同一口径),颜色按段序取图表色 --chart-1…6;下面的 .sharebar-legend 按同一顺序标名字和数。
   非零份额至少 2px —— 「还剩几条」不能和「一条没有」长得一样(与 .funnel 同一条规矩)。
   各段之间留一道 1px 的卡片色缝,相邻两段颜色接近时也分得开。 */
.sharebar { display: flex; block-size: 0.5rem; overflow: hidden; border-radius: var(--radius-full); background: var(--border); }
.sharebar > i { display: block; flex: none; inline-size: max(min(2px, var(--share, 0) * 100000px), var(--share, 0) * 100%); background: var(--sharebar-color, var(--chart-1)); }
.sharebar > i + i { box-shadow: inset 1px 0 0 var(--card); }
.sharebar > i:nth-child(2), .sharebar-legend > span:nth-child(2) { --sharebar-color: var(--chart-2); }
.sharebar > i:nth-child(3), .sharebar-legend > span:nth-child(3) { --sharebar-color: var(--chart-3); }
.sharebar > i:nth-child(4), .sharebar-legend > span:nth-child(4) { --sharebar-color: var(--chart-4); }
.sharebar > i:nth-child(5), .sharebar-legend > span:nth-child(5) { --sharebar-color: var(--chart-5); }
.sharebar > i:nth-child(6), .sharebar-legend > span:nth-child(6) { --sharebar-color: var(--chart-6); }
.sharebar-legend { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); font-size: var(--text-micro); color: var(--muted-foreground); }
.sharebar-legend > span { display: inline-flex; align-items: center; gap: var(--sp-1); }
.sharebar-legend > span::before { content: ""; inline-size: 0.5rem; block-size: 0.5rem; border-radius: var(--radius-sm); background: var(--sharebar-color, var(--chart-1)); }
.sharebar-legend b { font-weight: var(--weight-semibold); color: var(--foreground); font-variant-numeric: tabular-nums; }

/* ── 结论卡 .insight(2026-10-21)────────────────────────────────────────
   「谁、什么时候、下了什么结论、每条凭什么」的一张卡:头部写来源与时间,一句话结论,
   下面逐条列发现(状态点 + 标题 + 证据 + 建议)。AI 诊断、体检报告、巡检结论都是这个形状。
   只有结论没有条目 = 「一切正常 / 暂无发现」的空态,照样成立,不另造件。
   加载态直接在卡里放 `.skeleton`(头一条、结论一条),不要换一张别的卡。
   用法:`<section class="insight"><div class="insight-head">来源 · 时间</div><p class="insight-verdict">一句话</p>
   <ul class="insight-items"><li class="insight-item"><span class="status-dot status-dot-fail"></span>
   <div class="insight-body"><span class="insight-title">…</span><span class="insight-evidence">…</span>
   <span class="insight-advice">…</span></div></li></ul></section>` */
.insight { display: grid; gap: var(--sp-3); padding: var(--c-card-pad); border: 1px solid var(--border); border-radius: var(--c-card-radius); background: var(--card); }
.insight-head { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-micro); color: var(--muted-foreground); }
.insight-verdict { margin: 0; font-size: var(--text-body); font-weight: var(--weight-semibold); color: var(--foreground); text-wrap: pretty; }
.insight-items { display: grid; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; }
.insight-item { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: start; padding-block-start: var(--sp-2); border-block-start: 1px solid var(--border); }
/* 状态点与标题首行对齐(点本身 .5rem,标题行高大于它)。 */
.insight-item > .status-dot { margin-block-start: 0.3em; }
.insight-body { display: grid; gap: var(--sp-1); min-inline-size: 0; }
.insight-title { font-weight: var(--weight-medium); color: var(--foreground); }
.insight-evidence { font-size: var(--text-caption); color: var(--muted-foreground); }
.insight-advice { font-size: var(--text-caption); color: var(--foreground); }

/* ── 状态开关 ──────────────────────────────────────────────────────────
   一颗胶囊**既是「现在是什么状态」也是「点一下就切」**。首个落地面是图片监控的
   状态列(渠道启停 / 模型上下架),形状却是通用的:渠道监控、节点健康、任何
   「一行一个实体,这一列写它开着还是关着」的表问的都是同一件事。

   进 lib 而不是各页自己拼,是因为「一个状态标签 + 一颗动作按钮」这种两件套会
   自相矛盾 —— 图片监控上一版就在同一格里出现过「启用」配「已自动停用」两个说法。
   一颗胶囊只有一个真相:文字写现状,`title` 写点下去会发生什么。

   三条交互规矩,写在这里免得下一个人「优化」掉:
   - **可点的悬停只加深边框,宽度绝不变**。悬停换文案(「启用」→「点击停用」)
     会让整列跟着抖,鼠标扫过一遍表格就是一次全表重排。
   - **动作与解释一律进 `title`**,不占第二行 —— 行高一涨,一屏能看见的对象就
     少一批,而这是一张用来扫的表。
   - **状态不是按钮时不许伪装成按钮**:派生的、点了不会有任何改变的状态用
     `<span class="state-switch state-switch-static">`,不用 `<button>`。一颗点了
     没反应的按钮比不给按钮更糟。

   用法:`<button class="state-switch state-switch-on" title="点击停用"><i></i>启用</button>`
   —— `<i>` 是状态点,与 `.status-dot` 同一套色觉(success / 琥珀 / 中性),小一号。 */
.state-switch {
  --state-switch-border: var(--border);
  --state-switch-border-hover: var(--border-hover);
  --state-switch-dot: var(--muted-foreground);
  display: inline-flex; align-items: center; gap: 5px;
  /* **高度走梯子,不写字面量**(2026-09-17):原来是写死的 22px,而 lib 的控件
     高度是 30 / 28 / 26 三档 —— 22 不在任何一档上,于是同一个表格行里「状态」这颗
     胶囊比旁边的 btn-sm 矮 4px,两个控件并排就是错位的。字号同理跟 btn-sm 对齐。 */
  block-size: var(--control-h-sm); padding: 0 var(--sp-2);
  border: 1px solid var(--state-switch-border); border-radius: var(--radius);
  background: var(--card); color: var(--foreground);
  font-size: var(--text-micro); line-height: 1; white-space: nowrap;
  cursor: pointer;
}
.state-switch > i {
  inline-size: 6px; block-size: 6px; border-radius: var(--radius-full);
  flex: none; background: var(--state-switch-dot);
}
/* 悬停只动边框的颜色 —— 尺寸 / 内距 / 字重 / 文案一个都不许变(见上)。 */
.state-switch:not(.state-switch-static):hover { border-color: var(--state-switch-border-hover); }
.state-switch:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }

/* 在售 / 启用。 */
.state-switch-on {
  --state-switch-border: var(--success-border);
  --state-switch-border-hover: var(--success);
  --state-switch-dot: var(--success);
  background: var(--success-bg);
}
/* 琥珀 = **非常态,要人知道**:被策略自动停用的渠道、一条活渠道都没有 / 一行价
   都没配的模型。它和「人自己关掉的」长得一样的话,运维会守着一条永远不会自己
   回来的通路等它自愈。(默认那身中性皮才是「人关的」。) */
.state-switch-warn {
  --state-switch-border: var(--warning-border);
  --state-switch-border-hover: var(--warning);
  --state-switch-dot: var(--warning);
  background: var(--warning-bg);
}
/* 只是一句陈述,不是开关。配 `<span>` 用(见上);光标不变手型、悬停不反应,
   免得读的人以为它点得动。 */
.state-switch-static { cursor: default; }
/* 表单行里的那一颗:与 `.input-sm` 同高(28px)。
   缺省 22px 是给表格行的(那里它挨着的是文字);放进 `.form-field-row` 时旁边是
   输入框,矮 6px 一眼看得出没对齐 —— 而「与相邻输入框同高」是可以像素验证的判据
   (one/CLAUDE.md §3.3)。只加一个尺寸修饰,颜色/圆点/确认态全部照旧。 */
.state-switch-field { block-size: 28px; padding-inline: var(--sp-3); }
/* 危险方向的第二段:点一下先变成「确认下架?」,再点才执行 —— 超时还原由页面
   自己计时(那是业务节奏,不是样式)。 */
.state-switch-confirm {
  --state-switch-border: var(--danger-border);
  --state-switch-border-hover: var(--danger);
  --state-switch-dot: var(--danger);
  background: var(--danger-bg); color: var(--danger-text);
}
/* 写在飞:点不动 + 状态点换成转圈。这一格会宽 2px(8px 的圈比 6px 的点大),
   与悬停那条铁律不冲突 —— 悬停是鼠标扫过整列的高频动作,而这只发生在人自己
   点下去之后的那一两秒。转圈本身**不随 reduced-motion 停掉**,只降速:一个
   冻住的忙态圈让人分不清「在跑」和「挂了」(降速名单在 recipes.css)。 */
.state-switch-busy { opacity: .45; pointer-events: none; }
.state-switch-busy > i {
  inline-size: 8px; block-size: 8px; background: none;
  border: 1.5px solid var(--border); border-right-color: var(--foreground);
  animation: lib-spin 0.7s linear infinite;
}

/* recipes.css — 可注入 shadow DOM 的共享配方。
 *
 * 起因:shadow DOM 拿不到 system.css,于是每个 <dev-*> 组件把 .btn / .card /
 * 表头这些配方**各抄一份**,抄的过程中丢交互态、丢几何、词表还对不上 ——
 * 逐组件评审里条目最多的一条根因就是它。
 *
 * 这份文件是**唯一来源**:
 *   - 页面侧:build.mjs 把它拼进 /v3/system.css(消费方看到的仍是同一个文件)
 *   - 组件侧:esbuild 以 text 形式内联,组件用 adoptRecipes(shadowRoot) 采纳
 * 两侧吃的是同一段字节,所以不会再漂。
 *
 * 只有「组件内部也需要的」配方放这里。页面独有的排版(.page-pad、.app-shell…)
 * 留在 system.css。 */

@layer lib.components {
  /* ---- Buttons ---- */
  /* Default button = NEUTRAL OUTLINE (reference-console discipline: hue is
     reserved for the primary CTA + selected states; every other button stays
     quiet so a screen full of actions doesn't read as a wall of color). */
  .btn {
    box-sizing: border-box;
    display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
    min-height: var(--c-btn-h); padding: 0 var(--c-btn-px);
    border: 1px solid var(--btn-outline-border); border-radius: var(--c-btn-radius);
    background: var(--btn-outline-bg); color: var(--btn-outline-fg);
    box-shadow: var(--elevation-1);
    font-family: inherit; font-size: var(--text-caption); font-weight: var(--weight-medium);
    line-height: 1.2; letter-spacing: var(--tracking-normal); white-space: nowrap;
    text-decoration: none; cursor: pointer;
    transition: transform 100ms var(--ease-out),
                box-shadow 120ms var(--ease-out),
                background-color 120ms var(--ease-smooth),
                border-color 120ms var(--ease-smooth),
                filter 120ms var(--ease-smooth);
  }
  .btn:not(:disabled):hover { background: var(--btn-outline-bg-hover); border-color: var(--btn-outline-border-hover); }
  /* Press = 1px sink (the reference press affordance), not a scale. */
  .btn:not(:disabled):active { transform: translateY(1px); box-shadow: none; }
  .btn:focus-visible { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); }
  .btn:disabled, .btn[aria-disabled="true"] { opacity: var(--state-disabled-opacity); cursor: not-allowed; }
  .btn[aria-busy="true"] { cursor: progress; color: transparent; position: relative; }
  .btn[aria-busy="true"]::after {
    content: ""; position: absolute; inset: 0; margin: auto;
    width: 1em; height: 1em; border-radius: 50%;
    border: 2px solid currentColor; border-right-color: transparent;
    color: var(--btn-outline-fg); animation: lib-spin 0.7s linear infinite;
  }
  /* Primary CTA — v3: the SOLID ACCENT fill (the skin's voice color; baseline
     paper = amber), per the 漫剧工坊 definitive design. ONE decisive action
     per surface carries it (send, save, generate); everything else stays a
     quiet outline/ghost so the screen doesn't shout. `.btn-accent` is kept as
     a verbatim ALIAS for v2-era markup. */
  .btn-primary, .btn-accent { background: var(--ui-accent); color: var(--ui-accent-fg); border: 1px solid transparent; font-weight: var(--weight-semibold); box-shadow: var(--elevation-1); }
  .btn-primary:not(:disabled):hover, .btn-accent:not(:disabled):hover { background: var(--ui-accent-hover); color: var(--ui-accent-fg); }
  .btn-primary:not(:disabled):active, .btn-accent:not(:disabled):active { transform: translateY(1px); box-shadow: none; }
  .btn-primary[aria-busy="true"]::after, .btn-accent[aria-busy="true"]::after { color: var(--ui-accent-fg); }
  /* Accent-outlined secondary action (save-as-asset, view). The LABEL takes
     --ui-accent-text, not --ui-accent: the latter is the FILL tone (it is
     tuned to carry white ON it, so as ink on a light canvas it only reached
     3.70:1). --ui-accent-text is the accent already mixed toward --foreground
     for exactly this job, and it inverts correctly in dark mode. Same reason
     hover keeps the text token instead of --ui-accent-hover (the darker FILL,
     which on a dark surface is accent-on-accent). */
  .btn-outline-accent { background: transparent; color: var(--ui-accent-text); border: 1px solid color-mix(in oklab, var(--ui-accent) 45%, var(--surface-raised)); }
  .btn-outline-accent:hover:not(:disabled) { background: color-mix(in oklab, var(--ui-accent) 14%, var(--surface-raised)); color: var(--ui-accent-text); }
  /* Secondary — the neutral OUTLINE sibling (v3: same family as the default
     .btn; hierarchy comes from the one solid primary vs everything bordered). */
  .btn-secondary { background: var(--btn-outline-bg); color: var(--btn-outline-fg); border: 1px solid var(--btn-outline-border); box-shadow: var(--elevation-1); }
  .btn-secondary:not(:disabled):hover { background: var(--btn-outline-bg-hover); border-color: var(--btn-outline-border-hover); }
  /* Ghost — the QUIET, neutral button (cancel / toolbar / icon). Neutral fg so
     it does NOT carry the brand-blue accent; only the solid/selected states do. */
  .btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--secondary-foreground); }
  .btn-ghost:not(:disabled):hover { background: var(--state-hover); border-color: transparent; box-shadow: none; color: var(--foreground); }
  .btn-ghost:not(:disabled):active { background: var(--state-pressed); transform: none; box-shadow: none; }
  /* Danger — SOFT destructive (tinted fill, no shouting border); the solid
     variant below stays for the truly terminal action.
     The label is --danger-TEXT, not --danger: this is a tonal button, so its
     ink sits on its own 12%/20% tint, exactly where the fill tone is unreadable
     (3.74:1 resting, 3.38:1 hover). Its neutral sibling .btn[aria-pressed] has
     always used the ink step (--btn-tonal-fg) for the same reason. */
  .btn-danger { background: var(--danger-bg); color: var(--danger-text); border-color: transparent; box-shadow: none; }
  .btn-danger:not(:disabled):hover { background: color-mix(in oklab, var(--danger) 20%, var(--surface)); border-color: transparent; }
  .btn-danger:focus-visible, .btn-danger-solid:focus-visible { outline: var(--focus-outline-danger); }
  .btn-danger-solid { background: var(--danger); color: var(--danger-foreground); border: 1px solid transparent; box-shadow: var(--shadow-card); }
  .btn-danger-solid:not(:disabled):hover { background: var(--danger); filter: brightness(0.95); }
  .btn-danger-solid:not(:disabled):active { filter: brightness(0.9); }
  .btn[aria-pressed="true"], .btn.is-selected { background: var(--btn-tonal-selected-bg); border-color: var(--btn-tonal-border-hover); color: var(--btn-tonal-fg); box-shadow: none; }
  .btn-primary[aria-pressed="true"], .btn-primary.is-selected,
  .btn-accent[aria-pressed="true"], .btn-accent.is-selected { background: var(--ui-accent-hover); border-color: transparent; color: var(--ui-accent-fg); }
  .btn-sm { min-height: var(--control-h-sm); padding: 0 0.625rem; font-size: var(--text-micro); }
  .btn-lg { min-height: var(--control-h-lg); padding: 0 var(--sp-4); font-size: var(--text-body); }
  .btn-icon { padding: 0; width: var(--c-btn-h); aspect-ratio: 1; }
  .btn-icon.btn-sm { width: var(--control-h-sm); }
  .btn-icon.btn-lg { width: var(--control-h-lg); }
  .btn-icon:not(:disabled):hover { transform: none; box-shadow: none; }

  /* ---- Cards ---- */
  .card { border: 1px solid var(--border); border-radius: var(--c-card-radius); background: var(--card); color: var(--card-foreground); box-shadow: var(--c-card-shadow); transition: border-color var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out), translate var(--duration-normal) var(--ease-out); }
  .card-pad { padding: var(--c-card-pad); }
  .card-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-block-end: 1px solid var(--border); background: var(--surface-sunken); }
  /* 卡头**接满宽表格**时加它:去掉沉底色与下边框,只留内距。表格的 thead 自己就是
     --surface-sunken,两条同色带叠在一起会糊成一整块高灰带,那道 1px 边框反而像把
     表头劈成两半。去掉之后,标题与表头之间 = 12(卡头下内距) + 8(thead 上内距) = 20px。
     **这才是 `card-pad pb-0` 的正解** —— 那个凑法把下内距归零,实测标题与表头 0px 贴死。
     选择器写成 `.card-header.card-header-plain`(而不是单类)是**刻意的**:单类与基类
     同特异性,只靠源码顺序取胜 —— 写在别的文件里就会被基类静默盖掉,而页面上看着
     只是「修饰符没生效」,没有任何报错。 */
  .card-header.card-header-plain { background: none; border-block-end: none; }
  .card-body { padding: var(--sp-4); }
  /* Interactive cards lift 1px on hover (translate, not transform — composes
     with any consumer transform) with a soft mid shadow; the old overlay-grade
     float shadow read as heavy next to the reference consoles. */
  .card-hover:hover { border-color: var(--border-strong); box-shadow: var(--elevation-2); translate: 0 -1px; }
  /* v3: the active card is ringed in the accent, not the neutral gray ring. */
  .card-active { border-color: var(--ui-accent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--ui-accent) 26%, transparent); }

  /* ---- 主对话框 (composer) ----
     全平台只有一个「说点什么」的盒子:AI对话、AI短剧、以后任何频道都用这一份。
     形状由 lib 说了算,频道只往里放内容 —— 每个频道自己画一个盒子,就是 operator
     看到的「样式还是不一样」。

       <div class="composer">
         <div class="composer-lead">…chips / 附件…</div>        (可选,排在最上)
         <textarea class="composer-input">                      (或任意输入组件)
         <div class="composer-toolbar">
           <div class="composer-toolbar-group">…模式 / 模型 / 参数…</div>
           <div class="composer-toolbar-group">…引用 / 发送…</div>
         </div>
       </div>

     盒子不投影、不给自己内边距:内边距在输入区和工具条上,这样输入区的可点区域
     一直顶到盒子边缘 —— 点“空白”也能开始打字。 */
  .composer { display: flex; flex-direction: column; inline-size: 100%; min-inline-size: 0; background: var(--c-composer-bg); border: 1px solid var(--border); border-radius: var(--c-composer-radius); transition: border-color var(--duration-fast) var(--ease-standard); }
  .composer:focus-within { border-color: var(--ring); }
  .composer-lead { order: -1; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-4) 0; }
  /* 输入区:透明、无边框、不可拖拽改大小 —— 盒子才是那个控件。
     `field-sizing: content` 让它随内容长高到 max 后再滚(支持的浏览器上)。 */
  .composer-input { flex: 1 1 auto; min-block-size: var(--c-composer-min-h); max-block-size: var(--c-composer-max-h); padding: 14px var(--sp-4) 6px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--c-composer-font); line-height: var(--leading-body); resize: none; field-sizing: content; overflow: auto; }
  .composer-input:focus, .composer-input:focus-visible { outline: none; box-shadow: none; }
  .composer-input::placeholder { color: var(--muted-foreground); }
  /* 素材摆位(`.slot-row`)排在输入区上方时,要跟盒子里其它东西一条竖线对齐:
     它自己不带内边距,于是方位贴着盒子左边缘,而模式条与正文缩进 16px —— 一眼
     就看出来是两套(2026-08-28 量出来的)。 */
  .composer > .slot-row { padding: var(--sp-2) var(--sp-4) 0; }
  .composer-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3) var(--sp-3); }
  .composer-toolbar-group { display: flex; align-items: center; gap: var(--sp-1); min-inline-size: 0; }
  /* 发送键是圆的 —— 工具条上唯一一个实心圆,眼睛不用找。尺寸写死在 token 上:
     默认的图标按钮跟着 --c-btn-h 走(30px),比 AI对话 那颗小两像素,并排一看就是
     两个设计。 */
  .composer-send { border-radius: var(--radius-full); inline-size: var(--c-composer-send-size); block-size: var(--c-composer-send-size); min-block-size: var(--c-composer-send-size); padding: 0; }

  /* ---- 手机上的工具条 ----
     模式 + 模型 + 参数 + 引用 + 发送 一行放不下 390px。三种坏结局都真出现过:
     换行(工具条长一倍,把对话区顶出屏幕)、裁切(最后一个控件静默消失)、
     子项被压扁成竖排(2026-08-18 实测:模式按钮竖排盖住了右边整栏)。
     这里选第四种:**整条横向滚动,控件保持原尺寸**,发送键 sticky 钉在行尾,
     拇指永远够得着。 */
  @media (max-width: 48rem) {
    .composer-toolbar { flex-wrap: nowrap; gap: var(--sp-2); }
    /* 滚动条挂在**第一组**上,不是整条工具条:钉住(sticky)的发送键会把滚过它
       下面的控件盖死,谁都点不到。让第一组自己滚、发送组占住自己的位置,
       两边就永远不相交。 */
    .composer-toolbar > .composer-toolbar-group:first-child {
      flex: 1; min-inline-size: 0; flex-wrap: nowrap;
      overflow-x: auto; overflow-y: hidden;
      scrollbar-width: none; overscroll-behavior-inline: contain;
    }
    .composer-toolbar > .composer-toolbar-group:first-child::-webkit-scrollbar { display: none; }
    .composer-toolbar > .composer-toolbar-group:last-child { flex: none; flex-wrap: nowrap; }
    /* 分段控件在窄屏必须横着 —— 它是「三选一」,竖排既占高又会盖住下面的东西。 */
    .composer-toolbar .segmented { flex-direction: row; flex: none; }
  }

  /* ---- 缩略图右下角的时长角标 ----
     视频缩略图和声音缩略图报的是同一件事(这段有多长),所以只能有一颗。
     它在共享配方里而不是 system.css:`<dev-audio-thumb>` 在 shadow DOM 里画
     同一颗角标,而 shadow 读不到页面的 system.css —— 留在那边就只能让组件再抄
     一份,而抄出来的那份迟早与页面上的这颗不一样。 */
  .media-thumb-duration {
    position: absolute; inset-block-end: var(--sp-1); inset-inline-end: var(--sp-1);
    padding: 0 var(--sp-1); border-radius: var(--radius-sm);
    background: color-mix(in oklab, var(--foreground) 75%, transparent);
    color: var(--background); font-size: var(--text-micro);
  }
}

@layer lib.utilities {
  /* ---- Scrollbar(opt-in) ----
     放在共享配方里,组件采纳后就能用同一条细滚动条 —— 否则 shadow root 里够不着
     system.css,每个有滚动区的组件都露一条系统默认的粗条。 */
  .scroll-thin { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
  .scroll-thin::-webkit-scrollbar { inline-size: 6px; block-size: 6px; }
  .scroll-thin::-webkit-scrollbar-track { background: transparent; }
  .scroll-thin::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-full); }
  .scroll-thin::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
}

@layer lib.reset {
  /* ONE global reduced-motion guard — neutralizes lib motion DURATION for users
     who ask for it (a11y). Leaves end-states intact so layout doesn't break. */
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
    }
    /* 但**状态指示性**的动效不能一起冻住:转圈、不定进度、忙态圆环停下来之后,
       页面看起来就是卡死的 —— 用户分不清「在跑」和「挂了」。这类降速循环、
       不取消。装饰性动效才是这条守卫要压的东西。 */
    .spinner, .progress-indeterminate, .progress-indeterminate::after,
    .btn[aria-busy="true"]::after, .skeleton, .skeleton-text,
    .state-switch-busy > i {
      animation-duration: 1.6s !important;
      animation-iteration-count: infinite !important;
    }
  }
}

@layer lib.reset {
  /* ---- 选中文字 / 原生控件配色 ----
     两件都是「不设就退回浏览器默认」的东西,而且都跨不进 shadow DOM(::selection
     不继承;组件内部的原生控件也读不到页面那层的设置)。放在共享配方里,页面与
     组件吃同一份。
     · 选中态:默认是操作系统的蓝色高亮,压在这套暖纸配色上格格不入。
       用 28% 强调色混纸色 —— 量过正文墨色在上面对比度 10.09,读起来毫无压力。
     · accent-color:复选框 / 单选 / 进度条不设就是系统蓝(macOS),与产品强调色不一致。 */
  :where(:root, :host) { accent-color: var(--ui-accent); }
  ::selection { background: color-mix(in oklab, var(--ui-accent) 28%, var(--surface-raised)); color: var(--foreground); }
}

/* Offline document layout. All atoms and tokens come from the live v3 sources. */
@layer lib.components {
  .report-document { padding: var(--sp-6); overflow-wrap: anywhere; }
  .report-content { max-inline-size: 72rem; margin-inline: auto; min-inline-size: 0; }
  .report-content > * { min-inline-size: 0; }
  .report-header { border-block-end: 1px solid var(--border); padding-block-end: var(--sp-4); }
  .report-score { font-variant-numeric: tabular-nums; margin-inline-start: auto; }
  .report-score-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
  .report-score-label { flex: 1 1 12rem; min-inline-size: 0; }
  .report-score-bar { flex: 1 1 6rem; max-inline-size: 12rem; }
  .report-document .payload { max-block-size: none; }
  .report-preview { inline-size: 100%; block-size: 100%; min-block-size: 70vh; border: 0; background: var(--surface-raised); }
  @media (max-width: 30rem) {
    .report-document { padding: var(--sp-3); }
  }
  @media print {
    .report-document { padding: 0; background: var(--card); }
    .report-document .card { box-shadow: none; }
    .report-document details::details-content { content-visibility: visible !important; }
    .report-document details > :not(summary) { display: block !important; }
    .report-document .table-wrap { overflow: visible; }
  }
}
