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

  /* 减少动效的全局守卫搬去 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) 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: 1.6; 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: 1.4;
    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. */
  .card-accent-info    { border-inline-start: 3px solid var(--info); }
  .card-accent-warning { border-inline-start: 3px solid var(--warning); }
  .card-accent-success { border-inline-start: 3px solid var(--success); }
  .card-accent-danger  { border-inline-start: 3px solid var(--danger); }
  .card-accent-brand   { border-inline-start: 3px solid var(--brand); }

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

  /* ---- 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: 1.06rem; line-height: 1.6; 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: 1rem 1.15rem; font-family: var(--font-mono); font-size: .78rem; line-height: 1.65; 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: 1.6 !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); }
  .code { font-family: var(--font-mono); font-size: 0.95em; 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: 1.55;
    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); }
  .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; }
  .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-full);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; }
  .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; }
  /* 金额位:主币在前、副币跟在同一行的括号里(`¥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); }
    /* 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 var(--border); }
  .table thead th { font-size: var(--text-micro); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-wide); white-space: nowrap; color: var(--muted-foreground); background: var(--surface-sunken); border-block-end-color: var(--border-strong); }
  /* 表头是一个**字面标识**(分辨率 `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 tbody tr:hover { background: var(--state-hover); }
  .table-zebra tbody tr:nth-child(even) { background: color-mix(in oklab, var(--foreground) 3%, transparent); }
  .table tr[aria-selected="true"], .table tr.is-selected { background: var(--state-selected); }
  .table-sticky thead th { position: sticky; inset-block-start: 0; z-index: var(--z-raised); }
  /* **紧凑表**:一格里只有一个 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); }
  /* **合并单元格里的内容顶到上边**。`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; }
  /* 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. */
  .table-wrap { 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`,与正文里那批小写的协议名对不上。
     组名行**不铺 `surface-sunken`**:两行表头各一个底色会看成两条独立的横带,
     而它们本是同一个表头的上下两半。 */
  .table-grouped thead tr:first-child th { text-transform: none; letter-spacing: normal; font-weight: var(--weight-medium); text-align: center; background: transparent; border-block-end: none; padding-block-end: 0; }
  /* 组的第一列画一条竖线。**必须同时打在 `th` 与 `td` 上**(挂在 `col` 上不生效:
     `border` 不是 `<col>` 支持的属性之一),所以它是一个格子级的类。 */
  .table .cg { border-inline-start: 1px solid var(--border-strong); }

  /* **格子级语义**:一格的值相对某个基准是「一样 / 不一样 / 没有数据」。
     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-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 { 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; }

  /* 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 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); }
  .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: 1.35; }
  .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: 1.65; }
  /* **一个选项不是选择**:那一格是一句陈述,不是控件。但它仍要与同一列里的胶囊
     等高、文字压同一条左边缘 —— 否则一排参数里就它浮在行的上沿,而且比别人靠左
     (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-full); 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); }
  /* 引用身份色(设计稿帧 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; }
  .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; } }

  /* 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: 1.55; }
  /* 左右两栏原本各自堆行、各自滚动 —— 任何一行折行,从那里往下整篇就错位了。
     现在一行一对、共用同一条网格:.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: 1.1; 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: 1.1; 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); }
  /* 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-full); 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; }
  /* 缩略图撑满它所在的格子(默认是 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) calc(var(--radius) - 6px) var(--radius-xl); line-height: 1.8; 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); }
  }
}

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

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

   进 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;
  block-size: 22px; padding: 0 var(--sp-2);
  border: 1px solid var(--state-switch-border); border-radius: var(--radius-full);
  background: var(--card); color: var(--foreground);
  font-size: var(--text-caption); 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); }
}
