/* ===========================================================================
 * 镜相 · components
 * ---------------------------------------------------------------------------
 * 上半部分 = 从镜像 Demo `shared/styles.css` 逐条搬来的 Interaction Primitives
 *            （paper-card / ink-frame / gold-thread / chips / btn-*），
 *            数值一比一，只把硬编码色值换成 tokens.css 的变量。
 * 下半部分 = mirror-lite 四条路径（输码 / 量表库 / 答题 / 报告）新写的组件，
 *            全部只消费 token，不引入新色。
 * ========================================================================= */

/* ══════════════ Primitives（来自 Demo）══════════════════════════════════ */

/* Primitive #1 · paper-card
 * hover 浮起只在卡片**真正可点**时发生：<a>/<button> 自动启用，div+onclick
 * 要显式加 .paper-card--interactive。静态展示卡不许浮起——那是语义噪音。 */
.paper-card {
  background: var(--paper-bright);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-paper);
  transition: box-shadow .3s ease, transform .3s ease;
}
a.paper-card:hover, button.paper-card:hover, .paper-card--interactive:hover {
  box-shadow: var(--shadow-paper-lg);
}
.paper-card--flat { box-shadow: var(--shadow-paper-sm); }

/* Primitive #2 · ink-frame（深墨内线，取代 border 灰线）*/
.ink-frame { position: relative; }
.ink-frame::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; box-shadow: inset 0 0 0 1px rgba(14,16,24,.08);
}
.ink-frame--strong::after { box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.18); }

/* Primitive #3 · gold-thread（神秘金细线 · 付费语境）*/
.gold-thread { position: relative; }
.gold-thread::before {
  content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(197,163,88,.4), 0 0 20px rgba(197,163,88,.08);
}
.gold-thread--on::before {
  box-shadow: inset 0 0 0 1.5px rgba(197,163,88,.8), 0 0 30px rgba(197,163,88,.2);
}

/* Primitive #3b · sage-thread（免费语境）*/
.sage-thread { position: relative; }
.sage-thread::before {
  content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(122,160,129,.4), 0 0 20px rgba(122,160,129,.08);
}

/* Primitive #5 · chips */
.chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 4px 10px; font-size: .75rem; font-weight: 500;
  border-radius: var(--radius-full); white-space: nowrap;
}
.sage-chip   { background: var(--sage-mist);   color: var(--sage);        box-shadow: inset 0 0 0 1px rgba(122,160,129,.25); }
.mystic-chip { background: var(--mystic-mist); color: var(--mystic);      box-shadow: inset 0 0 0 1px rgba(197,163,88,.30); }
.rabbit-chip { background: var(--rabbit-mist); color: var(--rabbit-deep); box-shadow: inset 0 0 0 1px rgba(233,169,169,.30); }
.ink-chip    { background: var(--paper-dim);   color: var(--ink-mute);    box-shadow: inset 0 0 0 1px rgba(14,16,24,.07); }

/* Buttons */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 10px 20px; min-height: var(--tap-min);
  font-family: var(--font-headline); font-weight: 600; font-size: .9375rem;
  border-radius: var(--radius-full); border: none; cursor: pointer;
  /* transform 不在这里过渡 —— 交给文件末尾统一的「按压反馈」那一层，
   * 两处都写会打架（一个 .2s ease 一个弹性曲线，谁后加载谁赢）。 */
  transition: background .2s ease, box-shadow .2s ease, color .2s ease;
}
.btn-ink { background: var(--ink); color: var(--paper-bright); box-shadow: 0 4px 12px rgba(14,16,24,.18); }
.btn-ink:hover:not(:disabled) { background: var(--ink-deep); box-shadow: 0 8px 20px rgba(14,16,24,.24); }
.btn-gold { background: var(--gradient-gold); color: var(--ink-deep); box-shadow: 0 4px 14px rgba(197,163,88,.32); }
.btn-gold:hover:not(:disabled) { box-shadow: 0 8px 22px rgba(197,163,88,.42); }
.btn-sage { background: linear-gradient(135deg, var(--sage-soft) 0%, var(--sage) 100%); color: #14261B; box-shadow: 0 4px 14px rgba(122,160,129,.30); }
.btn-sage:hover:not(:disabled) { background: linear-gradient(135deg, var(--sage) 0%, var(--sage-deep) 100%); color: var(--paper-bright); box-shadow: 0 8px 22px rgba(122,160,129,.45); }
.btn-ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.15); }
.btn-ghost:hover:not(:disabled) { background: var(--paper-dim); box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.35); }
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: 0 2px 6px rgba(14,16,24,.12); }
.btn-sm    { padding: 6px 14px; font-size: .8125rem; min-height: 36px; }
.btn-lg    { padding: 14px 28px; font-size: 1rem; }
.btn-block { width: 100%; }

/* ══════════════ App shell ══════════════════════════════════════════════ */

.topbar {
  position: sticky; top: 0; z-index: 40;
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 1px 0 rgba(14,16,24,.05);
}
.topbar__inner { display: flex; align-items: center; justify-content: space-between; height: 56px; gap: var(--space-3); }
.brand { display: inline-flex; align-items: center; gap: 10px; }
.brand__mark {
  width: 34px; height: 34px; border-radius: var(--radius-full);
  background: var(--gradient-ink); box-shadow: var(--shadow-ink);
  display: grid; place-items: center;
  font-family: var(--font-display); font-size: 1.15rem; line-height: 1;
  color: var(--mystic);
}
.brand__name { font-family: var(--font-display); font-size: 1.125rem; letter-spacing: .04em; }
.brand__sub  { font-size: .625rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-faint); }

/* 底部安全区吸底 CTA —— 手册 §4.4：padding-bottom 必须叠 safe-area */
.sticky-cta {
  position: sticky; bottom: 0; z-index: 30;
  padding: var(--space-3) var(--space-5) calc(var(--space-3) + var(--safe-b));
  background: linear-gradient(to top, var(--paper) 62%, transparent);
}

/* ══════════════ 路径一 · 输码 ══════════════════════════════════════════ */

.hero { padding: var(--space-12) 0 var(--space-8); text-align: center; }
.hero__eyebrow { display: inline-flex; margin-bottom: var(--space-4); }
.hero__title { font-family: var(--font-display); font-weight: 600; }
.hero__sub { margin-top: var(--space-3); color: var(--ink-soft); }

.code-card { padding: var(--space-6) var(--space-5); }

/* 授权码输入框：等宽 + 字距拉开，用户一眼能数清位数。
 * inputmode/autocapitalize 等在 HTML 上，这里只管视觉。 */
.code-input {
  width: 100%; min-height: 52px;
  padding: 12px 14px;
  /* 16px 是 iOS 不触发聚焦缩放的下限（base.css 那条规则），所以字号不能再往下调；
   * 375px 窄屏上要塞下完整的 24 字符码，只能收紧字距 —— .12em 会把尾巴的 -C 挤掉。 */
  font-family: var(--font-mono); font-size: 1rem;
  letter-spacing: .04em; text-align: center; text-transform: uppercase;
  background: var(--paper-warm); color: var(--ink-deep);
  border: none; border-radius: var(--radius-lg);
  box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.10);
  transition: box-shadow .2s ease, background .2s ease;
}
.code-input::placeholder { color: var(--ink-faint); letter-spacing: .02em; }
.code-input:focus { outline: none; background: var(--paper-bright); box-shadow: inset 0 0 0 2px var(--mystic), var(--shadow-gold-glow); }
.code-input[aria-invalid="true"] { box-shadow: inset 0 0 0 2px var(--signal-error); }

.field-hint { font-size: .8125rem; color: var(--ink-mute); }
.field-error { font-size: .8125rem; color: var(--signal-error); font-weight: 500; }

/* ══════════════ 路径二 · 量表库 ════════════════════════════════════════ */

.filter-bar { display: flex; gap: var(--space-2); overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; }
.filter-bar::-webkit-scrollbar { display: none; }
.filter-pill {
  flex: 0 0 auto; padding: 7px 14px; min-height: 36px;
  border: none; border-radius: var(--radius-full); cursor: pointer;
  font-size: .8125rem; font-weight: 600;
  background: var(--paper-bright); color: var(--ink-mute);
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.08);
  transition: all .2s ease;
}
.filter-pill[aria-pressed="true"] { background: var(--ink); color: var(--paper-bright); box-shadow: var(--shadow-ink); }

.card-grid { display: grid; gap: var(--space-4); grid-template-columns: 1fr; }
@media (min-width: 40rem) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 60rem) { .card-grid { grid-template-columns: repeat(3, 1fr); } }

.test-card { display: flex; gap: var(--space-4); padding: var(--space-4); align-items: flex-start; }
.test-card__emoji {
  flex: 0 0 auto; width: 46px; height: 46px; border-radius: var(--radius-lg);
  display: grid; place-items: center; font-size: 1.5rem;
  background: var(--paper-warm);
}
.test-card__emoji--paid { background: var(--mystic-mist); }
.test-card__emoji--free { background: var(--sage-mist); }
.test-card__title { font-family: var(--font-headline); font-weight: 700; font-size: 1rem; }
.test-card__tagline {
  margin-top: 2px; font-size: .875rem; color: var(--ink-mute);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.test-card__meta { margin-top: var(--space-3); }

/* ══════════════ 路径三 · 答题（一屏一题）══════════════════════════════
 * 2026-08-25 重写。旧的这一段是从镜像 Demo 搬来的，写的是一套「假单选按钮」
 * （.likert__dot）和分段进度条（.progress__seg），而 quiz_body.php 渲染的是
 * **真 <input type=radio>** 和一条普通进度条 —— CSS 与标记从来没对上过
 * （组件总账 §7.1 记的那条）。这一版按实际标记重写，删掉空转的部分。
 * ═════════════════════════════════════════════════════════════════════ */

/* 进度：手册 §4.2「第 12 / 120 题」比无刻度进度条更让人安心 */
.progress { position: sticky; top: 56px; z-index: 20; padding: var(--space-3) 0 var(--space-2); background: var(--paper); }
.progress__meta { display: flex; justify-content: space-between; font-size: .75rem; color: var(--ink-mute); margin-bottom: 6px; }
.progress__track { height: 6px; border-radius: var(--radius-full); background: var(--paper-dim); overflow: hidden; }
.progress__fill  { height: 100%; border-radius: var(--radius-full); background: var(--gradient-ink); transition: width .35s cubic-bezier(.4,0,.2,1); }

.q-list { list-style: none; padding: 0; margin: 0; }

/* ---- 固定几何：题干长短不影响选项的位置 --------------------------------
 * 一道题就是一个**定高的两行网格**：上面一行是题干（占满剩余高度，题干在
 * 其中垂直居中），下面一行是选项。选项行的高度只由「几个选项」决定，
 * 而同一份量表里那是常数 —— 于是选项组的顶边 Y 在整场答题里纹丝不动。
 *
 * 定高取的是视口而不是「量最长的那道题」：不需要 JS 量一遍，任意长度的题干
 * 都成立，而且选项永远落在拇指够得到的下半屏。
 * 减掉的是顶栏 56px + 进度条约 50px + 翻页按钮约 68px + 呼吸空间。      */
.q-item {
  display: grid;
  grid-template-rows: 1fr auto;
  /* 组件内的局部变量，不是设计 token —— 减掉的是顶栏 56px + 进度条约 50px
   * + 翻页按钮约 68px + 呼吸空间。 */
  --quiz-chrome: 15rem;
  min-height: calc(100svh - var(--quiz-chrome));
  padding: var(--space-6) 0 var(--space-4);
}

/* 矮屏（iPhone SE 一代 568px 这类）。
 * 不收的话预留高度不够，最长的那几道题会把选项顶下去 —— 实测 320×568 上
 * 22 道题会出现 3 种不同的选项位置，「固定」就不成立了。
 * 收 chrome + 压扁选项行 + 缩小题干字号，把 22 道题重新拉回同一条线。 */
@media (max-height: 700px) {
  .q-item { --quiz-chrome: 9rem; padding-top: var(--space-4); }
  .q-item .question__text { font-size: clamp(1.125rem, 5vw, 1.5rem); }
  .likert__opt { min-height: 46px; padding: 8px 12px; }
  .likert { gap: 6px; }
}
/* 更矮的（iPhone SE 一代 320×568 这类）。再收一档，让最长的题也回到同一条线。
 * 这里 chrome 已经小于「顶栏 + 进度条 + 翻页按钮」的实际高度了，
 * 意味着这一档上翻页按钮要滚一点才看得全 —— 那是可接受的取舍：
 * **选项位置固定**比「一屏装得下」更重要，前者是每答一题都在体验的。 */
@media (max-height: 620px) {
  .q-item { --quiz-chrome: 8rem; }
}
.q-item__head {
  display: flex; flex-direction: column; justify-content: center;
  /* ⚠️ 这里**不能**写 min-height: 0。
   * 写了之后 1fr 行可以缩到比内容还小，小屏 + 长题干时题干会直接压到选项上面。
   * 保持默认的 auto 最小值：行永远不小于内容，实在放不下就整页往下长一点，
   * 用户滚一下 —— 那是降级，不是错乱。 */
}

.question__index { font-family: var(--font-mono); font-size: .75rem; color: var(--ink-faint); letter-spacing: .1em; }
/* JS 接管后进度条里已经有「第 N / M 题」，题干上再印一遍是重复。
 * 无 JS 时题目是全部堆在一页的，那时它是唯一的题号，必须留着。 */
.js-quiz .question__index { display: none; }

.question__text {
  margin-top: var(--space-3);
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(1.375rem, 5.5vw, 1.75rem); line-height: 1.45;
}

/* Likert：窄屏横排必挤 → 竖排整行可点，行高 ≥44px（手册 §4.2）*/
.likert { display: flex; flex-direction: column; gap: var(--space-2); }
.likert__opt {
  display: flex; align-items: center; gap: var(--space-3);
  width: 100%; min-height: 52px; padding: 11px 14px;
  text-align: left; cursor: pointer;
  border-radius: var(--radius-lg); background: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.08), var(--shadow-paper-sm);
  transition: box-shadow .18s ease, background .18s ease, transform .18s ease;
}
.likert__opt:hover { box-shadow: inset 0 0 0 1px rgba(14,16,24,.18), var(--shadow-paper); }

/* 真 radio 自己画成圆点。
 * ⚠️ 不藏起来用一个 <span> 假装 —— 原生 radio 自带键盘方向键切换、读屏播报、
 *    以及**无 JS 时的可用性**。appearance:none 只换外观，语义一个不丢。 */
.likert__radio {
  flex: 0 0 auto; width: 20px; height: 20px; margin: 0;
  appearance: none; -webkit-appearance: none;
  border: none; border-radius: var(--radius-full);
  background: var(--paper-bright);
  box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.22);
  transition: box-shadow .18s ease, background .18s ease;
  cursor: pointer;
}
.likert__radio:checked {
  background: var(--mystic);
  box-shadow: inset 0 0 0 3px var(--paper-bright), 0 0 0 1.5px var(--mystic);
}
.likert__label { font-size: .9375rem; font-weight: 500; }

/* 选中的整行高亮。:has() 不支持的老浏览器只是行不变色，
 * 圆点该亮还是亮 —— 降级是「少一层强调」，不是「看不出选了哪个」。 */
.likert__opt:has(input:checked) {
  background: var(--mystic-mist);
  box-shadow: inset 0 0 0 1.5px var(--mystic), var(--shadow-gold-glow);
}

/* 选完到翻页之间的那一下反馈（手册 §4.2 的 200–300ms）。
 * 方向跟着全站的按压语言走：**涨一下再落回**，不是缩一下。 */
.likert__opt--flash { animation: opt-flash .28s cubic-bezier(.22,1,.36,1); }
@keyframes opt-flash { 0% { transform: scale(1); } 40% { transform: scale(1.025); } 100% { transform: scale(1); } }

/* ---- 翻页过渡：渐出 → 换题 → 渐入 --------------------------------------
 * 只动内容，不动外框。.q-item 是定高的，所以整块不会因为换题而抖。
 * prefers-reduced-motion 由 base.css 全局压成 0.001ms，JS 那边也会直接跳过。 */
@keyframes q-out { from { opacity: 1; transform: none; }        to { opacity: 0; transform: translateY(-10px); } }
@keyframes q-in  { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.q-item--out .q-item__head, .q-item--out .likert { animation: q-out 160ms ease forwards; }
.q-item--in  .q-item__head, .q-item--in  .likert { animation: q-in 220ms cubic-bezier(.4,0,.2,1) backwards; }
/* 选项比题干慢一点点进场，读起来像是「题目先落下、选项跟上」 */
.q-item--in .likert { animation-delay: 60ms; }

.quiz-nav { display: flex; gap: var(--space-3); }
/* 断点续答提示条 */
.resume-bar {
  display: flex; align-items: center; gap: var(--space-3);
  padding: 10px var(--space-4); border-radius: var(--radius-lg);
  background: var(--sage-mist); color: var(--sage-deep);
  font-size: .8125rem; font-weight: 500;
  box-shadow: inset 0 0 0 1px rgba(122,160,129,.25);
}

/* 开始页 / 结束页：只是两个普通区块，给个上边距别贴着进度条 */
.quiz-intro, .quiz-outro { padding-top: var(--space-4); }

/* ══════════════ 路径四 · 报告 ══════════════════════════════════════════ */

.report-hero { padding: var(--space-8) 0 var(--space-6); }
/* 顶部三个保存动作——用户刚看完结果、情绪最高的时刻，转化最好（手册 §4.2）*/
.save-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-2); }
.save-action {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
  min-height: 62px; padding: 8px 4px; cursor: pointer; border: none;
  border-radius: var(--radius-lg); background: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.08), var(--shadow-paper-sm);
  font-size: .75rem; font-weight: 600; color: var(--ink-soft);
  transition: all .2s ease;
}
.save-action:hover { box-shadow: inset 0 0 0 1px rgba(14,16,24,.18), var(--shadow-paper); }
.save-action__icon { font-size: 1.125rem; line-height: 1; }
.save-action[data-state="done"] { background: var(--sage-mist); color: var(--sage-deep); box-shadow: inset 0 0 0 1px rgba(122,160,129,.3); }

/* 小屏默认横向条形图 + 数值（信息密度高、天然适配窄屏），
 * 点击展开雷达图——手册 §4.2「小屏图表要重新配置，不是缩放」。 */
.dim-row { padding: var(--space-4); }
.dim-row + .dim-row { margin-top: var(--space-3); }
.dim-row__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.dim-row__name { font-family: var(--font-headline); font-weight: 700; font-size: .9375rem; }
.dim-row__score { font-family: var(--font-mono); font-size: .875rem; color: var(--ink-mute); }
.dim-row__bar { margin-top: 10px; height: 8px; border-radius: var(--radius-full); background: var(--paper-dim); overflow: hidden; }
.dim-row__fill { height: 100%; border-radius: var(--radius-full); background: var(--gradient-sage); transition: width .6s cubic-bezier(.4,0,.2,1); }
.dim-row__band { margin-top: var(--space-3); font-size: .875rem; color: var(--ink-soft); line-height: 1.75; }
.dim-row__toggle {
  margin-top: var(--space-2); padding: 0; background: none; border: none; cursor: pointer;
  font-size: .8125rem; font-weight: 600; color: var(--mystic-deep); min-height: 32px;
}

/* 长报告导航：手册 §4.2 —— 否则手机上要滑十几屏 */
.toc { padding: var(--space-4); }
.toc__list { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
.toc__link { font-size: .8125rem; padding: 6px 12px; min-height: 34px; display: inline-flex; align-items: center; border-radius: var(--radius-full); background: var(--paper-dim); color: var(--ink-soft); }

/* 免责声明 —— 手册 §8.2 要求报告页必须有 */
.disclaimer {
  padding: var(--space-4); border-radius: var(--radius-lg);
  background: var(--paper-warm); color: var(--ink-mute);
  font-size: .8125rem; line-height: 1.7;
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.06);
}

/* 分享卡预览（竖版 3:4，小红书获客命脉 · 手册 §4.2）*/
.share-preview { aspect-ratio: 3 / 4; width: 100%; max-width: 300px; margin-inline: auto; border-radius: var(--radius-xl); overflow: hidden; }

/* ══════════════ 状态：加载 / 空 / 错误 ═════════════════════════════════ */

.state { padding: var(--space-12) var(--space-5); text-align: center; }
.state__art { font-size: 2.5rem; opacity: .55; }
.state__title { margin-top: var(--space-3); font-family: var(--font-headline); font-weight: 700; }
.state__desc { margin-top: var(--space-2); font-size: .875rem; color: var(--ink-mute); }
.state__actions { margin-top: var(--space-5); display: flex; justify-content: center; gap: var(--space-3); flex-wrap: wrap; }

.skeleton {
  border-radius: var(--radius-xl); background: var(--paper-dim);
  animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes shimmer { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }

/* localStorage 不可用时的顶部告警（iOS 无痕）——手册 §1.2 硬性要求 */
.storage-warn {
  padding: 10px var(--space-4); border-radius: var(--radius-lg);
  background: var(--rabbit-mist); color: var(--rabbit-deep);
  font-size: .8125rem; font-weight: 500; line-height: 1.6;
  box-shadow: inset 0 0 0 1px rgba(233,169,169,.35);
}

/* toast */
.toast {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--space-8) + var(--safe-b)); z-index: 60;
  padding: 10px 18px; border-radius: var(--radius-full);
  background: var(--ink); color: var(--paper-bright);
  font-size: .875rem; font-weight: 500; box-shadow: var(--shadow-ink);
  opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease;
}
.toast[data-open="true"] { opacity: 1; transform: translateX(-50%) translateY(-4px); }

.float { animation: float 6s ease-in-out infinite; }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }

/* ═══════════════════════════════════════════════════════════════════════════
 * 组件层 · 2026-08-24 新增（门户改版 v2）
 * ---------------------------------------------------------------------------
 * 本轮纪律：**token 不动，只加组件层**。下面每一条只消费 tokens.css 里已有的
 * 变量（唯一例外是 --xhs*，这一轮唯一破例加的 token）。
 * 每加一个 class 都登记在 public/DESIGN-TOKENS.md §6。
 *
 * 门户的三段结构，两段是**通栏满幅**的：
 *   .hero-deck   满幅轮播（贴屏幕两边，无圆角、无间隙、一屏一张）
 *   .code-band   满幅米色带（输码）
 *   <main.shell> 常规内容栏（底部按钮 / 历史 / 免责）
 * 前两段必须在 <main class="shell"> 之外 —— shell 有宽度上限和左右内边距。
 * ═════════════════════════════════════════════════════════════════════════ */

/* ══════════════ Hero 甲板 · 满幅轮播 ═════════════════════════════════════
 * 横向滚动用**原生 scroll-snap**，不是 JS 轮播 ——
 * 硬约束②要求无 JS 可用，而且原生滚动的手感没有任何 JS 库赢得过。 */

.hero-deck { position: relative; }

.hero-deck__rail {
  display: flex;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.hero-deck__rail::-webkit-scrollbar { display: none; }
/* 自动轮播由 JS 驱动（见 static/js/index-page.js）。
 * CSS 这边只做一件事：把滚动行为交给 scroll-behavior，让 JS 只管
 * 「滚到第几张」，不用自己写缓动。用户按住不放时 JS 会停，不跟手指抢。 */
@media (prefers-reduced-motion: no-preference) {
  .hero-deck__rail { scroll-behavior: smooth; }
}

.hero-slide {
  position: relative; flex: 0 0 100%; scroll-snap-align: start;
  display: flex; flex-direction: column;
  /* 上半屏。svh 而不是 vh —— iOS 地址栏收起时 vh 会让整块跳一下。
   * 上边距要同时让开刘海安全区和悬浮的品牌条（56px）。 */
  min-height: clamp(16rem, 44svh, 25rem);
  padding: calc(var(--space-18) + env(safe-area-inset-top, 0px)) var(--space-5) var(--space-10);
  overflow: hidden;
  color: var(--paper-bright);
}

/* 底：默认是色调渐变；配置里给了 cover 就换成图，**不用改代码**。 */
.hero-slide::before {
  content: ''; position: absolute; inset: 0; z-index: 0;
  background: var(--slide-cover, var(--slide-bg, var(--gradient-gold)));
  background-size: cover; background-position: center;
}
/* 上下各压一道深洗。
 * ⚠️ **所有色调统一走「深洗 + 白字」**，不按 tone 分浅字深字 ——
 *    因为 cover 换成真实照片后亮度完全不可控，深洗是唯一在任何底上都成立的解。
 *    渐变色块只是「还没有图」时的占位，它得跟有图时长一个样，
 *    否则换图那天整页要重调一遍。 */
.hero-slide::after {
  content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(to bottom,
    rgba(14,16,24,.56) 0%, rgba(14,16,24,.10) 36%,
    rgba(14,16,24,.16) 60%, rgba(14,16,24,.60) 100%);
}
/* 占位渐变左上压一道光晕。纯线性渐变 + 深洗 = 一整块死色（金色会洗成橄榄泥），
 * 加一层径向高光就有了体积。换成真封面图后这一层自动没了（--slide-cover 整个替换）。 */
.hero-slide--gold   { --slide-bg: radial-gradient(120% 78% at 22% 12%, rgba(255,255,255,.40), transparent 58%), var(--gradient-gold); }
.hero-slide--rabbit { --slide-bg: radial-gradient(120% 78% at 22% 12%, rgba(255,255,255,.42), transparent 58%), var(--gradient-rabbit); }
.hero-slide--sage   { --slide-bg: radial-gradient(120% 78% at 22% 12%, rgba(255,255,255,.40), transparent 58%), var(--gradient-sage); }
.hero-slide--ink    { --slide-bg: radial-gradient(120% 78% at 22% 12%, rgba(197,163,88,.24), transparent 62%), var(--gradient-ink); }

/* 右上角的 tag 组。绝对定位而不是跟标题同一行 flex ——
 * 标题长的时候（IPIP-HEXACO-100 六因子人格）会换两行，同行 flex 会把 tag
 * 顶到第二行的右边，看起来像是挂在标题上而不是钉在卡片角上。 */
.hero-slide__tags {
  position: absolute; z-index: 2;
  top: calc(var(--space-16) + env(safe-area-inset-top, 0px));
  right: var(--space-5);
  display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2);
  max-width: 45%;
}

.hero-slide__body { position: relative; z-index: 2; padding-right: 40%; }
.hero-slide__title {
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(1.75rem, 7.5vw, 2.5rem); line-height: 1.18;
  text-shadow: 0 2px 12px rgba(14,16,24,.35);
}
.hero-slide__tagline {
  margin-top: var(--space-3); margin-right: -40%;   /* tagline 在 tag 下方，不用避让 */
  font-size: .9375rem; line-height: 1.6;
  color: rgba(250,250,247,.86);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* 元信息推到底部，中间留空给图 —— 满幅 Hero 的中段是画面，不是文字 */
.hero-slide__foot {
  position: relative; z-index: 2; margin-top: auto;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-3);
}
.hero-slide__cta { font-size: .8125rem; font-weight: 700; white-space: nowrap; }
/* 右下角的社会证明。**不做成 chip** —— 它不是标签，是一句话；
 * 套上胶囊底就变成了「又一个属性」，跟左边的 tag 抢注意力。
 * 数字用等宽，四位数换成五位数时不会整行跳一下。 */
.hero-slide__taken {
  font-size: .75rem; font-variant-numeric: tabular-nums;
  color: rgba(250,250,247,.72); white-space: nowrap;
  text-shadow: 0 1px 6px rgba(14,16,24,.45);
}

/* 深洗底上的 chip。paper 系的 chip 在这里会糊成一团。 */
.slide-chip {
  background: rgba(255,255,255,.18); color: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,.30);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.slide-chip--paid { background: rgba(197,163,88,.34); box-shadow: inset 0 0 0 1px rgba(232,216,176,.55); }
.slide-chip--free { background: rgba(122,160,129,.34); box-shadow: inset 0 0 0 1px rgba(200,216,204,.55); }

/* 悬浮品牌条。顶栏取消了，但品牌和「全部量表」这条出口不能一起没。
 * absolute 而不是跟着 slide 走 —— 它要在轮播上面待着不动。 */
.hero-deck__bar {
  position: absolute; inset-inline: 0; top: 0; z-index: 3;
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
  padding: calc(var(--space-3) + env(safe-area-inset-top, 0px)) var(--space-5) var(--space-3);
  pointer-events: none;
  color: var(--paper-bright);
}
.hero-deck__bar > * { pointer-events: auto; }
.hero-deck__bar .brand__mark {
  width: 30px; height: 30px; font-size: 1rem;
  background: rgba(14,16,24,.34); color: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,.30);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
/* 品牌收成一行。两行的 lockup 会一路压到 Hero 标题上。 */
.hero-deck__bar .brand__name {
  font-size: 1.0625rem; color: var(--paper-bright);
  text-shadow: 0 1px 8px rgba(14,16,24,.45);
}
.hero-deck__nav {
  font-size: .75rem; font-weight: 600;
  padding: 7px 12px; min-height: 32px; display: inline-flex; align-items: center;
  border-radius: var(--radius-full);
  background: rgba(14,16,24,.28); color: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(255,255,255,.22);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}

/* 分页点。
 * ⚠️ 默认带 .hidden，**由 JS 摘掉** —— 原生滚动没有「当前页」的 CSS 信号，
 *    无 JS 时画一排点不亮的点比不画更糟。有 JS 才出现，没 JS 就没有。 */
.hero-deck__dots {
  position: absolute; inset-inline: 0; bottom: var(--space-4); z-index: 3;
  display: flex; justify-content: center; gap: 6px; pointer-events: none;
}
.hero-deck__dot {
  width: 6px; height: 6px; border-radius: var(--radius-full);
  background: rgba(250,250,247,.42);
  transition: width .28s cubic-bezier(.4,0,.2,1), background .28s ease;
}
.hero-deck__dot[data-on="true"] { width: 20px; background: var(--paper-bright); }

/* ══════════════ 输码带 · 满幅米色 ════════════════════════════════════════
 * 通栏的一条暖米色带，把「输码」这件事从上下两段里切出来。
 * 整站都是白卡贴温白底、靠阴影分层，唯独这一条用**换底色**分层。 */

.code-band {
  background: var(--paper-warm);
  padding: var(--space-8) 0;
  box-shadow: inset 0 1px 0 rgba(14,16,24,.06), inset 0 -1px 0 rgba(14,16,24,.06);
  transition: background .25s ease, box-shadow .25s ease;
}
.code-band__inner {
  max-width: var(--shell-max); margin-inline: auto;
  padding-inline: var(--space-5); text-align: center;
}
.code-band__label {
  font-family: var(--font-headline); font-weight: 700; font-size: 1rem;
  color: var(--ink-deep);
}
.code-band__sub { margin-top: 6px; font-size: .8125rem; color: var(--ink-mute); }

/* .code-input 默认底色就是 --paper-warm，跟这条带同色 —— 那样它看起来是只读的。
 * 在米色带里翻成白底。 */
.code-band .code-input { background: var(--paper-bright); margin-top: var(--space-4); }
.code-band .code-input:focus { background: var(--paper-bright); }

/* 按钮列。比输入框窄一档 —— 输入框要塞下 24 个字符，按钮不用，
 * 一样宽会让整块变成三条等长的横杠，看不出主次。 */
.code-band__actions,
.deck-actions {
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-3); margin-top: var(--space-5);
}
.code-band__actions .btn,
.deck-actions .btn { width: min(100%, 17rem); }

/* 核销失败：整条带转红。
 * 用 --signal-error 而不是 --xhs —— 那个红是「去买」，这个红是「出错了」，
 * 两者同屏出现（失败提示 + 购买按钮），混用会让人以为报错是个可点的入口。 */
.code-band--error {
  background: color-mix(in srgb, var(--signal-error) 7%, var(--paper-warm));
  box-shadow: inset 0 3px 0 var(--signal-error), inset 0 -1px 0 rgba(181,72,61,.2);
}
.code-band--error .code-band__label { color: var(--signal-error); }
.code-band--error .code-input { box-shadow: inset 0 0 0 2px var(--signal-error); }
/* 砖红底 + 小红书品牌红按钮会糊在一起，镶一圈底色把它切出来 */
.code-band--error .btn-xhs { box-shadow: 0 0 0 3px color-mix(in srgb, var(--signal-error) 7%, var(--paper-warm)), 0 6px 16px rgba(255,36,66,.32); }

/* 金色主按钮压在米色底上会发飘，加一圈内描边钉住它。
 * 只在米色带里生效 —— 别处的 .btn-gold 压的是白卡，本来就够。 */
.code-band .btn-gold { box-shadow: inset 0 0 0 1.5px rgba(154,126,62,.5), 0 6px 18px rgba(197,163,88,.36); }
.code-band .btn-gold:hover:not(:disabled) { box-shadow: inset 0 0 0 1.5px rgba(154,126,62,.7), 0 10px 24px rgba(197,163,88,.46); }

/* 小红书渠道按钮。全站唯一使用 --xhs 的地方。 */
.btn-xhs { background: var(--gradient-xhs); color: #FFFFFF; box-shadow: 0 4px 14px rgba(255,36,66,.30); }
.btn-xhs:hover:not(:disabled) { box-shadow: 0 8px 22px rgba(255,36,66,.42); }

/* 未开放的入口。用 <span> 渲染而不是去掉 href 的 <a> ——
 * <a> 没有 disabled 属性，去掉 href 才是真的点不动。 */
.btn.is-soon {
  background: var(--paper-dim); color: var(--ink-faint);
  box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.07);
  cursor: default;
}
.btn.is-soon:hover { transform: none; }

/* ══════════════ 付费弹窗 · 纯 CSS ════════════════════════════════════════
 * 用 :target 实现，**一行 JS 都不需要** —— 硬约束②。
 * 打开 = <a href="#pw-xxx">，关闭 = <a href="#top">。
 * JS 只加三件增强（Esc 关闭、关闭后送焦点、埋点），去掉功能不缺。 */

.modal { display: none; }
.modal:target {
  display: flex; align-items: flex-end; justify-content: center;
  position: fixed; inset: 0; z-index: 70;
}
@media (min-width: 40rem) { .modal:target { align-items: center; } }

.modal__scrim {
  position: absolute; inset: 0;
  background: rgba(14,16,24,.52);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
}
.modal__panel {
  position: relative; z-index: 1;
  width: 100%; max-width: 26rem;
  max-height: 88svh; overflow-y: auto;
  padding: var(--space-6) var(--space-5) calc(var(--space-5) + var(--safe-b));
  background: var(--paper-bright);
  border-radius: var(--radius-2xl) var(--radius-2xl) 0 0;
  box-shadow: var(--shadow-paper-xl);
  animation: modal-rise .28s cubic-bezier(.4,0,.2,1);
}
@media (min-width: 40rem) {
  .modal__panel { border-radius: var(--radius-2xl); padding-bottom: var(--space-6); }
}
@keyframes modal-rise { from { transform: translateY(14px); opacity: 0; } to { transform: none; opacity: 1; } }

.modal__close {
  position: absolute; top: var(--space-3); right: var(--space-3);
  width: 36px; height: 36px; min-height: 36px;
  display: grid; place-items: center;
  border-radius: var(--radius-full);
  background: var(--paper-dim); color: var(--ink-mute);
  font-size: 1.125rem; line-height: 1;
}
.modal__close:hover { background: var(--paper-edge); color: var(--ink); }
.modal__title { font-family: var(--font-display); font-weight: 600; font-size: 1.375rem; line-height: 1.3; }
.modal__actions { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }

/* 弹窗开着时锁住背景滚动。:has 不支持的浏览器只是背景能滚，不影响功能。 */
body:has(.modal:target) { overflow: hidden; }

/* 「尚未购买 / 尚未输入」两行状态。用列表而不是散句 ——
 * 用户要在两秒内分清「我是没买」还是「我买了没输」。 */
.status-list { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-4); }
.status-line {
  display: flex; align-items: flex-start; gap: var(--space-3);
  padding: 10px var(--space-3);
  border-radius: var(--radius); background: var(--paper-warm);
  font-size: .875rem; color: var(--ink-soft); text-align: left;
}
.status-line__mark { flex: 0 0 auto; color: var(--ink-faint); font-family: var(--font-mono); }

/* ═══════════════════════════════════════════════════════════════════════════
 * 测试库 · 浅底 + 深色通栏行（2026-08-25）
 * ---------------------------------------------------------------------------
 * 页面整体是浅色（和门户一致，用 base.css 的默认页底），**只有列表行是深色**。
 *
 * 为什么这样比整页深色好：
 *   · 门户是浅的，量表库整页翻黑，用户点过去会有一次明显的断层
 *   · 深色行压在浅底上反差更强 —— 列表成了整页唯一的重色，视线自动落在它上面
 *   · 空态、错误态、按钮全部沿用现成的浅色组件，不用为深色再养一套
 *
 * 行本身仍然是「封面图 + 深洗 + 白字」，与 Hero 同一套语言。
 * ═════════════════════════════════════════════════════════════════════════ */

/* ══════════════ Banner（浅色）══════════════════════════════════════════ */

.lib-banner {
  position: relative;
  padding: calc(var(--space-4) + env(safe-area-inset-top, 0px)) 0 var(--space-6);
  /* 顶上一层极淡的金晕，把 Banner 从页底里轻轻托起来，不用画边框 */
  background: linear-gradient(180deg, rgba(197,163,88,.13) 0%, transparent 72%);
}
.lib-banner__inner {
  max-width: var(--shell-wide); margin-inline: auto; padding-inline: var(--space-5);
}
.lib-banner__top {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
}

.lib-banner__code {
  font-size: .8125rem; font-weight: 600; white-space: nowrap;
  padding: 8px 14px; min-height: 36px; display: inline-flex; align-items: center;
  border-radius: var(--radius-full);
  background: var(--mystic-mist); color: var(--mystic-deep);
  box-shadow: inset 0 0 0 1px rgba(197,163,88,.42);
}
.lib-banner__code:hover { background: var(--mystic-soft); color: var(--ink-deep); }

.lib-banner__title {
  margin-top: var(--space-5);
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(2rem, 9vw, 3rem); line-height: 1.1;
  color: var(--ink-deep);
}

/* 搜索。原生 GET 表单，JS 只是把它变成即时的。 */
.lib-search {
  display: flex; align-items: center; gap: var(--space-2);
  margin-top: var(--space-5);
  padding: 4px 4px 4px var(--space-4);
  border-radius: var(--radius-full);
  background: var(--paper-bright);
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.10), var(--shadow-paper-sm);
  transition: box-shadow .2s ease;
}
.lib-search:focus-within {
  box-shadow: inset 0 0 0 1.5px var(--mystic), var(--shadow-gold-glow);
}
.lib-search__icon { flex: 0 0 auto; font-size: 1.125rem; color: var(--ink-faint); }
.lib-search__input {
  flex: 1 1 auto; min-width: 0; min-height: 44px;
  background: none; border: none; outline: none;
  color: var(--ink-deep);
  /* 16px 是 iOS 不触发聚焦缩放的下限（base.css 那条规则），不能再往下调 */
  font-size: 1rem;
}
.lib-search__input::placeholder { color: var(--ink-faint); }
/* JS 接管之后这颗按钮就没用了，由 library-page.js 加 .hidden。
 * 无 JS 时它是唯一能提交的入口，所以默认必须在。 */
.lib-search__go {
  flex: 0 0 auto; min-height: 38px; padding: 0 16px;
  border: none; border-radius: var(--radius-full); cursor: pointer;
  font-family: var(--font-headline); font-weight: 600; font-size: .8125rem;
  background: var(--gradient-gold); color: var(--ink-deep);
}

/* 筛选。**是 <a> 不是 <button>** —— 服务端 GET 参数驱动，
 * 关掉 JS 照样能点，而且结果是可分享的 URL。 */
.lib-filter { display: flex; gap: var(--space-2); margin-top: var(--space-4); }
.lib-pill {
  padding: 8px 16px; min-height: 36px; display: inline-flex; align-items: center;
  border-radius: var(--radius-full);
  font-size: .8125rem; font-weight: 600;
  background: var(--paper-bright); color: var(--ink-mute);
  box-shadow: inset 0 0 0 1px rgba(14,16,24,.08);
  transition: all .18s ease;
}
.lib-pill:hover { color: var(--ink); box-shadow: inset 0 0 0 1px rgba(14,16,24,.22); }
.lib-pill[aria-current="true"] {
  background: var(--ink); color: var(--paper-bright);
  box-shadow: var(--shadow-ink);
}

.lib-banner__note {
  display: flex; align-items: center; gap: var(--space-2);
  margin-top: var(--space-4); padding: 10px var(--space-4);
  border-radius: var(--radius-lg);
  background: var(--sage-mist); color: var(--sage-deep);
  font-size: .8125rem; font-weight: 500;
  box-shadow: inset 0 0 0 1px rgba(122,160,129,.25);
}
.lib-banner__note a { font-weight: 700; text-decoration: underline; }

/* ══════════════ 列表 · 深色通栏行 ══════════════════════════════════════
 * 一行一份量表。**不是卡片** —— 通栏、无圆角、无间隙，行与行之间只有一道发丝线。
 * 手机上从上到下滑，节奏是连续的，不是一颗一颗的。 */

/* 通栏。<main class="shell-wide"> 有 space-5 的左右内边距，这里用负边距把它抵掉，
 * 让行真的贴到屏幕两边 —— 长列表的连续感靠的就是「没有边距」。
 * 行自己的 padding 留着，文字不会贴边。
 * 桌面端 shell-wide 封顶 64rem，抵掉的只是那 20px，不会拉成满屏。 */
.lib-list {
  margin-top: var(--space-6);
  margin-inline: calc(var(--space-5) * -1);
  /* 整块深色压在浅底上，上下各一道墨线收边，免得边缘看起来是「没画完」 */
  box-shadow: 0 1px 0 rgba(14,16,24,.10), 0 -1px 0 rgba(14,16,24,.10);
}

.lib-row {
  position: relative; display: flex; align-items: center; gap: var(--space-4);
  min-height: 92px;
  padding: var(--space-4) var(--space-5);
  overflow: hidden;
  /* 行间发丝线是**浅色**的 —— 底是深的，墨色线在上面看不见 */
  box-shadow: inset 0 -1px 0 rgba(250,250,247,.12);
}
.lib-row:last-child { box-shadow: none; }

/* 底：这份量表的封面图；还没有图时用调子渐变。
 * 加封面 = 往 php/hero.php 对应项填 cover，首页和这里同时生效。 */
.lib-row::before {
  content: ''; position: absolute; inset: 0; z-index: 0;
  background: var(--row-cover, var(--row-bg, var(--gradient-ink)));
  background-size: cover; background-position: center;
  transition: transform .35s ease;
}
/* 深洗：左重右轻。左边要压得住文字，右边留出图本身。
 * ⚠️ 和 Hero 一样，**所有调子统一深洗 + 白字** —— cover 换成真实照片后亮度
 *    完全不可控，深洗是唯一在任何底上都成立的解。 */
.lib-row::after {
  content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(90deg,
    rgba(14,16,24,.86) 0%, rgba(14,16,24,.72) 46%, rgba(14,16,24,.40) 100%);
  transition: background .25s ease;
}
/* hover 与按压的动效统一放在文件末尾的「交互反馈」那一层 */
/* 深底上的 focus 环要用金色，墨色那圈在这里看不见 */
.lib-row:focus-visible { box-shadow: inset 0 0 0 2px var(--mystic); }

.lib-row--gold   { --row-bg: var(--gradient-gold); }
.lib-row--rabbit { --row-bg: var(--gradient-rabbit); }
.lib-row--sage   { --row-bg: var(--gradient-sage); }
/* ink 调子压完深洗就是一块死黑 —— 那样这一行看起来像没加载出来。
 * 右侧给一团更重的金晕，让它跟别的行一样「背后有东西」。 */
.lib-row--ink    { --row-bg: radial-gradient(90% 160% at 88% 50%, rgba(197,163,88,.55), transparent 66%), var(--gradient-ink); }

.lib-row__main { position: relative; z-index: 2; flex: 1 1 auto; min-width: 0; }
.lib-row__emoji { margin-right: 8px; font-size: 1.125rem; }
/* 单行截断。IPIP-HEXACO-100 那种长标题换两行会把行撑高，
 * 一列高矮不齐的行读起来就不是「列表」了。 */
.lib-row__title {
  display: block;
  font-family: var(--font-headline); font-weight: 700; font-size: 1.0625rem;
  color: var(--paper-bright);
  text-shadow: 0 1px 8px rgba(14,16,24,.6);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lib-row__sub {
  display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
  margin-top: 4px; font-size: .8125rem; line-height: 1.5;
  color: rgba(250,250,247,.66);
}
/* 右侧两个 tag。竖排在窄屏会把行撑高，所以固定横排 + 不换行。 */
.lib-row__tags {
  position: relative; z-index: 2; flex: 0 0 auto;
  display: flex; align-items: center; gap: var(--space-2);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * 交互反馈（2026-08-25）—— 全站统一的一层，放在最后，压得住前面所有组件
 * ---------------------------------------------------------------------------
 * 两条规则：
 *   1. **按下去 = 涨一下。** 所有按钮、pill、可点的行与卡片统一这个语言。
 *      不做 hover 位移 —— 悬停是鼠标才有的状态，而这个站的用户在手机上。
 *   2. **hover 效果一律关在 @media (hover: hover) 里。** 触摸设备上 iOS/安卓
 *      会把上一次点过的元素一直留在 :hover 上，于是列表里会有一行「亮着」
 *      直到你点别处 —— 那是移动端最常见的一类脏效果。
 * ═════════════════════════════════════════════════════════════════════════ */

/* ---- 按压：涨一下再落回 -------------------------------------------------
 * 快进慢出是「丝滑」的关键：
 *   按下 90ms 到位 —— 手指还没离开屏幕就已经反馈了，慢一点就会觉得「没响应」
 *   松开 320ms 落回，用 cubic-bezier(.22,1,.36,1)（ease-out-quint 那一类）——
 *   前 1/3 时间走完 80% 的距离，尾巴长而轻，看起来像有惯性而不是硬停。
 * 两段用同一条 transition，靠 :active 里改 duration 实现。            */
.btn,
.likert__opt,
.lib-pill,
.lib-search__go,
.lib-banner__code,
.hero-deck__nav,
.modal__close,
.filter-pill,
a.paper-card,
button.paper-card {
  /* 移动端点击时那块半透明灰底是浏览器默认的，跟我们自己的反馈会叠在一起 */
  -webkit-tap-highlight-color: transparent;
  /* 去掉移动端 300ms 双击缩放等待 —— 反馈再快，也快不过一个 300ms 的延迟 */
  touch-action: manipulation;
  transition: transform 320ms cubic-bezier(.22, 1, .36, 1),
              background .2s ease, box-shadow .2s ease, color .2s ease;
}

.btn:not(:disabled):not(.is-soon):active,
.likert__opt:active,
.lib-pill:active,
.lib-search__go:active,
.lib-banner__code:active,
.hero-deck__nav:active,
.modal__close:active,
.filter-pill:active,
a.paper-card:active,
button.paper-card:active {
  transform: scale(var(--press-scale, 1.035));
  transition-duration: 90ms;
}

/* 大按钮涨太多会顶到相邻元素，按面积反着调幅度 —— 小目标涨得多才看得见 */
.btn-lg, .btn-block { --press-scale: 1.022; }
.btn-sm, .modal__close { --press-scale: 1.06; }
.likert__opt { --press-scale: 1.015; }
a.paper-card, button.paper-card { --press-scale: 1.012; }

/* 禁用与「即将开放」不给任何按压反馈 —— 反馈了就是在说「这个能点」 */
.btn:disabled:active, .btn.is-soon:active { transform: none; }

/* ---- 通栏的行与卡片：按压不缩放整块 -------------------------------------
 * .lib-row 和 .hero-slide 是贴着屏幕两边的，整块 scale 会露出背景边，很脏。
 * 改成压里面的图：图往里推一点，深洗提亮一点，感觉像是「按进去了」。 */
.lib-row, .hero-slide { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }

.lib-row::before  { transition: transform 620ms cubic-bezier(.22, 1, .36, 1); }
.lib-row::after   { transition: background 380ms ease; }
.lib-row__title   { transition: transform 380ms cubic-bezier(.22, 1, .36, 1); }

/* ---- 悬停：只在真有指针的设备上 ---------------------------------------- */
@media (hover: hover) and (pointer: fine) {
  /* 测试库的行：图慢慢推近、深洗变浅、标题往右挪一点点。
   * 620ms 的图 + 380ms 的洗 + 380ms 的标题，三条时长错开，
   * 读起来是「一层一层醒过来」，而不是整块同时跳一下。 */
  .lib-row:hover::before { transform: scale(1.055); }
  .lib-row:hover::after {
    background: linear-gradient(90deg,
      rgba(14,16,24,.78) 0%, rgba(14,16,24,.58) 46%, rgba(14,16,24,.22) 100%);
  }
  .lib-row:hover .lib-row__title { transform: translateX(4px); }

  .hero-slide:hover::before { transform: scale(1.035); }
}

/* ---- 按压：写在 hover 之后 ----------------------------------------------
 * 两组规则特异度相同（0,2,1），同分时**后写的赢**。桌面端是「先悬停、再按下」，
 * 按压必须能压过悬停 —— 顺序写反了的话按下去图还停在悬停的 1.055，
 * 那一下就完全没有反馈。 */
.lib-row:active::before { transform: scale(1.07); transition-duration: 160ms; }
.lib-row:active::after {
  background: linear-gradient(90deg,
    rgba(14,16,24,.74) 0%, rgba(14,16,24,.54) 46%, rgba(14,16,24,.20) 100%);
  transition-duration: 120ms;
}

.hero-slide::before { transition: transform 620ms cubic-bezier(.22, 1, .36, 1); }
.hero-slide:active::before { transform: scale(1.05); transition-duration: 180ms; }

/* prefers-reduced-motion 由 base.css 全局把 transition-duration 压成 0.001ms，
 * 所以这一整层对前庭敏感的用户自动变成「瞬间切换」，不需要在这里再写一遍。 */

/* ═══════════════════════════════════════════════════════════════════════════
 * 组件层 · 2026-08-25 新增（发码台 /sales.php）
 * ---------------------------------------------------------------------------
 * 纪律照旧：**token 不动，只加组件层**，每一条只消费 tokens.css 里已有的变量。
 * 登记在 public/DESIGN-TOKENS.md §10。
 *
 * 这一轮只加了 6 个组件，因为发码台刻意**没有自己的视觉语言** ——
 * 它是内部工具，用户是三五个同事，每天看一眼。给它设计一套新样子的收益是零，
 * 而多一套样子就多一处会跟主站漂移的地方。所以：
 *   标题        复用 .h-page
 *   卡片        复用 .paper-card
 *   按钮 / chip 复用 .btn-* / .chip
 *   提示 / 错误 复用 .field-hint / .field-error / .storage-warn / .state
 *
 * 这一页**没有登录界面** —— 站点保护是 Apache 的 Basic Auth（浏览器原生弹窗），
 * 所以整页只有一个状态：已进站。少一个状态就少一套要设计的东西。
 *
 * 真正没有现成的只有「带 label 的下拉框」—— 站上此前唯一的输入控件是
 * .code-input（等宽、居中、大写、字距拉开），那是为 24 位授权码调的，
 * 拿它装一个量表标题会很怪。
 * ═════════════════════════════════════════════════════════════════════════ */

/* 面板。与 .code-card 同一个内边距刻度 —— 两处都是「一张纸上的一个表单」，
 * 没有理由长得不一样。 */
.sales-card { padding: var(--space-6) var(--space-5); }

/* 一个字段 = 标签在上、控件在下。
 * 不做 label 与 input 同行：375px 竖屏上同行会把输入框压到 60% 宽，
 * 而这一页最长的控件是量表下拉框，标题能有十几个字。 */
.sales-field { display: flex; flex-direction: column; gap: var(--space-2); }
.sales-field__label {
  font-family: var(--font-headline);
  font-size: .8125rem; font-weight: 600; color: var(--ink-soft);
}

/* 下拉框（这一页唯一的表单控件）。
 *
 * 视觉上是 .code-input 的「常规版」：同一套底色、同一圈内阴影、同一个焦点环，
 * 但字体是正文体、左对齐、不大写 —— 装的是量表标题，不是码。
 *
 * 写成 .sales-input 而不是 .sales-select，是留给下一个人的：这一页迟早会多一个
 * 普通文本框（比如「备注」），到那天直接用同一个 class，不用再调一遍视觉。
 *
 * ⚠️ font-size 不能低于 1rem：iOS Safari 在小于 16px 的输入框上会自动放大整页
 *    （base.css 里那条规则的同一个理由）。放大之后用户得手动缩回去，
 *    而这一页他一天要开好几次。
 *
 * ⚠️ appearance: none 之后 <select> 在 iOS 上会丢掉那个小箭头，所以用
 *    background-image 画一个回去。不画的话下拉框看起来和文本框一模一样，
 *    没人知道它能点开。 */
.sales-input {
  width: 100%; min-height: 48px;
  padding: 12px 14px;
  font-family: var(--font-body); font-size: 1rem; color: var(--ink-deep);
  background: var(--paper-warm);
  border: none; border-radius: var(--radius-lg);
  box-shadow: inset 0 0 0 1.5px rgba(14,16,24,.10);
  transition: box-shadow .2s ease, background .2s ease;
}
.sales-input:focus {
  outline: none; background: var(--paper-bright);
  box-shadow: inset 0 0 0 2px var(--ink), 0 0 0 4px rgba(14,16,24,.06);
}

select.sales-input {
  appearance: none; -webkit-appearance: none;
  padding-right: 40px; cursor: pointer;
  /* 一个 12×8 的墨色三角。内联 SVG 走 data: URI —— 这个站不引任何外部资源，
   * 而为一个箭头单开一个 .svg 文件要多一次请求。 */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%230E1018' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' opacity='.55'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 14px center;
}

/* 指纹前 8 位、批次前 8 位。等宽 + 一点点底，让它在一行中文里读起来是「一个编号」。
 * 用 <span> 不用 <code>：<code> 会被 base.css 的排版规则接管，
 * 而这里要的只是「等宽 + 一个底」，不是代码块的语义。 */
.sales-mono {
  font-family: var(--font-mono); font-size: .8125rem;
  padding: 1px 6px; border-radius: var(--radius);
  background: var(--paper-dim); color: var(--ink-mute);
}

/* 「CSV 是明文码的唯一一份」那段。
 *
 * 用 signal-error 的语气，但**不是** .field-error —— 那个是「你填错了」，
 * 这个是「这件事不可逆」。左边一道竖线而不是整块红底：整块红底在一个
 * 每天都要用的工具里会很快变成噪音，三天之后没人再读它。 */
.sales-danger {
  padding: var(--space-3) var(--space-4);
  border-left: 3px solid var(--signal-error);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: color-mix(in srgb, var(--signal-error) 7%, transparent);
  font-size: .8125rem; line-height: 1.7; color: var(--ink-soft);
}
.sales-danger strong { color: var(--signal-error); }

/* 记录列表（发码批次 / 设备）。
 *
 * 是**列表不是卡片网格**：这两张表都是「扫一眼有没有异常」，不是「挑一个点开」。
 * 卡片会让每一行都像一个可点的入口，而它们全都不可点。
 * 行与行之间只有一道发丝线 —— 与量表库那套深色通栏行同一个思路，
 * 只是这里是浅底，所以线用墨色（那边是浅色，因为底是深的）。 */
.sales-ledger {
  border-radius: var(--radius-lg); overflow: hidden;
  background: var(--paper-bright); box-shadow: var(--shadow-paper-sm);
}
.sales-ledger__row {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-4) var(--space-5);
}
.sales-ledger__row + .sales-ledger__row { box-shadow: inset 0 1px 0 rgba(14,16,24,.07); }
/* 行内两段文字竖排。__row 是 flex row，里面的 .grow 要变回 column */
.sales-ledger__row > .grow { display: flex; flex-direction: column; gap: 2px; }


/* ═══════════════ 语言开关 ═════════════════════════════════════════════════
 *
 * 页脚上一行「中文 / English」，由 layout.php 的 ml_lang_switch() 渲染。
 *
 * 三个决定：
 *
 * · **是两个 <a>，不是 <select>。** 全站硬约束是「无 JS 也要能用」，
 *   而 <select> 要切换语言就得挂 onchange。两个链接零脚本，
 *   而且当前语言用 <span> 而不是 disabled 的链接 —— 不可点的东西不该长得像链接。
 *
 * · **放在页脚不是顶栏。** 顶栏在首页和测试库都被 Hero/Banner 顶掉了，
 *   放上面就得在三个地方各写一遍。页脚是唯一每页都有的位置。
 *
 * · **克制。** 这是一个一次性开关：点过之后 cookie 记住，后面再也不用碰。
 *   给它顶栏那种视觉重量，等于每一页都在提醒用户「你可以换语言」——
 *   而绝大多数人只需要看见它一次。
 *
 * 只用已有 token，没有新增颜色/间距。 */
.lang-switch {
  display: flex; justify-content: center; align-items: center;
  gap: var(--space-3);
  margin-top: var(--space-10);
  padding-bottom: var(--space-6);
  font-family: var(--font-body);
  font-size: .8125rem;
  line-height: 1.4;
}
.lang-switch__on {
  color: var(--ink-soft);
  font-weight: 600;
}
.lang-switch__off {
  color: var(--ink-faint);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color .15s ease, border-color .15s ease;
}
.lang-switch__off:hover,
.lang-switch__off:focus-visible {
  color: var(--ink-soft);
  border-bottom-color: currentColor;
}
/* 两项之间一道细分隔，不用「|」字符 —— 那是内容，这是装饰 */
.lang-switch__on + .lang-switch__off::before,
.lang-switch__off + .lang-switch__on::before,
.lang-switch__off + .lang-switch__off::before {
  content: ""; display: inline-block;
  width: 1px; height: .75em;
  margin-right: var(--space-3);
  background: var(--ink-faint); opacity: .45;
  vertical-align: -1px;
}
