/* ==========================================================================
   页面层 · 各视图专属样式
   ========================================================================== */

/* ---------------------------------------------------------------- 档案馆首页 */
/*
 * 摘要 + 「概览」触发器那一行。
 *
 * 离标题多远这一条外边距挂**在这一行**上，不挂在 .archive-summary 自己身上。
 * 原来它是挂在摘要上的，而那是摘要还独占一行（.page-head__sub）时写的：
 * 进了 .row（align-items: center）之后，摘要的 margin 盒被行居中，正文因此
 * 比按钮低半条外边距 —— 实测按钮比旁边那行字高出 4.0px，
 * 看起来就是"它一个人在上面一点"。
 *
 * 取 --space-1（4px，和 .page-head__sub 同一个刻度）不是随手挑的：
 * 这一行的高度（30px）由按钮决定、正文只有 21px —— 居中之后正文的头顶
 * 正好在这个数上落回原来那一格，所以**摘要那行字一个像素都没动**（实测 top 74.97px），
 * 往下走的是按钮，它来就文字。整个页头的代价只是高了 4px（81.5 → 85.5）。
 *
 * align-items 从 .row 的 center 改成 **baseline**：整行按"字压在同一条基线上"排。
 * 居中对的是两个**盒子**的中心，而两个盒子不一样高（按钮 30px、正文 21px）、
 * 字也不一样大（标签 12px、正文 13px），所以盒子对齐了字还能差半像素；
 * 基线的对齐才是眼睛看到的那条线。顺带解决另一种情况：摘要万一折成两行，
 * 按钮会跟**第一行**齐平，而不是漂在两行中间（那看着就是"没对齐"）。
 * 实测这一行的高度两种对齐下都是 30px，页头高度不变。
 *
 * 两个 gap 是分开给的，因为它们在两种排法下各管一件事：
 *   column-gap 16px —— 同一行时，摘要文字到「概览」两个字的可见空档
 *                     （按钮那 13px 内边距被下面那条负外边距挂到线外了，
 *                      所以这里的数就是**眼睛看到的**空档，不再是 12+13=25px）
 *   row-gap 4px     —— 摘要折行、按钮掉到下一行时（手机上就是），
 *                     按钮盒里的标签上下各带约 15px 空气（44px 触控高度 − 19px 行盒），
 *                     所以这里给 4px，看到的才是约 19px，不是 30px
 */
.archive-head-sub {
  margin-top: var(--space-1);
  align-items: baseline;
  column-gap: var(--space-4);
  row-gap: var(--space-1);
}
.archive-summary {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--space-2);
  font-size: var(--text-sm); color: var(--muted-foreground);
}
.archive-summary b { font-weight: 600; color: var(--foreground); font-variant-numeric: tabular-nums; }
.archive-summary__sep { color: var(--border-strong); }

.archive-toolbar { display: flex; flex-direction: column; gap: var(--space-4); margin-bottom: var(--space-5); }
.archive-toolbar__row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.archive-toolbar__row .select,
.toolbar-select { flex: 0 0 auto; width: auto; min-width: 7.5rem; }
.archive-toolbar__row .input-icon { flex: 1 1 16rem; min-width: 12rem; }

.filters {
  display: flex; align-items: center; flex-wrap: wrap;
  border-bottom: 1px solid var(--border);
  margin-bottom: calc(-1 * var(--space-1));
}
.filters .grow { min-width: var(--space-2); }
/* 换行时「多选」自动靠右，不会掉到左边显得像另一个筛选条件 */
.filters > .btn { margin-left: auto; }

/* 状态筛选做成下划线页签：一排文字比一排胶囊更容易扫读，也不会和卡片抢注意力 */
.filters--tabs .chip {
  position: relative; padding: var(--space-2) 0;
  border: none; background: none; border-radius: 0;
  font-size: var(--text-sm); color: var(--muted-foreground);
}
.filters--tabs .chip + .chip { margin-left: var(--space-5); }
@media (hover: hover) {
  .filters--tabs .chip:hover { color: var(--foreground); }
}
.filters--tabs .chip::after {
  content: ""; position: absolute; inset-inline: 0; bottom: -1px; height: 1.5px;
  background: transparent; border-radius: 2px;
  transition: background var(--dur-fast) var(--ease-out);
}
.filters--tabs .chip[aria-pressed="true"] { background: none; color: var(--foreground); font-weight: 600; }
.filters--tabs .chip[aria-pressed="true"]::after { background: var(--primary); }
.filters--tabs .chip[aria-pressed="true"] .chip__count { color: var(--primary); opacity: 1; }

/* ---------------------------------------------------------------- 搜索 / 添加 */
.search-hero { margin-bottom: var(--space-6); }
.search-hero__title { font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 600; }
.search-hero__sub { color: var(--muted-foreground); margin-top: var(--space-1); font-size: var(--text-sm); }
.search-box { margin-top: var(--space-5); max-width: 42rem; }
.search-box .input { min-height: 2.75rem; font-size: var(--text-base); border-radius: var(--radius-lg); padding-left: 2.75rem; }
.search-box .input-icon > svg { left: var(--space-4); width: 1.125rem; height: 1.125rem; }
.search-filters { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-3); max-width: 42rem; }
.search-filters .select { width: 100%; }

.result-list { display: flex; flex-direction: column; }
.result-item {
  display: flex; gap: var(--space-4); padding: var(--space-4) var(--space-2);
  border-bottom: 1px solid var(--border);
  transition: background var(--dur-fast) var(--ease-out);
}
.result-item:first-child { border-top: 1px solid var(--border); }
@media (hover: hover) {
  .result-item:hover { background: color-mix(in srgb, var(--foreground) 3%, transparent); }
}
.result-item__poster { width: 3.75rem; flex-shrink: 0; }
@media (min-width: 640px) { .result-item__poster { width: 4.25rem; } }
.result-item__body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.result-item__title { font-size: var(--text-base); font-weight: 600; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.result-item__meta { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; font-size: var(--text-xs); color: var(--muted-foreground); }
.result-item__overview { font-size: var(--text-sm); color: var(--muted-foreground); }
.result-item__actions { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-1); }

/* ---------------------------------------------------------------- 作品详情 */
.work-hero { margin-bottom: var(--space-6); }
.work-hero__inner { display: flex; flex-direction: column; gap: var(--space-5); }
@media (min-width: 768px) { .work-hero__inner { flex-direction: row; gap: var(--space-8); } }
.work-hero__poster { width: 7rem; flex-shrink: 0; }
@media (min-width: 768px) { .work-hero__poster { width: 10rem; } }
.work-hero__poster .poster { border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
.work-hero__info { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--space-3); }
.work-hero__title {
  font-family: var(--font-display);
  font-size: var(--text-2xl); font-weight: 600; line-height: var(--leading-tight);
  letter-spacing: -0.01em;
}
@media (min-width: 768px) { .work-hero__title { font-size: var(--text-3xl); } }
.work-hero__original { color: var(--faint-foreground); font-size: var(--text-sm); }
.work-hero__meta { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; font-size: var(--text-sm); color: var(--muted-foreground); }
.work-hero__meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--border-strong); }
.work-hero__overview {
  font-size: var(--text-sm); color: var(--muted-foreground); max-width: 46rem;
  padding-left: var(--space-4); border-left: 2px solid var(--border);
}
.work-hero__actions { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-2); }

.round-block { border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.round-block__head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-3);
  padding: var(--space-3) var(--space-4); background: var(--background);
  border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.round-block__title { font-size: var(--text-sm); font-weight: 600; display: flex; align-items: center; gap: var(--space-2); }
.round-block__body { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }

.timeline { position: relative; display: flex; flex-direction: column; gap: var(--space-5); padding-left: var(--space-5); }
.timeline::before { content: ""; position: absolute; left: 3px; top: 0.5rem; bottom: 0.5rem; width: 1px; background: var(--border); }
.timeline__item { position: relative; }
.timeline__item::before {
  content: ""; position: absolute; left: calc(-1 * var(--space-5) + 0px); top: 0.42rem;
  width: 7px; height: 7px; border-radius: 50%; background: var(--primary);
  box-shadow: 0 0 0 3px var(--background);
}
.timeline__date { font-size: var(--text-xs); color: var(--faint-foreground); font-variant-numeric: tabular-nums; }

.record-card { border-bottom: 1px solid var(--border); padding-block: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.record-card:last-child { border-bottom: none; }
.record-card__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.record-card__meta { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; font-size: var(--text-xs); color: var(--muted-foreground); }
.record-card__meta > span { display: inline-flex; align-items: center; gap: var(--space-1); }
.record-card__note {
  font-size: var(--text-sm); line-height: var(--leading-normal); white-space: pre-wrap;
  font-family: var(--font-display);
}

/*
 * 这里原来是一整段「配图墙」（.photo-wall / .photo-pin / .photo-pin__*）。
 *
 * 整段删掉的原因：那面墙已经从「配图」页去掉了（用户原话「下面那个展开的去掉」
 * 「下面那个没用」）—— 堆栈看的就是同一批照片，而且堆栈和观影记录是连着的，
 * 墙只是把同样的照片再铺一遍。逐张删除的能力挪进了全屏抽屉，功能没丢。
 * 留着一整套没人用的选择器，下一个人会以为还有第二种照片视图。
 */

.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr)); gap: var(--space-2); }
.photo-tile { position: relative; aspect-ratio: 1; border-radius: var(--radius-md); overflow: hidden; background: var(--muted); cursor: zoom-in; }
.photo-tile img { width: 100%; height: 100%; object-fit: cover; }
.photo-tile__flag { position: absolute; top: var(--space-1); left: var(--space-1); }
.photo-tile__del {
  position: absolute; top: var(--space-1); right: var(--space-1);
  width: 1.375rem; height: 1.375rem; border-radius: var(--radius-sm);
  background: rgb(0 0 0 / 0.62); color: #fff; display: grid; place-items: center;
  opacity: 0; transition: opacity var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .photo-tile:hover .photo-tile__del, .photo-tile:focus-within .photo-tile__del { opacity: 1; }
}

.uploader {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2);
  padding: var(--space-6); border: 1px dashed var(--border-strong); border-radius: var(--radius-lg);
  color: var(--muted-foreground); font-size: var(--text-sm); cursor: pointer; text-align: center;
  transition: border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .uploader:hover, .uploader.is-dragover { border-color: var(--primary); background: var(--primary-soft); color: var(--primary); }
}

/* ---------------------------------------------------------------- 时间胶囊 */
.thread {
  position: relative; padding: var(--space-4) 0 var(--space-4) var(--space-5);
  border-left: 2px solid var(--border);
  display: flex; flex-direction: column; gap: var(--space-3);
}
.thread--pending { border-left-style: dashed; border-left-color: var(--primary-line); }
.thread__meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--muted-foreground); }
.thread__content { font-family: var(--font-display); font-size: var(--text-base); line-height: var(--leading-normal); white-space: pre-wrap; }
.thread__answer { border-left: 2px solid var(--primary-line); padding-left: var(--space-3); font-size: var(--text-sm); color: var(--muted-foreground); white-space: pre-wrap; }
.thread__foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }

/* ---------------------------------------------------------------- 问答 */
.question-item { padding: var(--space-4) 0; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: var(--space-3); }
.question-item:last-child { border-bottom: none; }
.question-item__q { font-weight: 600; display: flex; align-items: flex-start; gap: var(--space-2); }
.question-item__a { font-size: var(--text-sm); color: var(--muted-foreground); white-space: pre-wrap; border-left: 2px solid var(--border); padding-left: var(--space-3); }

/* ---------------------------------------------------------------- 伴侣 / 关系 */
.entity-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--space-2); }
.entity-card { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-2); border-radius: var(--radius-md); }
@media (hover: hover) {
  .entity-card:hover { background: var(--muted); }
}
.entity-card__avatar {
  width: 2.125rem; height: 2.125rem; border-radius: var(--radius-md); flex-shrink: 0;
  display: grid; place-items: center; font-weight: 600;
  background: var(--muted); color: var(--muted-foreground); font-size: var(--text-sm);
}
.entity-card__name { font-weight: 500; font-size: var(--text-sm); }
.entity-card__role { font-size: var(--text-xs); color: var(--faint-foreground); }

/* ---------------------------------------------------------------- 图谱 */
/* 工具栏与图例是绝对定位的，必须有这个 relative 容器兜住，
   否则它们会相对视口定位、飘到页面右上角去。 */
.graph-stage { position: relative; }
.graph-shell { position: relative; border: 1px solid var(--border); border-radius: var(--radius-xl); overflow: hidden; background: var(--card); }
.graph-canvas { width: 100%; height: clamp(22rem, 60dvh, 42rem); display: block; touch-action: none; cursor: grab; }
.graph-canvas:active { cursor: grabbing; }
.graph-toolbar {
  position: absolute; top: var(--space-3); right: var(--space-3); display: flex; flex-direction: column; gap: var(--space-1);
  background: color-mix(in srgb, var(--popover) 90%, transparent); backdrop-filter: blur(10px);
  border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-1);
}
/*
 * 关系图那一屏：左图右表单的两栏工作台。
 *
 * 宽度比例写在 CSS 里而不是 inline style 上，窄屏才有得改
 * （内联样式特异性高过样式表，手机上塌不成一栏）。
 */
.relations-split { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--space-5); }

/* ------------------------------------------------------- 关系图 · 全屏查看 */
/*
 * "伪全屏"：铺满视口的一层浮层，不是浏览器的 requestFullscreen。
 *
 * 为什么不用真全屏 API：真全屏会把整个应用（含"完成/清空"这些按钮和
 * 右侧表单）一起放大，用户要的只是"把这张图单独拿出来看大一点"；
 * 而且 iOS Safari 对非视频元素的 requestFullscreen 支持一直不完整。
 *
 * 层次：bar（标题 + ❌）/ body（画布）。stage 是同一个节点被搬进来的，
 * 所以进出的缩放、平移、选中状态都留着。
 */
.graph-fullscreen {
  position: fixed; inset: 0;
  /*
   * 85 这个数是挑出来的，不是随手写的：
   *   要在宿主弹窗之上（--z-modal: 70），否则"铺满视口"会变成"铺满那个弹窗"；
   *   要在 toast 之下（--z-toast: 90）—— 全屏看图时后台同步失败、
   *   "已删除"这类提示必须还能看见，被浮层压住就等于没有；
   *   要在命令面板之下（--z-cmdk: 100），⌘K 仍然能把面板叫到最上面。
   *
   * 这一档给"宿主本身是弹窗"的图（角色关系图）。宿主是页面里一块的
   * （作品星系 / 共看关系网）用下面那个 --page 变体，见那里的说明。
   */
  z-index: 85;
  display: flex; flex-direction: column;
  background: var(--background); color: var(--foreground);
  animation: fa-fade-in var(--dur-normal) var(--ease-out) both;
  /*
   * 底部安全区（iPhone 的小白条）。
   *
   * 不留给它的话画布会一直铺到屏幕最下沿 —— 而那一小条是**系统截走触摸**的，
   * 单手拿手机时拇指正好落在那里：在图上拖拽平移会变成"上滑回桌面"。
   * box-sizing 是 border-box、元素又是 fixed inset:0，所以这条 padding
   * 只是把 .graph-fullscreen__body（flex: 1）挤矮 34px，
   * 浮层总高仍然等于视口高度，不会多出滚动条。
   * 实测（CDP 覆写刘海 47 / 手势条 34）：画布底边从 844 → 810，正好让开手势条。
   */
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
/*
 * 宿主是"页面里的一块"时（作品星系、共看关系网），浮层**故意压在弹窗之下**。
 *
 * 因为图里双击一个节点会开出作品详情弹窗（--z-modal: 70）：浮层要是比它高，
 * 那个弹窗会被整个盖住 —— 用户看到的是"双击没反应"，其实弹窗已经开了、
 * 焦点也被它抓走了，人在一个看不见的对话框里按键盘。
 *
 * 65 仍然高过导航(40)、吸顶条(20)、弹窗遮罩(60)，"盖住应用其他一切"照样成立；
 * 低于 toast(90) 和命令面板(100)，提示和 ⌘K 也不会被吃掉。
 */
.graph-fullscreen--page { z-index: 65; }
.graph-fullscreen__bar {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-2) var(--space-3) var(--space-2) var(--space-5);
  border-bottom: 1px solid var(--border); background: var(--card);
  padding-top: max(var(--space-2), env(safe-area-inset-top));
}
.graph-fullscreen__title { font-size: var(--text-sm); font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.graph-fullscreen__hint { flex: 1 1 auto; min-width: 0; font-size: var(--text-2xs); color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/*
 * ✕ 必须永远贴在右上角。
 *
 * 电脑上把 ✕ 顶到最右边的是它前面那条 hint（flex: 1 1 auto 会吃掉剩余空间），
 * 而手机档把 hint 收掉了（见下面 768 那段）—— 于是窄屏上没有人再吃空间，
 * 这一列就变成"标题 + ✕"从左往右挨着排，✕ 落在半空中：
 * 实测 390 宽下它的右边缘在 217px，而不是贴着 390。
 * margin-inline-start: auto 让 ✕ 自己吃掉剩余空间，与 hint 在不在无关。
 */
.graph-fullscreen__close { flex: 0 0 auto; margin-inline-start: auto; }
/* 画布宿主必须是"有确定高度"的那一层，否则 canvas 的 height:100% 无从计算 */
.graph-fullscreen__body { position: relative; flex: 1 1 auto; min-height: 0; }
.graph-stage--full { position: absolute; inset: 0; }
.graph-stage--full .graph-shell { height: 100%; border: 0; border-radius: 0; }
.graph-stage--full .graph-canvas { height: 100%; }

.graph-legend {
  position: absolute; left: var(--space-3); bottom: var(--space-3);
  display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); max-width: calc(100% - var(--space-6));
  background: color-mix(in srgb, var(--popover) 90%, transparent); backdrop-filter: blur(10px);
  border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3);
  font-size: var(--text-2xs); color: var(--muted-foreground);
}
.legend-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex-shrink: 0; }
/* 群体颜色的小圆点：和图上角色外圈的环是同一个色 */
.group-dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(128,128,128,0.25); }
/* 右侧列表行：指上去有个底色，暗示"这一行和左边联动" */
.relation-row { border-radius: var(--radius-sm); padding-left: var(--space-2) !important; padding-right: var(--space-2) !important; }
@media (hover: hover) {
  .relation-row:hover { background: var(--muted); }
}
/* 角色关系图用圆点大小区分主次，所以图例也要能显示两种大小 */
.legend-dot--lg { width: 0.72rem; height: 0.72rem; }
/* 线的颜色区分关系性质：实线 / 虚线两段，外加一个分隔符和群体光晕示意 */
.legend-line { width: 1.1rem; height: 2px; border-radius: 2px; flex-shrink: 0; }
.legend-line--dash {
  background-image: linear-gradient(90deg, currentColor 0 55%, transparent 55% 100%);
  background-size: 8px 2px; background-repeat: repeat-x;
}
.legend-sep { width: 1px; height: 0.85rem; background: var(--border); }
.legend-hull {
  width: 1rem; height: 0.72rem; border-radius: var(--radius-sm);
  border: 1px dashed var(--muted-foreground);
  background: color-mix(in srgb, var(--muted-foreground) 10%, transparent);
}

.graph-tip {
  position: absolute; pointer-events: none; z-index: 5;
  background: var(--popover); border: 1px solid var(--border); border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-3); font-size: var(--text-xs); box-shadow: var(--shadow-lg);
  max-width: 16rem;
}

/* ------------------------------------------------- 关系图 · 手机竖屏 */
/*
 * 关系图这一屏的手机档规则**只有这一处**（连"两栏塌成一栏"也在），
 * 不散在 mobile.css 里。
 *
 * 理由："这一屏在手机上长什么样"是一件事 —— 几栏、画布多高、工具条横排还是
 * 竖排、全屏那层的标题怎么收 —— 分成两个文件之后，改的人要同时知道
 * "mobile.css 后加载会赢"这条隐含规则，才能判断哪条生效。
 *
 * 位置也有讲究：这一段必须排在**所有 `.graph-*` / `.legend-*` 普通规则之后**。
 * 同特异性时后者胜，写在前面的话上面那条 `.graph-legend { … padding … }`
 * 会把它盖掉（tools/check.mjs 有一条"响应式失效"专门抓这个，第一版就是这么被抓的）。
 */
@media (max-width: 900px) {
  /*
   * 两栏工作台塌成一栏：图在上、表单在下。
   *
   * 不塌的话，390px 宽的手机上左边只剩 ~250px 给画布、右边 ~125px 给表单 ——
   * 图看不清、表单里的输入框窄到打不了字，两边都废。
   * `.relations-split` 这个类名是为它加的：原来宽度比例内联写在 style 上，
   * 内联样式赢过样式表，窄屏根本盖不住。
   */
  .relations-split { grid-template-columns: minmax(0, 1fr); }

  /*
   * 画布高度：竖屏下 60dvh（约 506px）太高 —— 弹窗本身是底部抽屉，
   * 92dvh 里塞进 506px 的图之后，下面"关系表单"基本看不见，
   * 用户得先滚过一整屏图才够得着编辑区。压到 46dvh 让两者同屏。
   */
  .graph-canvas { height: clamp(18rem, 46dvh, 28rem); }

  /*
   * 工具条：电脑上是右上角竖排一列，手机上一列在触控尺寸下要占
   * 4×44 + 间距 ≈ 198px —— 一半的画布。横排一行只占 54px 高
   * （实测三个按钮 150×54），而图的内容本来就在中间，横排更划算。
   */
  .graph-toolbar { flex-direction: row; }
  .graph-legend { gap: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-2); }
}
@media (max-width: 768px) {
  /* 全屏那层的副标题在窄屏上会把标题挤成省略号，直接收掉 */
  .graph-fullscreen__hint { display: none; }
  .graph-fullscreen__bar { padding-left: var(--space-4); }
}

/* ---------------------------------------------------------------- 观影宇宙 */
/* 目录式索引：一行一个去处，比 2x2 大卡片更容易扫读，也不占满首屏 */
.hub-list { border-top: 1px solid var(--border); }
.hub-row {
  display: flex; align-items: flex-start; gap: var(--space-4);
  padding: var(--space-5) var(--space-2);
  border-bottom: 1px solid var(--border);
  transition: background var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .hub-row:hover { background: color-mix(in srgb, var(--foreground) 3%, transparent); }
}
.hub-row__icon { flex-shrink: 0; width: 1.25rem; color: var(--faint-foreground); margin-top: 0.15rem; }
@media (hover: hover) {
  .hub-row:hover .hub-row__icon { color: var(--primary); }
}
.hub-row__body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.hub-row__head { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; }
.hub-row__title { font-family: var(--font-display); font-size: var(--text-lg); font-weight: 600; }
.hub-row__meta { font-size: var(--text-xs); color: var(--faint-foreground); font-variant-numeric: tabular-nums; }
.hub-row__desc { font-size: var(--text-sm); color: var(--muted-foreground); max-width: 42rem; }
.hub-row__go { flex-shrink: 0; align-self: center; color: var(--faint-foreground); transition: transform var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); }
@media (hover: hover) {
  .hub-row:hover .hub-row__go { color: var(--primary); transform: translateX(3px); }
}

/* ---------------------------------------------------------------- 年刊 */
.yearbook {
  border: 1px solid var(--border); border-radius: var(--radius-xl); overflow: hidden;
  background: var(--card);
}
.yearbook__cover {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-3);
  padding: var(--space-10) var(--space-8);
  border-bottom: 1px solid var(--border);
  background: color-mix(in srgb, var(--foreground) 2.5%, var(--card));
}
.yearbook__year {
  font-family: var(--font-display);
  font-size: clamp(3rem, 11vw, 5.5rem); font-weight: 700; line-height: 0.92;
  letter-spacing: -0.04em; color: var(--foreground);
}
.yearbook__title { font-family: var(--font-display); font-size: var(--text-xl); font-weight: 600; }
.yearbook__section { padding: var(--space-6) var(--space-8); border-top: 1px solid var(--border); }
.yearbook__section:first-of-type { border-top: none; }
.yearbook__section-title {
  font-size: var(--text-base); font-weight: 600; margin-bottom: var(--space-5);
  display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap;
}
/*
 * 月度足迹的柱子。
 *
 * 原来这里是内联的 `transition: height …`，**一次都没播过** ——
 * 柱子是每次 render 用 h() 新建的元素，而 render() 内部是
 * container.replaceChildren()（core/dom.js）：旧节点被整体换掉，
 * 新元素没有"旧值"，transition 需要同一元素上的旧值→新值才会触发。
 *
 * 所以改用 @keyframes：动画在元素插入时就会开始播，不需要旧值。
 * animation-fill-mode: both 是关键 —— 它让 element 在 animation-delay 那段
 * 等待期里保持 from 帧（scaleY(0)），否则柱子会先满高闪一下再缩回去。
 *
 * 动 scaleY 而不是 height：height 每帧都要重排这一列的兄弟节点，
 * scaleY 走合成层。柱子的 height 仍然由内联样式给最终值，动画只负责"立起来"这个过程。
 *
 * 时长走 --dur-reveal（tokens.css），它在 prefers-reduced-motion 里会被压到 1ms。
 */
.yearbook__bar {
  transform-origin: bottom;
  animation: fa-bar-rise var(--dur-reveal) var(--ease-out) both;
}
@keyframes fa-bar-rise {
  from { transform: scaleY(0); }
  to { transform: scaleY(1); }
}

.quote-wall { display: grid; grid-template-columns: 1fr; gap: var(--space-4); }
@media (min-width: 640px) { .quote-wall { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .quote-wall { grid-template-columns: repeat(3, 1fr); } }
.quote-card {
  padding: var(--space-4) 0 0; border-top: 2px solid var(--border);
  display: flex; flex-direction: column; gap: var(--space-3);
}
.quote-card__text { font-family: var(--font-display); font-size: var(--text-base); line-height: var(--leading-normal); white-space: pre-wrap; }
.quote-card__from { font-size: var(--text-xs); color: var(--faint-foreground); }

.bar-list { display: flex; flex-direction: column; gap: var(--space-3); }
.bar-row { display: grid; grid-template-columns: minmax(4rem, 9rem) 1fr auto; align-items: center; gap: var(--space-3); font-size: var(--text-sm); }
.bar-row__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted-foreground); }
.bar-row__track { height: 4px; border-radius: var(--radius-full); background: var(--muted); overflow: hidden; }
.bar-row__fill { height: 100%; border-radius: var(--radius-full); background: var(--primary); transition: width var(--dur-slow) var(--ease-out); }
.bar-row__value { font-variant-numeric: tabular-nums; color: var(--faint-foreground); font-size: var(--text-xs); min-width: 2.25rem; text-align: right; }

/* ---------------------------------------------------------------- 档案馆 · 数据栏（原设置页「概览」） */
/*
 * 八个数字 + 三块分布从设置页搬到了档案馆页（js/views/archive.js）。
 *
 * **排版必须在这里重写一遍**：原来那些数字的字号挂在 .settings-page 底下
 * （`.settings-page .settings-stats .stat__value`，那批规则已随「概览」从本文件删掉），
 * 搬到档案馆页之后那些覆盖一条都不再生效 —— 只剩 app.css 里的基础 .stat（text-xl），
 * 八个数字会一起缩小，主次（馆藏作品最大）也跟着没了。
 *
 * 取向照旧：不做"KPI 卡片"，只有数字和细间距 —— 和 app.css 的 .stat-strip 一致
 * （那段注释写着这个应用的数据栏该是"一本刊物的数据栏，而不是仪表盘卡片"）。
 *
 * 展开 / 收起是一段**高度过渡**（js 那边量真实内容高度，见 archive.js 的 slideStats）：
 * 外层只负责高度、被动画改的就是它；内容全在里面那层 __inner 上。
 * 分两层是为了"压到 0 也能量准"—— 外层被压成 0 高时，__inner 照样按自己的内容排。
 */
.archive-stats {
  display: block;
  /*
   * 高度是这一页唯一的过渡属性，而且**只有展开 / 收起时才会被改**：
   * 平常内联样式是空的，高度就是 auto（数据变了高度跟着变，不会被钉死）。
   *
   * 时长与曲线走 --dur-disclose / --ease-in-out，**不是** --dur-slow / --ease-out：
   * 后者前 3 帧就走掉 60%，450px 的面板单帧最多跳 138px（占总高的 31%）——
   * 眼睛读到的就是"内容突然出现"，用户报的正是这个。这一档单帧最多约 8%，
   * 每一帧都看得出空间在长，整段也只要 400ms。
   *
   * 「减少动态效果」下 --dur-disclose 被压到 1ms，js 那边还有一条 REDUCE 短路 ——
   * 两条路都到得了终止态，而且都不是"动得轻一点"，是不动。
   */
  transition: height var(--dur-disclose) var(--ease-in-out);
}
/*
 * 收起时必须是真的收起。
 * 上面给了 display，hidden 属性（UA 的 display:none）会被它盖掉，收起后
 * 会留一个空壳占着位置 —— 和 .settings-panel[hidden] 是同一个坑、同一个写法。
 */
.archive-stats[hidden] { display: none; }

/*
 * 内容那一层：**永远是内容的真实高度**，不参与外层的收缩。
 * 下面这块底距从外层挪到了这里（原来是 .archive-stats 的 margin-bottom）：
 * 挂在外层的话，收起（display: none）时它整块消失、展开时又整块冒出来，
 * 页面在动画之外还会**多跳 20px**；放进内层就成了被量进去的高度，
 * 跟内容一起让出来。外层也因此不该再有外边距。
 */
.archive-stats__inner {
  display: flex; flex-direction: column; gap: var(--space-6);
  padding-bottom: var(--space-5);
  flex: none;
}

/*
 * 数字格。
 * 窄屏两列：馆藏作品（hero）和"看过的"（wide）各占满一整行，其余两两一排。
 * 2 + 2 + 6 = 10 格 = 5 行整，不留空格子。
 */
.archive-stats__grid {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-5) var(--space-5);
}
.archive-stats__hero { grid-column: span 2; }
.archive-stats__wide { grid-column: span 2; }
.archive-stats .stat__value { font-size: var(--text-2xl); }
.archive-stats__hero .stat__value { font-size: var(--text-3xl); }

/*
 * 三块分布。
 * auto-fit + 每块自己定宽（≤34rem）而不是让格子拉伸：
 * 里面是 .bar-row 的进度条，一条拉到 1000px 以上，"谁长谁短"反而看不出来。
 * 宽屏三块并排、窄屏自然落成一列。
 */
.archive-stats__dists {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
  gap: var(--space-6) var(--space-8);
}
.archive-stats__dist { min-width: 0; max-width: 34rem; }
.archive-stats__dist-title { font-size: var(--text-sm); font-weight: 600; margin-bottom: var(--space-3); }

/*
 * 分布里的进度条：标签列收窄一档，数值列照旧。
 * 只写在 .archive-stats__dist 作用域内 —— .bar-row 是年刊页、作品页共用的
 * 基础规则（上面那三条），改它会把那两页一起动掉。
 */
.archive-stats__dist .bar-row {
  grid-template-columns: minmax(3.5rem, 7rem) minmax(0, 1fr) auto;
  gap: var(--space-3);
}

/* 一份数据都没有时的空状态（档案馆页的主状态之一，按正经内容做，不做成一行灰字） */
.archive-stats__empty { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); }
/* 空状态里那两句是"标题 + 说明"（archive.js 里是两个裸 <p>，没有类名）：
   第一行给一层主标题的分量，第二行是被压下去的说明 */
.archive-stats__empty p { font-size: var(--text-sm); color: var(--muted-foreground); max-width: 34rem; text-wrap: pretty; }
.archive-stats__empty p:first-child { font-size: var(--text-base); font-weight: 600; color: var(--foreground); }

/*
 * 摘要行右边那个展开 / 收起按钮。
 * 状态只写在 aria-expanded 上（没有 .is-open 之类的类），所以箭头旋转也认它 ——
 * 一旦有人把它换成 class，这里会立刻看得出来（箭头不动了），不会静默失效。
 *
 * 箭头是 trailing 的（archive.js 用 iconRight 给），所以下面这条选择器不用改：
 * 它认的是按钮里那个 svg，不认它在文字前面还是后面。
 * 转的是 180°（收起朝下、展开朝上），用的是**和面板高度同一个时长与曲线** ——
 * 两个字要一起动完才读成一次状态切换；箭头 110ms 就转完、面板 400ms 才长完的话，
 * 眼睛看到的是两件事，而不是"同一个内容被打开了"。
 *
 * 字号跟摘要同一档（--text-sm = 13px，.btn--sm 默认是 --text-xs = 12px）：
 * 它和那行字读起来得是**同一条线上的东西**。小一号的标签摆在同一行里，
 * 即使基线对齐了，看起来也像"另贴上去的一个小东西"，而不是那行字的一部分。
 *
 * margin-inline 那条负外边距是"对齐"的关键，两件事一起解决：
 *   · 同一行时：按钮自带 13px 横向内边距（12 padding + 1 border），那是**底色**的边距、
 *     不是文字的边距。不抵消的话，摘要文字到「概览」会空出 25px（12 行距 + 13 内边距），
 *     而眼睛只认文字到文字。抵消掉之后，行上的 column-gap 就是看到的那条空档。
 *   · 换行时（手机上按钮掉到第二行）：文字会正好压在行的左边缘上，跟上面那行字对齐 ——
 *     不抵消就是缩进 13px，看着"歪了一点"（用户报的就是这个）。
 * 底色因此是**对称地挂在文字两边**的，hover 不会只往一边扑。
 * 注意：这个 13px 是 .btn--sm 的 padding-inline + .btn 的 1px 边框推出来的，
 * 哪天动了那两个数，这里也要跟着动。 */
.archive-stats-toggle {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font-size: var(--text-sm);
  margin-inline: calc(-1 * (var(--space-3) + 1px));
  color: var(--muted-foreground);
}
@media (hover: hover) {
  .archive-stats-toggle:hover { color: var(--foreground); }
}
/* 和 .archive-stats 的 transition 必须是**同一条**：同一个时长、同一条曲线。
   改一处忘了另一处，箭头和面板就会各走各的（一个转完了一个还在长）。 */
.archive-stats-toggle svg { transition: transform var(--dur-disclose) var(--ease-in-out); }
.archive-stats-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }

/*
 * 宽屏：三列。
 * hero 仍然占两格 —— 2（hero）+ 1（wide）+ 6 = 9 格 = 3×3 整，正好铺满。
 */
@media (min-width: 1024px) {
  .archive-stats__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5) var(--space-6); }
  .archive-stats__wide { grid-column: auto; }
  .archive-stats__hero .stat__value { font-size: var(--text-4xl); }
}

/* ---------------------------------------------------------------- 排列方式 */
/*
 * 工具栏右边那一颗「排列」触发器 + 它下面那块面板（八种排法、两个类别）。
 *
 * 三层高度过渡（面板、两个类别的说明、以及三个箭头）**必须是同一条时长与曲线** ——
 * 那个数只在 tokens.css 的 --dur-disclose 里，js 那边（ui/disclose.js）也只有兜底
 * 一个同名常量。箭头 110ms 就转完、面板 400ms 才长完的话，眼睛看到的是两件事，
 * 而不是"同一个内容被打开了"（「概览」那一轮踩过，注释留在 archive.js 里）。
 *
 * finesse · register=product · shell=档案馆主页不变（页头 + 工具栏 + 网格区）
 * · palette=inherited（一个色值都没加，选项卡只用 --card/--border/--primary*）
 * · type=不改刻度（沿用 --text-xs/sm/base 三档）
 * · layout=C 轴：**同一批数据换八种编排** —— 平面两种（网格 / 列表）+ 空间六种
 * · engine=D 轴：CSS 3D（perspective + preserve-3d）+ 手写 rAF，零依赖、零动画库
 * · material=E 轴：卡片从"平铺的格子"变成**可以被拿起来的物件**（抬起 / 归位 / 转动）
 * · SOUL=6 SPECTACLE=6 DENSITY=7
 */

/* 触发器：和「概览」同一档字号（--text-sm），因为它在工具栏里要跟筛选器读成一行 */
.archive-layout-toggle {
  display: inline-flex; align-items: center; gap: var(--space-2);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}
@media (hover: hover) {
  .archive-layout-toggle:hover { color: var(--foreground); }
}
/*
 * 箭头只转**最后那一颗** svg。
 * 这颗按钮左边还有一个"当前排法"的图标（网格 / 扇形 / 星球…），
 * 写 `svg` 会把两颗一起转 180° —— 那颗图标看着就像倒过来了。
 */
.archive-layout-toggle svg,
.arrange-group__head svg { transition: transform var(--dur-disclose) var(--ease-in-out); }
.archive-layout-toggle[aria-expanded="true"] svg:last-of-type,
.arrange-group__head[aria-expanded="true"] svg { transform: rotate(180deg); }

/* 面板：外层只管高度，内容是 __inner 的真实高度（见 ui/disclose.js 的契约） */
.archive-arrange { display: block; transition: height var(--dur-disclose) var(--ease-in-out); }
/* 收起时必须是真的收起：上面给了 display，hidden 属性（UA 的 display:none）会被它盖掉 */
.archive-arrange[hidden] { display: none; }
.archive-arrange__inner {
  display: flex; flex-direction: column;
  padding-bottom: var(--space-5);
  border-top: 1px solid var(--border);
}

/* 一个类别 = 一行可折叠的说明 */
.arrange-group { border-bottom: 1px solid var(--border); }
.arrange-group__head {
  display: flex; align-items: center; gap: var(--space-3);
  width: 100%; min-height: 2.75rem;      /* 44px：触控下限，手机上这一行要能点中 */
  padding: var(--space-3) 0;
  text-align: start;
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}
@media (hover: hover) {
  .arrange-group__head:hover { color: var(--foreground); }
}
.arrange-group__title { font-weight: 600; color: var(--foreground); }
/* 「现在用的是哪一种」：收起时这一行唯一的实质信息，所以给它正常字色而不是灰的 */
.arrange-group__current { color: var(--muted-foreground); }
/* 箭头钉在整行的最右缘：它是"这一行下面还有东西"的尾巴，不是跟在文字后面的标点 */
.arrange-group__head svg { margin-inline-start: auto; flex: 0 0 auto; color: var(--faint-foreground); }

.arrange-group__body { display: block; transition: height var(--dur-disclose) var(--ease-in-out); }
.arrange-group__body[hidden] { display: none; }
.arrange-group__inner {
  display: flex; flex-direction: column; gap: var(--space-4);
  padding-bottom: var(--space-5);
}
/* 说明文字：一整段话，不切成列表 —— 它回答的是"这两种有什么区别、什么时候用" */
.arrange-group__note {
  font-size: var(--text-sm); color: var(--muted-foreground);
  max-width: 46rem; text-wrap: pretty;
}

/* 选项：一类里的几种排法 */
.arrange-options {
  display: grid;
  /*
   * 10rem 是量出来的：探索排列有 6 种，桌面上这一行正好排满 6 个（一屏看完，
   * 不用横着找）；手机上落成两列、每个 165px，说明文字还读得下去。
   * 写成 13.5rem 的话桌面上每行只放得下 5 个，第 6 个（波浪矩阵）会孤零零换行。
   */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
  gap: var(--space-2);
}
.arrange-option {
  display: flex; align-items: flex-start; gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--border); border-radius: var(--radius-lg);
  background: var(--card); color: var(--foreground);
  text-align: start;
  transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .arrange-option:hover { border-color: var(--border-strong); }
}
/* 当前这一种：主色边框 + 主色底 + 一颗勾。三样一起说，不靠单一颜色 */
.arrange-option.is-active { border-color: var(--primary-line); background: var(--primary-soft); }
.arrange-option__icon { flex: 0 0 auto; margin-top: 1px; color: var(--muted-foreground); }
.arrange-option.is-active .arrange-option__icon { color: var(--primary); }
.arrange-option__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.arrange-option__label { font-size: var(--text-sm); font-weight: 600; }
.arrange-option__hint { font-size: var(--text-xs); color: var(--muted-foreground); text-wrap: pretty; }
.arrange-option__check {
  flex: 0 0 auto; margin-inline-start: auto; color: var(--primary);
  opacity: 0; transition: opacity var(--dur-fast) var(--ease-out);
}
.arrange-option.is-active .arrange-option__check { opacity: 1; }

/* ---------------------------------------------------------------- 空间舞台 */
/*
 * 六种探索排法的画布。
 *
 * 舞台高度按排法定：扇形要向上散开、螺旋要往上盘，都比波浪矩阵高一截。
 * 用 dvh 而不是 vh —— 手机地址栏收放时 vh 不会变，舞台会比屏幕还高。
 *
 * 透视给**相机层**（.stage__camera）、preserve-3d 给里面那层：一个灭点，
 * 整片卡片在同一个空间里。卡片各自给透视的话，每张的灭点都不一样，
 * 一屏卡片像被拧过（app.css 的 .grid-auto 那段注释是同一个道理）。
 *
 * 相机层隔出来是为了**平移**：把整台构图摆到舞台正中，得在透视**外面**做，
 * 否则位移会跟着每张卡的 z 一起被透视除掉（前后两张移的量不一样 = 把它拧斜）。
 */
.stage-wrap { display: flex; flex-direction: column; gap: var(--space-2); }
:root {
  /*
   * 「扇形展开那一档的舞台高度」提成变量：它是**选中那张的基准大小**的来源
   * （见 js/ui/stage.js 的 focusCardWidth）。
   *
   * 为什么 JS 不自己写一遍 clamp(...dvh...)：dvh 到底等于多少浏览器说了算，
   * 手机上还随地址栏伸缩 —— JS 里那份必然对不上（实测移动模拟里 window.innerHeight
   * 报的是内容高度，1212/1041/886，每种排法一个数，于是基准也跟着每种排法一个数）。
   * 所以 JS 挂一个用这条变量的隐藏元素去**量**。
   */
  --stage-h-fan: clamp(19rem, 54dvh, 32rem);
  /*
   * 「圆柱环绕那一档」的舞台高度 —— **电脑端选中那张的基准**，也是所有排法的基础高度
   * （圆柱自己不覆盖 --stage-h，用的就是这一条）。
   * 用户的原话：「电脑端最好的应该是圆柱，因为圆柱这个位置和大小应该是最好的……
   * 就把它拉正，就像圆柱这样的一个位置和大小。」
   * 圆柱和扇形一样，卡片高都占自己舞台高的 66%，所以两档的差别只在这条高度上。
   */
  --stage-h-cylinder: clamp(18rem, 50dvh, 30rem);
  /*
   * 「波浪矩阵那一档」的舞台高度 —— 手机端"卡片大小以波浪为准"那条要用它去算波浪的卡片。
   * 用户的原话：「我说的是波浪吧？我说的是跟波浪的那个大小都一样……每个统一就统一。」
   */
  --stage-h-wave: clamp(17rem, 48dvh, 29rem);
  /*
   * 「球面那一档」的舞台高度 —— **电脑端"统一卡片大小"的基准**（球面的半径从它算）。
   * 用户：「电脑端就以球面那个大小为主。」
   */
  --stage-h-sphere: clamp(20rem, 50dvh, 30rem);
}

/* 只用来量的探针：和扇形展开的舞台一样高，不参与排版也不接指针 */
.stage-probe {
  position: absolute; left: -9999px; top: 0; width: 1px;
  height: var(--stage-h-fan);
  visibility: hidden; pointer-events: none;
}
.stage-probe--wave { height: var(--stage-h-wave); }
.stage-probe--sphere { height: var(--stage-h-sphere); }

.stage {
  /*
   * 舞台高度：**这一轮整体调高了一档**（用户："探索排列这些布局都有点小"）。
   *
   * 六种几何里，半径和卡片大小都是从舞台的宽高里倒推的 —— 舞台矮，整台东西就小。
   * 现在按视口高度给（dvh），桌面上大约半屏、手机上大约半屏多一点；
   * 上限卡住是因为再高就会把分页条整个推到屏幕外，反而不好翻页。
   */
  --stage-h: var(--stage-h-cylinder);
  position: relative;
  height: var(--stage-h);
  /*
   * 手机上竖向还能滚页面，横向才归拖动。
   * 写成 none 的话，手指落在卡片上就滚不动页面了 —— 那是最容易被当成"页面卡住"的一种写法。
   */
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.stage[data-arrangement="fan"] { --stage-h: var(--stage-h-fan); }
.stage[data-arrangement="depth"] { --stage-h: clamp(18rem, 52dvh, 30rem); }
.stage[data-arrangement="sphere"] { --stage-h: var(--stage-h-sphere); }
.stage[data-arrangement="helix"] { --stage-h: clamp(20rem, 56dvh, 33rem); }
.stage[data-arrangement="wave"] { --stage-h: var(--stage-h-wave); }

.stage__camera {
  position: absolute; inset: 0;
  /*
   * 相机与 space **不接指针**：整台构图被 scale 放大、又被相机平移之后，
   * 它们的**命中盒子**会跟着变大、可能盖到工具栏上 ——
   * 表现就是"点了某个排法之后，档案馆的按钮全点不动了"。
   * 只留 .stage 自己那一块（正好是舞台的大小）接指针。
   */
  pointer-events: none;
  /* 透视在这一层：它同时也是"把构图平移到正中"的那一层（纯屏幕位移，见 stage.js） */
  perspective: 1200px;
  perspective-origin: 50% 42%;
  transform-style: preserve-3d;
  transition: transform var(--dur-normal) var(--ease-out);
}
.stage__space {
  position: absolute; inset: 0;
  pointer-events: none;      /* 同上：接指针的只有 .stage 那一块 */
  transform-style: preserve-3d;
  /* 拖动 / 惯性期间这一条会被 .is-dragging 关掉，否则卡片慢半拍（跟手才是手感） */
  transition: transform var(--dur-normal) var(--ease-out);
}
.stage.is-dragging { cursor: grabbing; }
.stage.is-dragging .stage__space,
.stage.is-dragging .stage__card { transition: none; }

/*
 * 每档"飞到正中"的那一下，用的节奏**跟着几何走**（用户："因为排版不同，所以做的交互
 * 也是不同的……每次点开它都会到一样的位置，但确实动作是不一样的……这个动作是根据
 * 排版来的，没有强行加绚丽的动作"）。
 *
 * 动作本身就来自起始矩阵的差别：转盘上的三种要从**旋转的面上甩出来**（同时把朝向转正），
 * 扇形要从**弧上抬起来并转正**，纵深是**从自己那一层往前抽**，波浪是**从起伏里浮起来**。
 * 这里只调时长与缓动，让这四种"手感"各自站得住：
 *   · 转盘三种（圆柱/球面/螺旋）：走得久一点，缓动带一点过冲 —— "甩出来、再定住"
 *   · 扇形：中等，稳稳收住
 *   · 纵深：短而干脆 —— 往前抽不需要铺垫
 *   · 波浪：**不覆盖**，用默认那档。它每帧都在重画 transform（起伏），
 *     拉长会让整片浪糊掉。
 */
.stage { --focus-dur: var(--dur-normal); --focus-ease: var(--ease-out); }
.stage[data-arrangement="fan"] { --focus-dur: 260ms; }
.stage[data-arrangement="depth"] { --focus-dur: 220ms; }
.stage[data-arrangement="cylinder"] { --focus-dur: 340ms; --focus-ease: cubic-bezier(0.3, 1.2, 0.5, 1); }
.stage[data-arrangement="sphere"] { --focus-dur: 360ms; --focus-ease: cubic-bezier(0.3, 1.2, 0.5, 1); }
.stage[data-arrangement="helix"] { --focus-dur: 340ms; --focus-ease: cubic-bezier(0.3, 1.2, 0.5, 1); }

/*
 * 一张卡。
 *
 * 定位靠 left/top 50% + 自己的 `translate(-50%,-50%)`（transform 由 js 拼），
 * 所以旋转、外推、抬起全在一句话里，不会和定位打架。
 * 宽度由 js 按量到的舞台宽度写内联（见 stage.js 的 measure），这里只给一个兜底。
 */
.stage__card {
  position: absolute; left: 50%; top: 50%;
  display: block; width: 8rem;
  border-radius: var(--radius-md);
  /*
   * **卡片不接指针事件**，点击统一落到舞台上，由 js 自己算命中
   * （见 ui/stage-math.js 的文件头：Chrome 在 preserve-3d 里只认得出最前面那一张卡，
   * 实测 12 张里只有 1 张点得中）。
   * 键盘不受影响：pointer-events: none 只挡指针，Tab 与 Enter 照旧。
   */
  pointer-events: none;
  transition: transform var(--focus-dur) var(--focus-ease), opacity var(--dur-normal) var(--ease-out);
}
/* 不转的那三种：整块舞台都是"可以点的卡片"，指针就是手 */
.stage[data-arrangement="fan"], .stage[data-arrangement="depth"], .stage[data-arrangement="wave"] { cursor: pointer; }
/* 能拖的三种：默认张开手，拖住时握起来（.is-dragging 在下面） */
.stage[data-arrangement="cylinder"], .stage[data-arrangement="sphere"], .stage[data-arrangement="helix"] { cursor: grab; }
.stage__card .poster { box-shadow: var(--shadow-md); }
.stage__card.is-focused .poster { box-shadow: var(--shadow-xl); }
.stage__card:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
/*
 * 背对镜头的卡片要真的看不见。
 * 圆柱 / 球面 / 螺旋是**围起来**的：转过去的那一半背面朝人，
 * 不隐藏的话它们会以镜像的样子透在前面 —— 遮挡关系（"远处被近处挡住"）就假了。
 */
.stage[data-arrangement="cylinder"] .stage__card,
.stage[data-arrangement="sphere"] .stage__card,
.stage[data-arrangement="helix"] .stage__card { backface-visibility: hidden; }
/*
 * 选中的那一张抬起来之后，其余的往后退一档。
 * 只给三种"平面感"的排法（扇形 / 纵深 / 波浪）：它们本来就靠前后堆叠区分，
 * 而圆柱那三种的远近由 3D 自己说，再压一层透明度就浑浊了。
 */
.stage[data-arrangement="fan"] .stage__card.is-behind,
.stage[data-arrangement="depth"] .stage__card.is-behind,
.stage[data-arrangement="wave"] .stage__card.is-behind { opacity: 0.6; }

/* 卡面上的片名：字很小是因为卡片本身只有 58–132px 宽，标题只是"这是哪一部"的线索 */
.stage__card-title { font-size: var(--text-2xs); font-weight: 600; line-height: var(--leading-snug); }
.stage__card-overlay { gap: var(--space-1); }

/*
 * 舞台底下那一行：没选中时是手势提示，选中后换成片名 + 「详情」。
 *
 * 高度**常驻**（min-height），不是"出现时把页面往下推一下"——
 * 舞台是个固定高度的盒子，脚下一行忽有忽无会让整页跳一格。
 */
.stage__bar { display: flex; align-items: center; min-height: 2.75rem; }
.stage__hint { font-size: var(--text-xs); color: var(--muted-foreground); }
.stage__focus { display: flex; align-items: center; gap: var(--space-3); width: 100%; }
.stage__focus-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stage__focus-title { font-size: var(--text-sm); font-weight: 600; }
.stage__focus-meta { font-size: var(--text-xs); color: var(--muted-foreground); }
.stage__focus-hint { font-size: var(--text-xs); color: var(--faint-foreground); margin-inline-start: auto; }
.stage__detail { flex: 0 0 auto; }
@media (max-width: 640px) {
  /* 手机上这一行留给片名和按钮：那句"再点一次…"挤不下，交互提示已在上面的 act 里说过 */
  .stage__focus-hint { display: none; }
}

/* ---------------------------------------------------------------- 回收箱 */
.trash-item { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-2); border-bottom: 1px solid var(--border); }
.trash-item:first-child { border-top: 1px solid var(--border); }
.trash-item__poster { width: 2.5rem; flex-shrink: 0; border-radius: var(--radius-sm); overflow: hidden; }
.trash-item__body { flex: 1 1 auto; min-width: 0; }

/* ---------------------------------------------------------------- 设置 */
.settings-section { display: flex; flex-direction: column; gap: var(--space-5); padding: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); }
.settings-section__title { font-size: var(--text-base); font-weight: 600; }
.settings-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
.settings-row__label { font-size: var(--text-sm); font-weight: 500; }
.settings-row__desc { font-size: var(--text-xs); color: var(--muted-foreground); margin-top: 2px; }

.accent-picker { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.accent-swatch {
  width: 1.875rem; height: 1.875rem; border-radius: var(--radius-md); position: relative;
  border: 1px solid rgb(0 0 0 / 0.12);
  transition: transform var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .accent-swatch:hover { transform: scale(1.08); }
}
.accent-swatch[aria-checked="true"] { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--foreground); }
.accent-swatch[aria-checked="true"]::after {
  content: ""; position: absolute; inset: 0;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / 58% no-repeat;
  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.55));
}

.avatar-edit { position: relative; width: 4rem; height: 4rem; flex-shrink: 0; }
.avatar-edit__img { width: 100%; height: 100%; border-radius: var(--radius-lg); object-fit: cover; background: var(--muted); }
.avatar-edit__fallback { width: 100%; height: 100%; border-radius: var(--radius-lg); display: grid; place-items: center; background: var(--muted); color: var(--muted-foreground); font-size: var(--text-xl); font-family: var(--font-display); font-weight: 600; }
.avatar-edit__btn {
  position: absolute; right: -6px; bottom: -6px; width: 1.5rem; height: 1.5rem; border-radius: var(--radius-md);
  background: var(--foreground); color: var(--background); display: grid; place-items: center;
  border: 2px solid var(--card);
}
/* ==========================================================================
   设置页 · #/profile
   --------------------------------------------------------------------------
   这一段的类名**全部挂在 .settings-page 底下**（这个页面自己的根，见
   js/views/profile.js 的 renderPage）。这一点是刻意的：invite.js 也在用
   .settings-section / .settings-row，它的样子由上面那几条基础规则说了算，
   这里一条都不改 —— 设置页换了皮，邀请页一个字不动。

   配色只用现有令牌（--card / --border / --primary* / --destructive）——
   三套主题 + 可换主色都得成立，所以"层次"是靠间距、不透明度、1px 线和
   玻璃令牌做出来的，不引入第二套色板。

   骨架：**一张清单 + 一次只开一块**。
   手机  清单是整页，点一行 → 清单收起、那一块占满（.settings-page[data-pushed="true"]）
   电脑  清单缩成左边一列，右边直接是选中那块（同一份 DOM，两种摆法）
   九块内容一直挂在那儿，只切 hidden —— 所以切来切去不丢任何正在编辑的东西。

   finesse · register=product · shell=master-detail（清单 + 面板）
   · palette=inherited(tokens) · SOUL=5 SPECTACLE=2 DENSITY=7
   ========================================================================== */

/* ---------------------------------------------------------------- 页头 */
/*
 * 这里原本有一条 `.page-head:has(+ .settings-page)` 覆盖，用来修一个布局事故：
 * 带返回的页头是两个 flex item（「‹ 返回」+「标题」），被 space-between 推到
 * 同一行两端，标题因此右对齐、和下面左对齐的清单打架。
 *
 * 现在**根因没了**：设置页页头不再放返回（理由见 profile.js 里那段注释 ——
 * 它和底栏/侧栏的「设置」重复，又落在手机上最容易误触的左上角，还会把
 * "敲回车才存"的输入静默丢掉）。页头只剩「标题 + 副标题」一个 flex item，
 * space-between 对单个子元素不起作用，标题自然贴左缘，不需要任何覆盖。
 *
 * 邀请页、回收箱、作品页、年刊、伙伴页仍带返回，那边保持原样、一行未动。
 */

/* ---------------------------------------------------------------- 页面骨架 */
/*
 * ============================================================ 视觉语言
 *
 * 这一页前三轮（v37 五区索引 / v38 换骨架 / v39 收窄单列）都在解决**秩序**：
 * 先把长滚动切成一屏清单，再把宽度收到 40rem、把行高压到 56px。
 * 秩序对了，但用户仍然说「太普通、没有重点、没什么新奇」——
 * 因为三轮的 soul/spectacle 都取的 5/2，也就是每轮都主动选了"最没个性"那一档，
 * 配色一律 inherited、排版一个字没动。秩序不等于设计。
 *
 * 这一轮补的是**视觉语言**，方向是「接触印相样张（contact sheet）」：
 *   · 胶片是**黑的**，不是米色做旧的。所以配色仍然全部继承（三主题 × 8 主色
 *     必须自动成立），个性做在层级、排版、结构上，不靠发明一套新配色 ——
 *     这也避开了本项目记录在案的"二阶默认：档案馆=做旧（米色纸+牛皮纸+衬线大标题）"。
 *   · **等宽字承担元数据**：像样张边上的片号、拍摄参数。所有小标签统一走
 *     --font-mono + 大字距 + 小字号，它们一眼就和正文分开，读者知道那是"标注"。
 *   · **全站一支黑体**（--font-display 已改成黑体，见 tokens.css）：
 *     宋体被用户否掉了 —— 那是写字的字体，界面字号下笔画会糊，
 *     而且和正文混排像两套东西拼的。层级改由字号与字重承担。
 *   · **编号是结构，不是装饰**：每块面板顶上有个片号（设置 · 01 / 08），
 *     编号来自 CSS 计数器，随板块增删自动走，不需要在 JS 里维护一份序号。
 *   · 分隔用发丝线（1px --border），不用卡片套卡片 —— 盒子只会让一页重新变挤。
 */
.settings-page { display: block; }
.settings-page__panels { min-width: 0; }

/*
 * 这一页通用的"标注"字样：等宽、小、字距拉开、颜色退后。
 * 单独抽出来是因为片号、小节标题、字段名、快捷键名、存储分组名都要用它 ——
 * 全页只有一种"标注腔调"，才像同一份文档。
 */
.settings-page .settings-block__title,
.settings-page .settings-field__head > .settings-field__label,
.settings-page .storage-group__title,
.settings-page .settings-shortcuts > * > span:first-child {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted-foreground);
}

/* ---------------------------------------------------------------- 清单 */
/*
 * 清单不只是目录，**它是这一页的状态板**：每行右边写着这一项现在的样子
 * （「浅色 · 铜绿」「已使用 1.3 MB」「记录只在这台设备上」）。
 * 一屏之内就知道自己叫什么、什么主题、占了多少、登没登录、TMDB 配没配、
 * 馆藏多大 —— 这些信息原来散在四千多像素的长滚动里。
 *
 * v40 的样子：不再是一张"卡片里塞八行"，而是**一张索引**。
 * 行与行之间一条发丝线，外面那圈边框去掉 —— 去掉盒子的那一刻，
 * 页面就从"一堆控件"变成"一份文档"。
 *
 * （v44 曾给每行加过一个 01…08 的片号，v45 删掉：见下面那段说明。）
 */
.settings-page .settings-nav__list {
  display: flex; flex-direction: column;
}

/*
 * 位置读数（"你看到第几块"）。**只有电脑档有**，样子在下面 ≥1024px 那一段。
 *
 * 手机上这里必须整个不显示：手机档的清单是一整页、点进去面板头就写着
 * 「设置 · 02 / 08」，同一件事在同一个视口里说两遍。手机和电脑是同一份
 * DOM 的两种摆法，所以"关掉"写在基础规则里、由电脑档那条打开，
 * 而不是反过来 —— 反过来的话手机档会先按电脑档画一帧（那正是这段布局
 * 一直在躲的事）。
 */
.settings-page .settings-nav__position { display: none; }
.settings-page .settings-nav__item {
  position: relative;
  display: flex; align-items: center; gap: var(--space-3);
  width: 100%; min-height: 3.5rem;
  padding: var(--space-3) 0;
  text-align: left; color: inherit;
  border-bottom: 1px solid var(--border);
  transition: background var(--dur-fast) var(--ease-out);
}
.settings-page .settings-nav__item:last-child { border-bottom: 0; }
@media (hover: hover) {
  .settings-page .settings-nav__item:hover { background: var(--accent); }
}
/* 全宽的列表行用内缩的焦点环（base.css 那条是 offset +2，会把环压到相邻行上） */
.settings-page .settings-nav__item:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }

/*
 * 片号：**只留在面板里，清单上不留**。
 *
 * 清单原来也有一列 01…08。那是形式主义 —— 面板头上「设置 · 02 / 08」
 * 有意义（你在第几块、一共几块），而清单上那个号**什么也不回答**：
 * 它不是步骤、不是优先级、也没有顺序含义，八行读下来只是多了八个数字。
 * 用户直接点名了这一处。删掉之后每行的左缘回到文字本身，也更安静。
 *
 * 计数器和 ::before 整块撤掉；下面 .settings-nav__item 的内边距与 gap
 * 相应收一档（少了 20px 的号位列 + 12px 的间距）。
 */

/*
 * 分组：只留空白，不加行、不加标题、不改结构。
 *
 * 清单连成一片"实心墙"的原因不是行高，是**每一行都长得一模一样、行距也一样**：
 * 眼睛找不到可以停下来的地方。按用途切成四段 —— 你自己（个人信息 / 外观）、
 * 数据与资料（存储 / 备份 / 账号 / 影片资料）、更多与关于、危险操作。
 *
 * 分组点挑的是 storage / about / danger 这三行，**没有挂在概览（stats）上** ——
 * 概览那一行已经搬去档案馆页（见 .archive-stats）。
 */
.settings-page .settings-nav__item[data-view="storage"],
.settings-page .settings-nav__item[data-view="about"],
.settings-page .settings-nav__item[data-view="danger"] {
  margin-top: var(--space-5);
  border-top: 1px solid var(--border);
  padding-top: var(--space-4);
}
/*
 * 分组交界处只能有一条线。
 *
 * 分组行自己加了 border-top，而它上面那一行仍有 border-bottom —— 两行之间
 * 隔着 --space-5(20px) 的空白，于是同一个分组边界画出**两条并排的发丝线**
 * （放大截图里能直接数出来，读起来像漏删了一条）。
 * 行挨着行（间距 0）时两条线重合看不出来，只有分组处才暴露。
 *
 * 收紧办法：让"分隔行"自己那条线负责，把紧邻它的上一行的下边线收掉。
 * 用 :has() 一次覆盖三个分组点，不必各写一份。:has() 在本项目有先例
 * （mobile.css 的 body:has(.bulkbar)）。
 */
.settings-page .settings-nav__item:has(+ .settings-nav__item[data-view="storage"]),
.settings-page .settings-nav__item:has(+ .settings-nav__item[data-view="about"]),
.settings-page .settings-nav__item:has(+ .settings-nav__item[data-view="danger"]) {
  border-bottom-color: transparent;
}

/*
 * 图标退到"标记"的位置：比正文暗、比片号亮，不参与竞争。
 * 原来它是 32px 的实底方块，八行排下来是一片灰格子墙 —— 和"安静"是反的。
 * 现在只留字形本身，底色全去；当前项才给它主色。
 */
.settings-page .settings-nav__icon {
  flex: 0 0 auto; display: grid; place-items: center;
  width: 1.5rem; height: 1.5rem;
  color: var(--faint-foreground);
  transition: color var(--dur-fast) var(--ease-out);
}
.settings-page .settings-nav__text { flex: 1 1 auto; min-width: 0; }
.settings-page .settings-nav__label {
  display: block; font-size: var(--text-base); font-weight: 500;
  letter-spacing: -0.005em;
  /* 紧凑行高是行高压到 56px 的另一半：默认 1.62 的行盒把这一行顶到 24px */
  line-height: var(--leading-snug);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/*
 * 摘要走等宽 —— 它写的是"当前值"（浅色 · 铜绿 / 1.3 MB / 25 部），
 * 本质是元数据。等宽字让八行的数字列天然对齐（tabular-nums），
 * 也让它和上面的名称一眼分开，不必靠颜色区分。
 */
.settings-page .settings-nav__summary {
  display: block; margin-top: 2px;
  font-family: var(--font-mono);
  font-size: var(--text-2xs); color: var(--muted-foreground);
  /*
   * 字距只给拉丁文。其它"标注"（小节标题、字段名）是纯 CJK 或短词，
   * 拉开的字距好看；但摘要里常有「邀请朋友 · 回收箱 · 快捷键」这种长中文串，
   * 0.12em 会把 11px 的字撑宽约 8%，正是英文档那几行被切掉的最后一根稻草。
   * 这里收到 0.02em —— 等宽的"标注腔调"靠字体本身就能立住，不需要字距帮忙。
   */
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  line-height: var(--leading-snug);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.settings-page .settings-nav__chevron { flex: 0 0 auto; color: var(--faint-foreground); }

/*
 * 当前那一行。
 *
 * 编号转主色 + 名称加重 + 一点底色，就够了 —— 全页只有一处是"当前"。
 *
 * 试过再加一根行首的主色竖线，**实测是错的**：它和编号抢同一块位置，
 * 把数字压成了「)7」，而且两个标记说同一件事。app.css 的注释里本来就
 * 点名否掉"侧边彩色竖条"那套做法 —— 这次是实测证明了它，不是照搬规则。
 */
.settings-page .settings-nav__item[aria-current="true"] { background: var(--secondary); }
.settings-page .settings-nav__item[aria-current="true"]::before { color: var(--primary); }
.settings-page .settings-nav__item[aria-current="true"] .settings-nav__label { font-weight: 600; }
.settings-page .settings-nav__item[aria-current="true"] .settings-nav__icon { color: var(--primary); }

/* 危险操作那一行：一眼能认出来，但不刺眼 */
.settings-page .settings-nav__item--danger .settings-nav__icon { color: var(--destructive); }
.settings-page .settings-nav__item--danger .settings-nav__label { color: var(--destructive); }

/* ---------------------------------------------------------------- 面板 */
.settings-page .settings-panel[hidden] { display: none; }
.settings-page .settings-panel { display: flex; flex-direction: column; gap: var(--space-6); }

/*
 * 面板头：一块的"抬头"。
 *
 * 上面那条通栏的 ::before 是**片号**（设置 · 01 / 08），由下面 panel__index 那个
 * 元素提供内容；这一层负责把它排成一小行等宽标签，再让标题以大字压住版面。
 *
 * 标题走 --font-display，而它现在是**黑体**（见 tokens.css 里那段说明：
 * 用户否掉了宋体）。层级不靠换字形，靠字号与字重 —— 标题 24px/600
 * 压在 13px 的正文之上，对比足够，不需要另一支字体来撑。
 */
.settings-page .settings-panel__head {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding-bottom: var(--space-4);
  border-bottom: 1px solid var(--border);
}
.settings-page .settings-panel__index {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  /*
   * 字距只给数字和斜杠，**不摊到中文上**。
   *
   * 这条原来是 0.14em 的整串字距。西文小字拉开字距是对的（那是"标注"的腔调），
   * 但「设置 · 02 / 08」里有中文，CJK 字面本来就填满字身，再拉 0.14em
   * 就成了「设 置 · 02 / 08」——放大看是散的。
   * 现在把字距下沉到那个数字片段自己的元素上（.settings-panel__count），
   * 中文按原样排。
   */
  letter-spacing: normal;
  /* 数字对齐：等宽字撤掉之后，2/8 这类数字要靠 tabular-nums 才不会左右跳 */
  font-variant-numeric: tabular-nums;
  color: var(--faint-foreground);
}
.settings-page .settings-panel__count { letter-spacing: 0.14em; }
.settings-page .settings-panel__title {
  font-family: var(--font-display);
  font-size: var(--text-2xl); font-weight: 600; letter-spacing: 0.01em;
  line-height: var(--leading-tight);
  text-wrap: balance;
  /* 它是被程序聚焦的（切面板时把读屏/键盘用户送到这儿），不是 Tab 到的一站，
     所以不画焦点环 —— "你在哪"由整块面板换掉来说 */
  outline: none;
}

/*
 * 面板内的返回。
 *
 * 手机上是**这一级的返回**（回清单）。页头那个「返回」已经删掉（见 profile.js
 * 里那段说明：和底栏/侧栏的「设置」重复、落在最容易误触的左上角）。
 * 触控目标按 44px 起（手机上这类"上一级"必须够得着）。
 */
.settings-page .settings-panel__back {
  display: inline-flex; align-items: center; gap: var(--space-1);
  align-self: flex-start; min-height: 2.75rem;
  margin-left: calc(var(--space-2) * -1); padding: 0 var(--space-2);
  border-radius: var(--radius-md);
  font-size: var(--text-sm); color: var(--muted-foreground);
  transition: color var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .settings-page .settings-panel__back:hover { color: var(--foreground); }
}
.settings-page .settings-panel__body {
  display: flex; flex-direction: column; gap: var(--space-8); min-width: 0;
  /*
   * 单列正文：正文最宽 40rem，贴内容区左缘。
   *
   * 这里**不写断点**是故意的 —— max-width 只在"容器比 40rem 还宽"时才起作用，
   * 窄窗口和手机天然就是满宽，写进媒体查询反而要多维护一套。
   *
   * 原来这条上限是 46rem，而且写在 ≥1024px 那一档的
   * `.settings-panel:not(.settings-panel--stats)` 里。那个 :not() 是死选择器：
   * js 里没有任何一处加过 settings-panel--stats（panel() 只加 --danger），
   * 所以它等于全部面板；留着只会把这里的 40rem 盖掉。
   */
  max-width: 40rem;
}

/* ---------------------------------------------------------------- 块 */
/*
 * 面板里的每一块不再自带盒子：分隔靠间距和一条细线。
 * 类名仍是 .settings-section（别的页面也在用这个类名，这里只做页面内的覆盖）。
 *
 * 间距节奏（这一页统一按这四档，不再处处 --space-5 / --space-6）：
 *   组内 8px  标签 → 控件（.settings-field）
 *   组间 12px 同一块里的几个小节（这里）
 *   块间 20px 一个表单里的几件事（.settings-fields）
 *   大块 24px 面板里的一块 → 下一块（.settings-panel__body）
 */
.settings-page .settings-section {
  display: flex; flex-direction: column; gap: var(--space-3);
  padding: 0; border: 0; border-radius: 0; background: none;
}
.settings-page .settings-section__note {
  font-size: var(--text-xs); color: var(--muted-foreground);
  line-height: var(--leading-normal); text-wrap: pretty;
}
.settings-page .settings-block__title { font-size: var(--text-sm); font-weight: 600; }

/* ---------------------------------------------------------------- 事实行 */
/*
 * 「标签 — 一句状态 — 动作」的一行。
 *
 * 解决的是**面板里空**：备份那块原来只有一段说明 + 五个按钮平铺，
 * 却占着 640×500 的空场，上半截堆一坨、下面全空（用户说"里面还是很一般"）。
 * 根因不是排版，是**内容没有形状** —— 五个按钮是平权的，谁在管什么看不出来。
 *
 * 现在每一行自带三样：等宽小标签（这一行管什么）、一句**由数据算出来的**状态、
 * 以及挂在这一行上的动作。于是不点进面板也能从状态那句话知道现在什么情况，
 * 而按钮不再是一坨，它们各自有归属。
 *
 * 这一行不是列表项，是**读数**：标签在左、动作在右、状态贴着动作，
 * 中间留白由 flex 吸收（和 .storage-row 同一套阅读顺序，但更紧凑）。
 * 类名用 settings-fact 而不是 settings-row —— 后者在本文件 607 行起是
 * **别的页面也在用的基础规则**，不能占。
 */
.settings-page .settings-facts { display: flex; flex-direction: column; }
.settings-page .settings-fact {
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
}
.settings-page .settings-fact:last-child { border-bottom: 0; }
/*
 * 标签和状态都贴顶，不垂直居中。
 *
 * 有一行的状态是整句说明（会折成两行），居中之后那个小标签会浮在两句之间，
 * 和上下两行的标签不在一条线上 —— 一列标签歪了，整张表就散了。
 * 贴顶之后三个标签左缘、顶缘都在一条线上，第二行起就读成一张真正的表。
 */
.settings-page .settings-fact__label {
  flex: 0 0 auto; width: 4.5rem; align-self: flex-start;
  padding-top: 1px;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.02em;
  color: var(--muted-foreground);
}
.settings-page .settings-fact__value {
  flex: 1 1 auto; min-width: 0;
  font-size: var(--text-sm);
  line-height: var(--leading-snug);
  overflow-wrap: anywhere;
}
.settings-page .settings-fact__actions {
  flex: 0 0 auto; display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap;
  align-self: flex-start;
  justify-content: flex-end;
}
/*
 * 状态里带一个头像 + 两行（名字在上、状态在下）时用这两个：
 * 名字要有分量，状态要退后 —— 和整页"标签弱、读数强"的层级一致。
 * 两者都必须 display:block，否则它们是行内 span，会挤成一行。
 */
.settings-page .settings-fact__strong { display: block; font-weight: 600; }
.settings-page .settings-fact__sub { display: block; font-size: var(--text-xs); color: var(--muted-foreground); margin-top: 1px; }
/* 手机档：标签独占一行，动作换到下面，别把一行挤成三截 */
@media (max-width: 768px) {
  .settings-page .settings-fact { flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
  .settings-page .settings-fact__label { width: auto; }
  .settings-page .settings-fact__value { flex-basis: 100%; order: 3; }
  .settings-page .settings-fact__actions { flex: 1 1 auto; justify-content: flex-start; }
}

/* ---------------------------------------------------------------- 快捷键 */
/*
 * 这一段原来**一条规则都没有**。
 *
 * .settings-shortcuts 是个裸 div（display:block），里面九行 .row-between 各自为政：
 * 左边一个标签、右边一颗键帽，键帽宽度随内容变（实测 89 / 58 / 30 / 17px）。
 * 右缘虽然都贴到面板右边，左缘却参差不齐 —— 就是"键帽在右侧竖着叠、
 * 无对齐无容器"那个观感；而且九行之间一点缝都没有（22px × 9 连成一片）。
 *
 * 现在每一行是两列网格：名称 1fr、键帽 auto 且固定宽度 ⇒ 九颗键帽左右两缘都对齐。
 * 键帽是"等宽字 + 内边距 + 淡底 + 细边"，底色走 --muted、边线走 --border，
 * 三套主题都成立；不是透明底的描边方框。
 */
.settings-page .settings-shortcuts { display: grid; gap: var(--space-1) 0; }
.settings-page .settings-shortcuts > * {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: center; gap: var(--space-3);
}
.settings-page .settings-shortcuts kbd {
  width: 6.5rem; text-align: center; white-space: nowrap;
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--muted); color: var(--foreground);
  font-family: var(--font-mono); font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
}

/* 表单块（标题在上、控件在下，整行宽） */
.settings-page .settings-field { display: flex; flex-direction: column; gap: var(--space-2); }
.settings-page .settings-field__head { display: flex; flex-direction: column; gap: 2px; }
.settings-page .settings-fields { display: flex; flex-direction: column; gap: var(--space-5); }
.settings-page .settings-note { display: flex; flex-direction: column; gap: var(--space-2); }
/* 块内间距由 flex gap 管，分隔线自己不再上下各留一段 */
.settings-page .divider { margin-block: 0; }

/* ---------------------------------------------------------------- 签名块 */
/*
 * 这一页唯一的"展示"时刻。昵称本来就要署在年刊的落款上，
 * 所以给它最大的字号、最重的字重 —— 全页只有这一处是"展示"。
 */
.settings-page .settings-signature { display: flex; align-items: center; gap: var(--space-4); }
.settings-page .settings-signature__text { min-width: 0; }
.settings-page .settings-signature__name {
  display: block;
  font-family: var(--font-display);
  font-size: var(--text-2xl); font-weight: 600;
  line-height: var(--leading-tight); letter-spacing: -0.01em;
  overflow-wrap: anywhere;   /* 长昵称不许把面板撑破 */
}
.settings-page .settings-signature__hint {
  font-size: var(--text-xs); color: var(--muted-foreground); margin-top: var(--space-1);
}

/* ---------------------------------------------------------------- 外观 */
/*
 * 主题：**三张圆角卡片 → 一条编号条目**。
 *
 * 原来这里是三张 112×52 的圆角卡（图标 + 居中标签），是 iOS 设置 /
 * shadcn 示例里出现频率最高的那一款。它不算难看，问题在于**它和左边刚建立
 * 的索引腔调毫无关系**：左边是等宽片号 + 发丝线 + 无边框，右边突然变成三个
 * 带边框的圆角盒子 —— 一页里两套语言，整屏最"通用"的就是这一处。
 *
 * 现在它和清单说同一种话：三个**挨着排的条目**，各自带一个等宽片号
 * （01 / 02 / 03，CSS 计数器），条目之间用发丝线分，没有边框、没有圆角、
 * 没有居中 —— 内容左对齐，和清单行同一个左缘。
 *
 * 类名仍是 .segmented / .segmented__item —— 冒烟按它找「浅色 / 深色 / 午夜黑」。
 */
.settings-page .settings-section--appearance .segmented--themes {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0; width: 100%; max-width: 26rem;
  padding: 0; background: none; border-radius: 0;
  counter-reset: theme-index;
  border-top: 1px solid var(--border);
}
.settings-page .settings-section--appearance .segmented__item {
  position: relative;
  display: flex; align-items: center; gap: var(--space-3);
  min-height: 3rem; padding: var(--space-3) var(--space-2) var(--space-3) 0;
  border: 0; border-radius: 0;
  background: none; color: var(--muted-foreground);
  font-size: var(--text-sm);
  text-align: left;
  white-space: normal;   /* 基础规则是 nowrap，条目里要允许换行 */
}
/* 片号：和左侧清单同一个计数器手法、同一个宽度节奏 */
.settings-page .settings-section--appearance .segmented__item::before {
  counter-increment: theme-index;
  content: counter(theme-index, decimal-leading-zero);
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  color: var(--faint-foreground);
  transition: color var(--dur-fast) var(--ease-out);
}
.settings-page .settings-section--appearance .segmented__label { text-align: left; }
/* 图标退成标记，和清单行里的图标一个位置、一个分量 */
.settings-page .settings-section--appearance .segmented__item svg { flex: 0 0 auto; opacity: 0.85; }
.settings-page .settings-section--appearance .segmented__item[aria-selected="true"] {
  color: var(--foreground); font-weight: 600; box-shadow: none;
}
.settings-page .settings-section--appearance .segmented__item[aria-selected="true"]::before { color: var(--primary); }
.settings-page .settings-section--appearance .segmented__item[aria-selected="true"] svg { color: var(--primary); opacity: 1; }
@media (hover: hover) {
  .settings-page .settings-section--appearance .segmented__item:hover { color: var(--foreground); }
}

/* ---------------------------------------------------------------- 语言 */
/*
 * 语言：**主题那条编号轨道，原样再用一次**。
 *
 * 改之前这里是一个原生 <select>，套在 .settings-row 里 —— 那个结构是
 * "标签在左、控件被 space-between 推到最右"，于是下拉框的左缘（实测 x≈1270）
 * 比上面主题条目的左缘（x=614）差了六百多像素。一页里两个"左缘"，
 * 上面是编号轨道、下面是默认控件，读起来就是"只改了主题那一行"。
 *
 * 现在它和主题同源到骨头里：
 *   · 同一个 .settings-field 结构 —— 标签在上、控件在下、贴同一条左缘
 *   · 同一条 26rem 轨道（左右两缘都和上面的主题条目对齐）
 *   · 同一套 CSS 计数器片号 01 / 02 / 03（等宽、tabular-nums、--faint-foreground）
 *   · 同一条发丝线起头，无边框、无圆角、无居中，内容左对齐
 *   · 选中态同一套：片号转 --primary、文字转 --foreground 加粗
 *
 * **为什么是三列而不是三行**
 * ① 三个语言名是短词（4~7 字符）。三列在 320px 下每列 90.7px、内容约 76px，
 *    实测不换行、不溢出；换成三行则要把这一块从 48px 抬到 144px，
 *    手机上一屏里平白多出一大段纵向滚动 —— 和"简约"正好相反。
 * ② 语言本来就是三选一的互斥选择，和主题同构。两条一模一样的轨道叠着，
 *    读起来是"同一套系统"；一条轨道 + 三个大行反而像两种不同控件。
 * ③ 三列让"01 / 02 / 03"的节奏横向连成一条，和上面主题的 01 / 02 / 03
 *    是同一行读法，视线不用重新学。
 *
 * 类名**刻意不叫 .segmented__item**：那是主题和分段按钮的类名，
 * 冒烟按它找「浅色 / 深色 / 午夜黑」，这里混进去会把断言搞乱。
 */
.settings-page .settings-section--appearance .language-picker {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0; width: 100%; max-width: 26rem;
  padding: 0; background: none; border-radius: 0;
  counter-reset: language-index;
  border-top: 1px solid var(--border);
}
.settings-page .settings-section--appearance .language-option {
  position: relative;
  display: flex; align-items: center; gap: var(--space-3);
  min-height: 3rem; padding: var(--space-3) var(--space-2) var(--space-3) 0;
  border: 0; border-radius: 0;
  background: none; color: var(--muted-foreground);
  font-size: var(--text-sm);
  text-align: left;
  white-space: normal;   /* 和主题条目一样：窄处宁可换行也不许撑破 */
}
/* 片号：和主题、和左侧清单同一个计数器手法、同一个宽度节奏 */
.settings-page .settings-section--appearance .language-option::before {
  counter-increment: language-index;
  content: counter(language-index, decimal-leading-zero);
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  color: var(--faint-foreground);
  transition: color var(--dur-fast) var(--ease-out);
}
.settings-page .settings-section--appearance .language-option__label { text-align: left; }
.settings-page .settings-section--appearance .language-option[aria-checked="true"] {
  color: var(--foreground); font-weight: 600;
}
.settings-page .settings-section--appearance .language-option[aria-checked="true"]::before { color: var(--primary); }
@media (hover: hover) {
  .settings-page .settings-section--appearance .language-option:hover { color: var(--foreground); }
}

/*
 * 主色：每个色块底下写出名字。
 * 原来是一排没有名字的色点，用户得挨个点一下、或者把鼠标停上去看 title
 * 才知道哪个是"铜绿"、哪个是"黛蓝"；顺带把触控目标从 30px 抬到 44px。
 * 类名 .accent-swatch 原样保留（冒烟按它数色块、按第 4 个切主色）。
 *
 * 五列等宽：原来 auto-fill 在 736px 里排出 12 列、九个色块挤成一行，
 * 每个只占 54px 的格子 —— 又密又不对称（第 10~12 列永远空着）。
 * 改成 5 列 × 2 行（5+4）之后每个格子的位置是固定的、行数也是整的；
 * 整排给 24rem 的上限，色块 40px 居中，名字在下面。
 *
 * 色块仍然是 <button>、九个一个不少、全部都看得见 —— 冒烟那两条断言的前提。
 * 选中态沿用原来的双环 + 白勾：格子有 70px 宽，40px 色块外面留得下 4px 环，不会被裁。
 */
.settings-page .accent-picker {
  display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: var(--space-3) var(--space-2);
  max-width: 24rem;
}
.settings-page .accent-cell { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); min-width: 0; }
/*
 * 色名走等宽小字，和这一页其它"标注"同一腔调。
 * 它们是**数据的名字**（铜绿 / 黛蓝 / 赭石），不是正文。
 */
.settings-page .accent-cell__name {
  font-family: var(--font-mono);
  font-size: var(--text-2xs); color: var(--muted-foreground);
  letter-spacing: 0.02em;
  text-align: center; line-height: 1.25;
}
.settings-page .accent-swatch { width: 2.5rem; height: 2.5rem; }

/* 主色样例：换主题 / 换主色，这里当场跟着变（用的就是这一页自己的变量） */
/*
 * 主色预览。
 *
 * 原来这里是三个抽象色块（实底胶囊 / 淡底胶囊 / 一条进度条）配两个内部术语标签
 * 「主要动作」「当前项」—— 用户直接问"这个是啥意思，是不是错了"。问题不在配色，
 * 在**受众**：那两个词是开发者的说法。而且三个裸色块不构成"预览"，
 * 它只是在陈列色值。
 *
 * 现在它是一个**缩微的真实界面**：一个带框的预览窗，里面三行分别是
 * 真按钮、真选中态（左侧主色标记 + 淡底）、真进度条。用这一页自己的令牌画，
 * 换主题 / 换主色当场跟着变，但它读起来是"这些东西会变成这个颜色"，
 * 而不是"这里有三种色块"。
 *
 * 预览窗的边框用发丝线、圆角走 --radius-lg —— 和这一页其它块同一个几何，
 * 不引入新的形状语言。
 */
.settings-page .settings-sample { display: flex; flex-direction: column; gap: var(--space-3); }
.settings-page .settings-sample__frame {
  display: flex; flex-direction: column; gap: var(--space-3);
  padding: var(--space-4);
  border: 1px solid var(--border); border-radius: var(--radius-lg);
  background: var(--background);
}
.settings-page .settings-sample__row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
/* 真按钮的样子：实底主色 */
.settings-page .settings-sample__btn {
  display: inline-flex; align-items: center; min-height: 2rem; padding: 0 var(--space-4);
  border-radius: var(--radius-md);
  background: var(--primary); color: var(--primary-foreground);
  font-size: var(--text-xs); font-weight: 600;
}
/* 次要动作：描边，不抢主色 */
.settings-page .settings-sample__ghost {
  display: inline-flex; align-items: center; min-height: 2rem; padding: 0 var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid var(--border-strong); color: var(--muted-foreground);
  font-size: var(--text-xs);
}
/*
 * 选中态：左侧一根主色短标 + 淡底。
 * 这一根是**真实的状态标记**（列表里被选中的那一行就是这样），
 * 不是给每个条目挂一条当装饰 —— 预览里只有一行是选中的。
 *
 * 短标用 markup 里那个 <i>，**不用 ::before**：这一格是 inline-flex 的
 * 行内元素，::before 的高度在行内格式化上下文里会塌掉（实测那根标不出现）。
 * 换成真实子元素 + 定高，稳。
 */
.settings-page .settings-sample__item {
  display: inline-flex; align-items: center; gap: var(--space-2);
  min-height: 2rem; padding: 0 var(--space-4) 0 var(--space-3);
  border-radius: var(--radius-md);
  background: var(--primary-soft); color: var(--foreground);
  font-size: var(--text-xs); font-weight: 600;
}
.settings-page .settings-sample__dot {
  flex: 0 0 auto; width: 2px; height: 0.875rem;
  border-radius: var(--radius-full); background: var(--primary);
}
.settings-page .settings-sample__track {
  flex: 1 1 4rem; min-width: 3rem; height: 6px;
  border-radius: var(--radius-full); background: var(--muted); overflow: hidden;
}
.settings-page .settings-sample__track i { display: block; height: 100%; width: 60%; border-radius: var(--radius-full); background: var(--primary); }
.settings-page .settings-sample__caption { font-size: var(--text-xs); color: var(--muted-foreground); }

/* ---------------------------------------------------------------- 存储 */
/*
 * 分组：.storage-group 是「本机 / 云端」这两组。
 * app.css 给 `.storage-group + .storage-group` 留了 20px 上边距，而这里
 * .settings-section 的 flex gap 又有 12px —— 两者叠加成 32px，比"块间"还大。
 * 组间的距离交给 gap 一处管，把 app.css 那条在这个页面里收掉。
 * 行的内边距/高度**一个没动**：那是触控尺寸（app.css 在触屏档还会给 44px 下限）。
 */
.settings-page .storage-group + .storage-group { margin-top: 0; }

/*
 * 存储行：标签用等宽、数值退到最暗、说明压到最紧。
 *
 * 这张表是"标签在左、数字在右"，中间那条空白是左右对齐的必然结果，去不掉。
 * 能治的是**谁在抢戏**：原来标签 13px/500、数值 13px/--muted-foreground，
 * 两者一样重，中间那段空白就显眼；现在标签走等宽小字（它是"字段名"）、
 * 数值走等宽 11px + --faint-foreground（它是"读数"），视线自然从标签跳到数字，
 * 中间那段路就不再是一块空场。
 *
 * 顺带把说明和标签的间距从 2px 收到 0 —— 两行小字之间那 2px 是"松散"的来源。
 * 行的内边距/高度仍然一个没动：那是触控尺寸（app.css 在触屏档还会给 44px 下限）。
 */
.settings-page .storage-row__label {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--foreground);
}
.settings-page .storage-row__note {
  margin-top: 1px;
  font-size: var(--text-2xs);
  line-height: var(--leading-snug);
}
.settings-page .storage-row__value {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--faint-foreground);
  font-variant-numeric: tabular-nums;
}
/* 窄处不许被长标签/长数值撑破（.storage-row__main 的 min-width: 0 在 app.css 里已经有了） */
.settings-page .storage-row__label,
.settings-page .storage-row__value { overflow-wrap: anywhere; }
.settings-page .settings-storage { display: flex; flex-direction: column; }
.settings-page .settings-storage__head { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
.settings-page .settings-storage__label { font-size: var(--text-xs); color: var(--muted-foreground); }
.settings-page .settings-storage__used {
  font-size: var(--text-2xl); font-weight: 600; letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums; line-height: var(--leading-tight);
}
.settings-page .settings-storage__quota { font-size: var(--text-xs); color: var(--muted-foreground); }
/*
 * 组成条：按浏览器给的分类把这一条切开。
 * 各段共用主色、只用不透明度分层 —— 不引入第二套配色。
 */
.settings-page .storage-meter { display: flex; height: 8px; margin-top: var(--space-3); }
.settings-page .storage-meter__seg { height: 100%; background: var(--primary); }
.settings-page .storage-meter__seg + .storage-meter__seg { border-left: 1px solid var(--card); }

/* ---------------------------------------------------------------- 危险操作 */
/*
 * 单独一块、单独一行、单独一种底色，**说明留在外面、不折叠** ——
 * 这一句是帮人判断"点下去会怎样"的，藏进弹层就等于没有。
 * 底色由 --destructive 派生，不是新配色。
 */
.settings-page .settings-section--danger {
  border: 1px solid color-mix(in srgb, var(--destructive) 30%, var(--border));
  background: color-mix(in srgb, var(--destructive) 5%, transparent);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
}
/* 「建议先导出完整备份」那句必须够得着：导出入口就在警告下面 */
.settings-page .settings-danger__prep {
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  padding-top: var(--space-1);
}

/* ---------------------------------------------------------------- 入场与切换 */
/*
 * 两个不同性质的东西，别混在一起：
 *
 *   ① 整页入场（.settings-page--enter）—— 第一次画这一页时，清单和面板浮起一次。
 *      保存昵称、改完密码、导完备份都会走 renderPage()（整页重建），那时候再演
 *      一遍就成了干扰，所以只在第一次跑（profile.js 里 painted 那个开关）。
 *
 *   ② 换块的推入（.settings-panel--enter）—— 每一次**真的换了另一块**都播。
 *
 * ② 为什么不能只靠 CSS：面板是一直挂在 DOM 里的（刻意的，拆 DOM 会抹掉敲到
 * 一半的输入），所以从 hidden 变可见时元素本身没有"新"可言，浏览器不会重播
 * animation。profile.js 的 replayPanelEnter() 摘掉类、强制回流、再挂回去。
 * 这个仓库踩过同类的坑：年刊月份柱原来用 transition 做出场，而节点每次新建，
 * transition 需要同一元素上的旧值→新值，所以永远不播。
 *
 * ③ 方向由 .settings-page[data-dir] 决定：点在你下面那一块，内容从下往上推入；
 *    点上面那一块，从上往下。**方向跟着操作走**，不是每次都同一个淡入 ——
 *    后者看三次就退化成噪音，而方向本身在告诉你"我在往下走"。
 *    transform 用 --space-3（12px）而不是更大的位移：设置页是"换一块看"，
 *    不是"翻一页"，位移大了就变成表演。
 */
.settings-page--enter .settings-nav__list {
  animation: settings-enter var(--dur-slow) var(--ease-out) both;
}
.settings-page--enter .settings-panel:not([hidden]) {
  animation: settings-enter var(--dur-normal) var(--ease-out) both;
}
@keyframes settings-enter {
  from { opacity: 0; transform: translateY(8px); }
}

/* 换块的推入：方向由 data-dir 给 */
.settings-page[data-dir="down"] .settings-panel--enter {
  animation: settings-push-down var(--dur-normal) var(--ease-out) both;
}
.settings-page[data-dir="up"] .settings-panel--enter {
  animation: settings-push-up var(--dur-normal) var(--ease-out) both;
}
@keyframes settings-push-down {
  from { opacity: 0; transform: translateY(var(--space-3)); }
}
@keyframes settings-push-up {
  from { opacity: 0; transform: translateY(calc(var(--space-3) * -1)); }
}
/* 手机档退回清单：清单从下面接回来（和"退出"同一个方向感） */
.settings-list--enter {
  animation: settings-push-up var(--dur-normal) var(--ease-out) both;
}

/*
 * 手感：按下时缩一点，松手弹回。
 *
 * 这是"触感"而不是"装饰"—— 它回答的是"我这一下按到没有"。
 * 0.97 是能感觉到、又不会让文字发虚的量；90ms 比 --dur-fast(110ms) 还短，
 * 因为按下必须在手指离开之前就已经有反应。
 *
 * 只给**真正是"操作"的东西**：清单行、主题条目、语言条目、色块，
 * 快捷键那一列不动（那是只读说明）。按钮组件自己有 :active，这里不重复管。
 *
 * 用 :active 而不是"按下/松开"两个事件：触屏和鼠标的按下语义一致，
 * 而且键盘激活（回车/空格）不会误触发 —— 那种情况由 :focus-visible 表达。
 */
.settings-page .settings-nav__item:active,
.settings-page .settings-section--appearance .segmented__item:active,
.settings-page .settings-section--appearance .language-option:active,
.settings-page .accent-swatch:active {
  transform: scale(0.97);
  transition: transform 90ms var(--ease-out);
}
/* 松手回弹走同一条过渡，但慢一点 —— 弹回要比按下柔 */
.settings-page .settings-nav__item,
.settings-page .settings-section--appearance .segmented__item,
.settings-page .settings-section--appearance .language-option,
.settings-page .accent-swatch {
  transition: transform var(--dur-fast) var(--ease-out),
              background var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}

/* ---------------------------------------------------------------- 设置页 · 手机档 */
/*
 * 这一档必须写在这里、不能只靠 mobile.css：
 * 上面那些规则带 .settings-page 前缀，特异性高过 mobile.css 里的
 * .settings-section 基础规则，那边改不动它。
 *
 * 手机上是"清单 → 点进去"两级：没进去时面板全收（连当前那块也收），
 * 进去了清单收起来。桌面档反过来，两栏并排 —— 见下面那一段。
 */
@media (max-width: 1023px) {
  .settings-page[data-pushed="false"] .settings-panel { display: none; }
  .settings-page[data-pushed="true"] .settings-nav { display: none; }
  /*
   * 这里原来还有一条 `data-pushed="true" .settings-panel:not([hidden])` 的
   * `settings-panel-in` 动画。**它现在删掉了**：那条的特异性高过上面
   * `.settings-page[data-dir] .settings-panel--enter`，会把方向推入整个盖住 ——
   * 实测手机上拿到的动画名是 settings-panel-in，方向白算了。
   *
   * 手机档的语义本来就该是"往上推进去"（从清单点进某一块），而 data-dir
   * 在点下面那块时算出来正是 'down' → settings-push-down，方向一致。
   * 一个动画、一个来源，不留第二套。
   */
}
@media (max-width: 768px) {
  .settings-page .settings-signature { gap: var(--space-3); }
  .settings-page .settings-storage__used { font-size: var(--text-xl); }
  .settings-page .settings-section--danger { padding: var(--space-4); }
  /*
   * 存储明细的说明：桌面上是单行省略号（那是 app.css 的基础规则），
   * 手机上 342px 放不下 —— 硬截断之后剩下的字没有用。放宽到两行再省略：
   * 换得开、也撑不破（行内还有 min-width: 0 兜底）。
   */
  .settings-page .storage-row__note {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    white-space: normal; overflow: hidden;
  }
}

/* ---------------------------------------------------------------- 设置页 · 电脑档 */
/*
 * ≥1024px 起用主从两栏（和 app.css 里"桌面才出现左侧导航"是同一个断点）：
 * 左边清单跟着滚，右边是选中那块。这时候不需要"推入"，
 * 所以 data-pushed 在桌面上不起作用 —— 清单永远在，返回那条也藏掉。
 */
@media (min-width: 1024px) {
  .settings-page {
    /*
     * 清单列宽 = **内容自己决定，上限 20rem**：
     *   minmax(16rem, 20rem) —— 窄的时候不塌，宽的时候最多长到 320px。
     *
     * 为什么不是拍一个死值：16rem(256px) 时文字列只有 160px，中文够（最长
     * 「邀请朋友 · 回收箱 · 快捷键」约 153px），**英文不够** —— 同一行英文要
     * 239px。而摘要这一轮才改成等宽字，等宽比原来的 12px 黑体更宽，于是
     * backup / account 两行也被一起压到线外。
     * 18rem 能救回那两行（184.75 < 192 ✓），但救不回 about 那一行。
     * 20rem 给到 224px —— 中文留白从 10px 涨到 70px，英文也能进得来。
     *
     * 上限 320px 是容器算出来的：main 内容区 1192px，减去面板 40rem(640px)
     * 和栏间 --space-8(32px) 剩 520px —— 清单占 320px 还有很多余量，
     * 不会挤到正文（正文自己的上限才是 640px 那条，这里改的是左边）。
     */
    display: grid; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr);
    gap: var(--space-8); align-items: start;
  }
  .settings-page .settings-nav {
    position: sticky; top: var(--space-6);
    z-index: var(--z-sticky);   /* 顶栏在桌面是 display:none，这里压不到它 */
  }
  .settings-page .settings-panel__back { display: none; }

  /*
   * ======================================================= 位置读数（电脑档）
   *
   * 左边这一列是这一页的地图，"你在第几格"就该写在地图上 —— 所以片号
   * 从面板头搬到了清单头上。同一屏里只有一个位置读数：
   * 面板头那个「设置 · 02 / 08」在电脑档收起来（手机档反过来，见上面那段）。
   *
   * 读数本身是一条**八格数字带**（js/views/profile.js 的 navPosition），
   * 这里只给窗口一行高、把带子按 --pos 平移。换块 = 带子滚一格，
   * 从 02 到 05 会真的经过 03、04。时长和面板推入同一个 --dur-normal。
   *
   * 为什么不做"滚动时跟着更新"：实测这一页在电脑档滚不动 ——
   * 清单 scrollHeight 恒等于 clientHeight，1440×960 整页也装得下，
   * 全文档没有一个可滚容器。没有滚动这个输入，就不该有一个跟着滚的读数。
   */
  .settings-page .settings-nav__position {
    display: flex; align-items: baseline; justify-content: flex-end; gap: 0.4em;
    /*
     * 和面板头那个片号同一套标注腔调（--font-mono / text-2xs / 字距 / faint），
     * 全页只有一种"标注"看起来才像同一份文档。这里全是数字和斜杠，
     * 字距可以照 0.14em 拉开（中文那种"被拆散"的问题不存在）。
     *
     * 注意 --font-mono 现在**不再是等宽字**（全站统一成同一支黑体，令牌名保留）。
     * 所以这一串数字的左右对齐完全靠下面那行 tabular-nums —— 去掉它，
     * 01 和 08 的宽度就会不一样，滚动时整串数字会左右抖。
     */
    font-family: var(--font-mono);
    font-size: var(--text-2xs);
    letter-spacing: 0.14em;
    font-variant-numeric: tabular-nums;   /* 01…08 同宽，滚的时候不左右跳 */
    color: var(--faint-foreground);
    padding-bottom: var(--space-2);
    border-bottom: 1px solid var(--border);
    --count-row: 1.15em;   /* 一格的"行高"，窗口高度和平移步长共用这一处 */
  }
  .settings-page .settings-nav__position-window {
    display: block; height: var(--count-row); overflow: hidden;
    /* 只露一行，上下七格被裁掉 —— 平移多少都在这个窗口里演 */
  }
  .settings-page .settings-nav__position-track {
    display: flex; flex-direction: column;
    transform: translateY(calc(var(--pos) * var(--count-row) * -1));
    transition: transform var(--deck-beat) var(--deck-ease);
  }
  .settings-page .settings-nav__position-track > span {
    display: block; height: var(--count-row); line-height: var(--count-row);
  }

  /*
   * 刻度轨**已删除**（曾经在这里：.settings-nav__list::before 画一条竖轨、
   * .settings-nav__item::after 在八行中点各画一记刻度）。
   *
   * 当时它被论证成"读数的坐标系"，几何上也确实自洽（八记刻度与八行中心
   * 逐行相差 ≤1px、轨道收在首尾刻度之间、落在栏间距里不碰面板）。
   * **但那个论证答错了问题**：它证明的是"它不像一张随手画的分隔线"，
   * 证不了"用户不会把它读成滚动条" —— 而这个列表**根本不能滚**
   * （1440×960 实测整页 scrollHeight===clientHeight、全文档零个可滚容器）。
   * 一个长在可滚动位置上的、不可滚动的轨道，读错一次就是撒谎的控件。
   *
   * 更要紧的是：用户上一轮刚刚否掉过同一类东西 —— **「外面大分类不需要编号，
   * 这个没有意义」**。那八记刻度就是同一类形式主义：每一行的位置已经由
   * "哪一行亮着"在说了，刻度只是把它再说一遍。
   *
   * 所以只留上面那个读数（03 / 08）—— 它是真的功能：它回答"我在第几块、
   * 一共几块"，而这件事原本要用户自己数列。删掉轨和刻度之后，清单列右缘
   * 回到干净的空白，栏间距里没有任何东西。
   */

  /* 位置读数只有一个来源 —— 电脑档把面板头那个片号收起来（手机档仍归它） */
  .settings-page .settings-panel__index { display: none; }


  /*
   * 正文宽度这里不再重复声明 —— 由 .settings-panel__body 的 max-width: 40rem 一处管。
   * （原来这一档里写着 46rem，还挂在 `.settings-panel:not(.settings-panel--stats)` 上；
   *  那个类名 js 里从没出现过，等于给所有面板写了另一条上限，把上面那条盖掉。
   *  "看数据的面板占满"那半边随「概览」一起搬去档案馆页了，见 .archive-stats ——
   *  原来这里那五条 .settings-stats / .settings-dists 的宽屏覆盖也一并删了。）
   */
}

/* ---------------------------------------------------------------- 命令面板 */
.cmdk-wrap { position: fixed; inset: 0; z-index: var(--z-cmdk); display: flex; justify-content: center; padding-top: min(12dvh, 7rem); padding-inline: var(--space-4); }
.cmdk-wrap .overlay { z-index: -1; }
.cmdk {
  width: 100%; max-width: 32rem; height: fit-content; max-height: 68dvh;
  display: flex; flex-direction: column; overflow: hidden;
  background: var(--popover); border: 1px solid var(--border); border-radius: var(--radius-xl);
  box-shadow: var(--shadow-xl); animation: fa-pop var(--dur-normal) var(--ease-out) both;
}
.cmdk__input-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); border-bottom: 1px solid var(--border); }
.cmdk__input { flex: 1 1 auto; background: none; border: none; font-size: var(--text-base); outline: none; }
.cmdk__list { overflow-y: auto; padding: var(--space-2); }
.cmdk__group-label { font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint-foreground); padding: var(--space-2) var(--space-3); }
.cmdk__item {
  display: flex; align-items: center; gap: var(--space-3); width: 100%; text-align: left;
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm);
  color: var(--foreground);
}
.cmdk__item[data-active="true"] { background: var(--muted); }
.cmdk__item kbd { margin-left: auto; }
.cmdk__empty { padding: var(--space-8); text-align: center; color: var(--muted-foreground); font-size: var(--text-sm); }

/* ---------------------------------------------------------------- 图片预览 / 照片抽屉 */
/*
 * 灯箱升级成"照片抽屉"：上半部照片、下缘暗角压白色大字、下半部抽屉推上来。
 *
 * 入场一律用 @keyframes，**不用 transition**：
 * openLightbox 每次调用都是 h('div', …) 新建 root 再题 document.body.appendChild(root)，
 * 新建的元素没有"旧值"，transition 永远不会触发 —— 年刊页月份柱刚在同一个坑里栽过
 * （见 pages.css 的 fa-bar-rise 与 js/views/yearbook.js 的注释）。
 *
 * 用 flex 竖排而不是 grid 居中：抽屉要贴底，照片要在上半部，两者是上下关系。
 */
.lightbox {
  position: fixed; inset: 0; z-index: var(--z-cmdk);
  background: var(--photo-backdrop);
  display: flex; flex-direction: column; overflow: hidden;
  animation: fa-fade-in var(--dur-fast) var(--ease-out) both;
  /*
   * 全屏浮层的左右让位（刘海/圆角）走内边距，抽屉再用负外边距顶回全宽。
   *
   * 为什么不让 .lightbox 直接吃 safe-area：mobile.css 已经给 .lightbox 加了
   * padding-left/right: max(--space-4, env(safe-area-inset-*)) —— 那是给"大图留白"
   * 用的，改成 padding: 0 会连带把照片的左右留白也干掉。所以这里补上同样的值，
   * 抽屉用 calc(-1 * …) 抵掉，照片仍然吃得到留白，抽屉照样贴边。
   */
  --lb-pad-l: max(var(--space-4), env(safe-area-inset-left, 0px));
  --lb-pad-r: max(var(--space-4), env(safe-area-inset-right, 0px));
  padding-left: var(--lb-pad-l);
  padding-right: var(--lb-pad-r);
}
/*
 * 照片区：占满抽屉上方剩下的全部空间，照片在其中居中、不裁切。
 *
 * flex-grow: 1 —— 让照片区吃掉抽屉之外的所有高度，抽屉才会真正落在屏幕底部。
 * 一开始写成 flex: 0 1 auto 时，抽屉只跟在照片下面，屏幕底下空出一大块背景色
 * （实测桌面 900×800 时抽屉底只到 458，下面 342px 全是空的）。
 *
 * flex-shrink: 1 + min-height: 0 —— 视口矮的时候先压缩照片区（照片是
 * max-height + contain，缩了只是变小，不丢内容），把空间让给抽屉：
 * 抽屉里是文字，被压扁就没法读了。
 */
/*
 * 这里原来有 .lightbox__stage / .lightbox__img / .lightbox__scrim / .lightbox__caption
 * 四条，以及照片落定的 fa-photo-settle 关键帧 —— 它们都被**叠放堆栈**取代了：
 * 舞台换成 .deck__stage，大图换成 .deck__card 里的 .deck__img，
 * 暗角白字换成卡片内的 .deck__scrim / .deck__caption（见上面那一段）。
 * 五处 JS 引用全部为 0，所以整段删掉而不是留着 —— 留着下一个人会以为还有两套照片视图。
 * .lightbox__flag 也一并删：票根角标在堆栈里是 .deck__flag。
 */
/*
 * 抽屉：从下方推入 + margin-top: -22px 静态压住照片底边（规范原话）。
 *
 * margin 只做静态层叠 —— 动画走 translateY，绝不拿 margin 做动画（那会逐帧重排）。
 * z-index 抬到暗角之上，让压边那一圈圆角真的盖住照片与暗角。
 */
.lightbox__drawer {
  position: relative; z-index: 2;
  /*
   * flex-basis: auto —— 先按内容高度要空间，但被 max-height 封顶；
   * flex-shrink: 0 —— 封顶之后不再往后缩。缩的是照片区（现在是 .deck__stage），
   * 因为照片变小不丢内容，抽屉里的文字被压扁就没法读了。
   * 这样"内容很长"时高度恒定 42dvh 并从内部滚，不会把自己顶出屏幕。
   */
  flex: 0 0 auto; min-height: 0;
  max-height: 42dvh;
  margin-top: -22px;
  /* 抵掉 .lightbox 为刘海留的左右内边距，让抽屉真正贴边（负外边距是静态层叠，不是动画） */
  margin-left: calc(-1 * var(--lb-pad-l));
  margin-right: calc(-1 * var(--lb-pad-r));
  background: var(--popover); color: var(--popover-foreground);
  border-radius: var(--radius-2xl) var(--radius-2xl) 0 0;
  box-shadow: var(--shadow-xl);
  /*
   * 抽屉现在是"固定把手行 + 可滚内容区"的竖排容器，滚动挪到了内层
   * （原来 overflow-y: auto 挂在这一层，把手塞进来会跟着内容一起滚走）。
   * 外部尺寸那几条（flex / max-height / margin-top / 左右负外边距 / 圆角）一条没动。
   */
  display: flex; flex-direction: column;
  /*
   * 左右内边距留在抽屉上（内容区只管上下）；左右把 .lightbox 为刘海留的那段补回来，
   * 文字不会顶到刘海。上下内边距的**值**逐字搬到了 .lightbox__drawer-body ——
   * 把手行不该吃那么多上边距。
   */
  padding-left: calc(var(--space-5) + var(--lb-pad-l));
  padding-right: calc(var(--space-5) + var(--lb-pad-r));
  /*
   * 阻尼弹性：用 --ease-spring 而不是 --ease-out。
   *
   * ease-out 是"到位就停"，读起来像被机械臂推上来；
   * spring 曲线末端会**过冲一点再收回**，才像一块有重量的板子被放下。
   * 时长压到 --dur-slow（原来 900ms 的 dur-reveal 太长，配弹簧会显得晃）。
   */
  animation: fa-drawer-up var(--dur-slow) var(--ease-spring) both;
}
/*
 * 抽屉内容区：把手行之外的正文，滚动只发生在这一层。
 *
 * 拆开之后 42dvh 封顶 + 内部滚动这两条行为都不变：抽屉负责封顶，
 * 内容区 flex: 1 1 auto + min-height: 0 负责滚。
 * 上下内边距从抽屉搬过来，值与原来逐字一致（含底部那条安全区）。
 */
.lightbox__drawer-body {
  overflow-y: auto; min-height: 0; flex: 1 1 auto;
  padding: var(--space-5) 0 calc(var(--space-6) + env(safe-area-inset-bottom, 0px));
}
@keyframes fa-drawer-up {
  from { transform: translateY(100%); }
  to   { transform: none; }
}

/* ---- 物理弹簧交错流 ---- */
/*
 * 新出现的卡片依次弹入（不是一起淡入）。
 *
 * **只给"新"的那些**：筛选之后留下来的卡片走 FLIP 归位（它们是在滑动，不是在入场），
 * 新进来的才弹入。两件事落在两批不同的元素上，是配合不是打架 ——
 * 如果所有卡片每次重画都播一遍入场，就会出现"滑到一半又弹一下"。
 * 哪个是新的由 flip.js 打上 is-new 决定。
 *
 * 选择器是**后代**不是直接子元素：data-flip-key（也就是 is-new）打在 .poster 上，
 * 而 .poster 在 .work-card 里面。写成 `>` 的话一条都匹配不到 —— 实测动画名是 none。
 */
.grid-auto .is-new {
  animation: fa-card-in var(--dur-slow) var(--ease-spring) both;
  /* 26ms 一档：快到像一口气弹出，又慢到眼睛能跟上顺序 */
  animation-delay: calc(var(--stagger-i, 0) * 26ms);
}
@keyframes fa-card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.96); }
  to   { opacity: 1; transform: none; }
}
.lightbox__title { font-size: var(--text-lg); font-weight: 600; line-height: var(--leading-snug); }
.lightbox__meta { margin-top: var(--space-1); font-size: var(--text-sm); color: var(--muted-foreground); }
.lightbox__rows { margin-top: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.lightbox__row { display: flex; align-items: baseline; gap: var(--space-3); font-size: var(--text-sm); }
.lightbox__label { flex: 0 0 auto; color: var(--muted-foreground); }
.lightbox__value { min-width: 0; }
.lightbox__note {
  margin-top: var(--space-4); font-size: var(--text-sm); color: var(--muted-foreground);
  border-left: 2px solid var(--border); padding-left: var(--space-3);
}
/* 进度条：scaleX 从 0 涨到目标比例，不动 width */
.lightbox__progress { margin-top: var(--space-4); }
.lightbox__progress-track {
  height: 4px; border-radius: var(--radius-full); background: var(--muted);
  overflow: hidden; margin-top: var(--space-2);
}
.lightbox__progress-fill {
  height: 100%; border-radius: var(--radius-full); background: var(--primary);
  transform-origin: left center;
  transform: scaleX(var(--photo-progress, 0));
  animation: fa-photo-progress var(--dur-reveal) var(--ease-out) both;
}
@keyframes fa-photo-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(var(--photo-progress, 0)); }
}
.lightbox__progress-text {
  font-size: var(--text-xs); color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
}
/*
 * 共享元素转场用的"飞行副本"。
 *
 * 真内容按最终位置排好（不参与动画），副本从起点飞到终点，飞完把真内容显出来。
 * transform-origin 必须是 0 0：副本的位移和缩放都是以"终点盒子的左上角"为基准算的，
 * 用默认的中心原点会整体偏掉半个盒子。
 */
.lightbox__morph {
  position: fixed; z-index: 5;
  transform-origin: 0 0;
  pointer-events: none;
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-xl);
  will-change: transform;
}
.lightbox__morph img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* 只有加上这个类之后才过渡 —— 直接设初值就过渡的话，第一帧就被吃掉了 */
.lightbox__morph--flying { transition: transform var(--dur-slow) var(--ease-out); }

/*
 * 关闭时整个浮层跟着淡出。照片在往回缩，背景不能"啪"地消失，
 * 否则看起来像图飞进了一片空白。
 */
.lightbox--closing { animation: fa-fade-in var(--dur-slow) var(--ease-out) reverse both; }

/*
 * 抽屉底部的危险动作（删除这张）。
 *
 * 单独一行、和上面那些只读字段用一条细线隔开 —— 删是不可逆的，
 * 不能混在一堆"作品 / 年份 / 评分"里让人误点。
 * 按钮复用项目现有的 .btn / .btn--danger-ghost，不另造一套
 * （同一个项目里的按钮应当是亲兄弟，不该有自己的配色）。
 */
.lightbox__actions {
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border);
  display: flex;
}
.lightbox__close {
  position: absolute; top: var(--space-4); right: var(--space-4);
  z-index: 3; color: #fff; width: 2.25rem; height: 2.25rem;
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--scrim-from) 70%, transparent);
  display: grid; place-items: center;
}
@media (prefers-reduced-motion: reduce) {
  /* 令牌已经压到 1ms；这里再兜一层，防止将来有人把时长写死 */
  .lightbox__drawer, .lightbox__progress-fill { animation-duration: 1ms; }
}

/* ---------------------------------------------------------------- 叠放堆栈（配图） */
/*
 * finesse · component: stacked-deck · register=product
 * states: default · hover · focus-visible · active(按下/拖拽) · disabled(到头那一侧，三通道) ·
 *         loading(图未就绪骨架) · error(这张读不到) · success(图已就位，安静)
 * 预览页: tools/screenshots/deck/叠放堆栈-八状态预览.html（八个状态摆开，看一眼就够，看完可删）
 * 只在鼠标设备上渲染翻页按钮：触屏滑动就是翻页，按钮多余。
 *   判据是指针类型，(hover: none) / (pointer: coarse) 隐藏，另加 ≤640px 兜底
 *   —— 因为窄屏 + 鼠标（比如把电脑窗口拉到 780）仍然需要按钮，不能按宽度一刀切。
 * tokens: inherited（--card / --border / --primary / --ring / --destructive / --scrim-from / --dur-* / --space-*，
 *                     组件文件里没有任何字面色值；页面档与灯箱档的差异全走 --deck-* 上下文变量）
 *
 * 组件级构建：不参与页面轮换，所以 .finesse/log.json 里没有这一条 ——
 * 那是整页构建的记录，把组件写进去会让下一轮拿组件去轮换。
 *
 * 一摞照片：最上面一张最大，后面两张缩着往上错开露出顶边；往上拖翻下一张。
 * 结构在 js/ui/deck.js，那边写清了三条设计决定（翻页先改状态、补位复用节点、
 * 坏图不许挡住其余）。这里只管样子。
 *
 * 全部走既有令牌，不发明新配色：卡片底 --card、描边 --border、页码弱化走
 * --muted-foreground。三套主题 × 8 个主色自动成立。
 */
.deck {
  /*
   * **一个时钟。** 全页唯一的时长：卡片飞出、页码滚动、进度线推进、标签计数跳动
   * 全部用它。js/ui/motion.js 里的 BEAT 必须和这个数一样。
   * 各写各的（曾经 300 / 460 / 各自的过渡）就是"不同步"的根源 ——
   * 那不是快慢问题，是它们根本不在同一个时间轴上。
   */
  --deck-beat: 300ms;
  --deck-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
  position: relative;  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column;
  /*
   * 配色**跟随容器**，不写死白字。
   *
   * 这套堆栈有两个落点：全屏查看器（底是 --photo-backdrop，三套主题下都是深色）
   * 和「配图」页里（底是当前主题的页面背景 —— 浅色主题下白字直接看不见）。
   * 所以这里只声明"由容器给"，两个落点各自覆盖一次。
   */
  --deck-fg: var(--foreground);
  --deck-fg-dim: var(--muted-foreground);
  --deck-line: color-mix(in srgb, var(--deck-fg) 14%, transparent);
  --deck-chip: color-mix(in srgb, var(--deck-fg) 8%, transparent);
}
/* 全屏查看器：底是深色照片背景，所以这一层用白字 */
.lightbox .deck {
  --deck-fg: #fff;
  --deck-fg-dim: color-mix(in srgb, #fff 62%, transparent);
  --deck-line: color-mix(in srgb, #fff 18%, transparent);
  --deck-chip: color-mix(in srgb, #fff 10%, transparent);
}
/*
 * 「配图」页的那一摞。
 *
 * **没有边框、没有底色、没有圆角、没有内边距。**
 * 原来是一个 border + background + 内边距的"相册框"，用户原话是
 * 「这个相册一样有个很实的边框，感觉很难看」—— 它同时制造了三个问题：
 *   · 框本身比照片还显眼，抢主体；
 *   · 框内那圈内边距白吃掉一圈空间，照片反而更小（这就是"有空"）；
 *   · 里面所有东西被同一圈等宽内边距框住，标题 / 照片 / 控制行贴在同一个平面上，
 *     没有前后关系（这就是"极其没层次"）。
 * 现在堆栈直接落在页面背景上，结构交给标题下面那一条细线 ——
 * 用对齐和分隔线分组，不用框子分组。
 *
 * 高度给足，卡片才当得起"这一页的主视图"（.deck 的 flex:1 需要确定父高度）。
 * 宽度 48rem：比原来 32rem 宽，照片能更大 —— 框子去掉之后多出来的宽度直接给照片。
 */
.photo-deck {
  width: 100%;
  max-width: 48rem;
  margin-inline: auto;
  /*
   * 高度**不写死**：由照片撑开（deck.js 的 sizeStack 按照片比例算出 --deck-h）。
   * 写死过一个 min(80vh, 50rem)=800，结果横图只占 316 高，
   * 上下各留一大片空（实测合计 316px）—— 那正是"有空"的来源。
   * 面板是这一页最后一块，后面没有别的内容，所以高度随照片变化不会挤动任何东西。
   */
  display: flex;
}
/* 页面档：整摞的高度由照片决定，不是填满剩余空间（灯箱档才填满） */
.photo-deck .deck__stack { flex: 0 0 auto; height: var(--deck-h, 60%); }
/*
 * 标题那一条下面加一道细线。
 *
 * 这是去掉框子之后**唯一**的结构线：它把"这是什么 / 第几张"的信息头
 * 和下面那张照片分开，两者不再糊成一片。
 * 只在页面档加 —— 全屏查看器的底是照片暗角，再加线就成装饰了。
 */
.photo-deck .deck__head {
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--deck-line);
}
/* 信息头和照片之间留出呼吸：没有框子，间距就是唯一的分组手段 */
.photo-deck .deck__cats { margin-top: var(--space-4); }

/* ---- 顶部：标题两行靠左，张数靠右（同一个信息只报一遍） ---- */
.deck__head {
  flex: 0 0 auto;
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4);
  padding: 0 var(--space-1);
  color: var(--deck-fg);
}
/*
 * 全屏查看器里右上角有关闭按钮，标题得让开。
 * 页面里没有那个按钮，"让出 3.5rem" 就是白白少一截宽度——所以只在灯箱里让。
 */
.lightbox .deck__head { padding-right: 3.5rem; }
.deck__head-text { min-width: 0; display: flex; flex-direction: column; gap: 2px;   transition: color var(--deck-beat) var(--deck-ease);
}
/*
 * 张数读数：等宽数字 + 大字号。
 *
 * 等宽是为了翻页时数字不左右抖（12 → 13 宽度不同，会带着整行挪一下）。
 * 尺寸比标题还大一点：在这一页它是最该被一眼看到的信息 —— "我在第几张、一共几张"。
 */
.deck__count {
  flex: 0 0 auto; margin: 0;
  display: flex; align-items: baseline; gap: 0.25em;
  font-size: var(--text-2xl); font-weight: 500; line-height: 1;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--deck-fg-dim);
}
.deck__count-now { color: var(--deck-fg); font-weight: 600; }
/*
 * 分隔符现在是 CSS 画的一根发丝线（空 span），不再是斜杠字符 ——
 * 原来这里的 color/opacity 是给那个字符的，留着会把线压淡到看不见。
 */
.deck__count-all { font-size: 0.62em; }
.deck__line { margin: 0; }
/*
 * 第一行**单行省略**。
 *
 * 规格是"两行细长宽体标题"——第一行自己是两行的话就不再是两行了（手机上实测
 * 长作品名换成两行，把分类行挤下去、还和关闭按钮打架）。
 * 完整名字在下面的抽屉里，这里给一个能认出来的开头就够。
 */
.deck__line--title {
  font-size: var(--text-lg); font-weight: 500;
  letter-spacing: 0.06em;
  line-height: var(--leading-snug);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.deck__line--meta {
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  color: var(--deck-fg-dim);
  font-variant-numeric: tabular-nums;
}
.deck__line[hidden] { display: none; }

/* ---- 一排分类：只有真有这类照片时才出现（由 js 决定显隐） ---- */
.deck__cats {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: var(--space-1);
  margin-top: var(--space-3);
  padding: 0 var(--space-1);
  overflow-x: auto; scrollbar-width: none;
}
.deck__cats::-webkit-scrollbar { display: none; }
.deck__cats[hidden] { display: none; }
.deck__cat {
  display: inline-flex; align-items: center; gap: var(--space-2);
  /*
   * 44px 是项目的触控下限（mobile-audit 会逐条量浮层里的可点元素）。
   * 原来写 2rem(32px)，实测 390 档两个分类标签都是 63x32，被判不合格 ——
   * 这一排是"筛选这一摞"的主要入口，手机上必须够得着。
   */
  /*
   * 44px 是**触屏**的触控下限，不是桌面的芯片高度。
   * 原来这条没有媒体查询，于是在电脑上也是 44px —— 一颗又大又圆的药丸杵在照片上方，
   * 比照片本身还抢眼。鼠标不需要那么大的靶子。
   */
  min-height: 2rem; padding: 0 var(--space-3);
  border: 0; border-radius: var(--radius-full);
  background: var(--deck-chip);
  color: var(--deck-fg);
  /* 受光的过渡走全页同一个节拍（受光本身由 js 的 lightZone 给 is-lit） */
  transition: color var(--deck-beat) var(--deck-ease),
              background var(--deck-beat) var(--deck-ease);
  font-size: var(--text-2xs); letter-spacing: 0.06em;
  white-space: nowrap;   /* M3：标签在任何宽度下都不换行 */
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.deck__cat[aria-selected="true"] { background: var(--primary); color: var(--primary-foreground); }
/*
 * 只有"全部"一项时的静态标签：长得像分类，但不装成能点
 * （cursor 和 hover 都不给，点了也没反应，因为确实没别的可筛）。
 */
@media (hover: none), (pointer: coarse), (max-width: 640px) {
  .deck__cat { min-height: 2.75rem; }
}
/*
 * 只有一个分类时的静态标签。
 *
 * 原来它长得和能点的分类**一模一样**（同样 44px 的药丸、同样的底色），只是不给 hover ——
 * 那是半修：它还是"看起来能点"。而项目里已经写明"不装成能点"。
 * 真做法是让它**根本不像按钮**：它就是一句"这里一共几张"，
 * 那就用一句注脚的样式：没有底、没有药丸、小一号、颜色退到次要。
 */
.deck__cat--label {
  cursor: default;
  min-height: 0; padding: 0;
  background: none; border-radius: 0;
  color: var(--deck-fg-dim);
  font-size: var(--text-xs);
  letter-spacing: 0.02em;
}
.deck__cat-n { font-variant-numeric: tabular-nums; opacity: 0.7; }
@media (hover: hover) {
  /*
   * `:not(.deck__cat--label)` 是必须的。
   *
   * 静态标签是**点不了**的，但它原来照样吃这条 hover —— 鼠标停上去就多一块深色底。
   * 配上"去掉药丸圆角"之后，那个底变成一个有棱有角的方盒子，比原来的圆药丸更难看。
   * 这就是"半修"的后果：只改了静态样式，没改交互态。
   * 不能点的东西，连 hover 都不该有。
   */
  .deck__cat:not(.deck__cat--label):hover { background: var(--deck-line); color: var(--deck-fg); }
}
.deck__cat:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }


/* ---- 受光（一个光源照到的所有东西共用这一条）----
 *
 * 照片、分类标签、页码、标题日期读的是**同一个指针位置**（js/ui/motion.js 的 lightZone），
 * 所以光是一盏灯，会从照片流到标签再流到页码。
 *
 * 文字的受光方式刻意**不是** hover 底色、也不是 cursor 变化 ——
 * 它们点不了，就不该装成能点（那是"撒谎的控件"）。
 * 光落在上面只做一件事：把它从"次要色"提到"主色"，像被照亮了一点。
 * 一亮一暗之间用同一个时钟，所以它和翻页是同一个节拍。
 */
/*
 * 过渡写在各组件**自己的基础规则**里，不在这里另起一条组合选择器 ——
 * .deck__cat 在媒体查询里有一条（触屏的 44px），后面再出现同名普通规则
 * 会把它盖掉（check.mjs 的"响应式失效"就是查这个）。
 */
.deck__cat.is-lit { color: var(--deck-fg); }
.deck__count.is-lit .deck__count-now { color: var(--deck-fg); }
.deck__head-text.is-lit { color: var(--deck-fg); }

/* ---- 舞台与摞 ---- */
/*
 * 舞台是**纵向**两段：上面是摞（吃掉剩余高度），下面是页码。
 *
 * 页码第一版写成绝对定位贴在舞台底部 —— 被抽屉压住了（抽屉有 -22px 的重叠，
 * 而且 z-index: 2）。改成正常流里的一个 flex 子项，它就永远在抽屉之上、也不会
 * 和卡片抢位置。实测原来那个位置只露出一半。
 */
.deck__stage {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; align-items: center;
  /*
   * 底部那 22px 是**给抽屉让位**的：抽屉有 margin-top: -22px（静态层叠，故意压住
   * 照片区底边），不预留的话页码正好落进那段重叠里 —— 实测 390 档
   * 页码底 570 / 抽屉顶 556，页码被压掉一截。
   */
  padding: var(--space-5) 0 0;
}
/*
 * 全屏查看器里额外多两件事：左右给照片留白（贴边会顶到刘海），
 * 底部那 22px 是**给抽屉让位**的（抽屉有 margin-top: -22px 的静态层叠，
 * 不预留的话控制行正好落进那段重叠里 —— 实测 390 档页码底 570 / 抽屉顶 556）。
 * 页面里没有抽屉，也就不需要这一段，所以只在灯箱里加。
 */
.lightbox .deck__stage { padding: var(--space-5) var(--space-4) calc(var(--space-2) + 22px); }
/*
 * 摞：占满舞台的内容盒（就是减掉左右留白之后的宽度）。
 * 用普通流 + position: relative，而不是 inset: 0 —— 后者会按**内边距盒**铺开，
 * 把舞台左右那圈留白也吃进去，卡片就会压到刘海/安全区上。
 */
.deck__stack { position: relative; flex: 1 1 auto; min-height: 0; width: 100%; }
.deck__stack[data-empty="true"] { display: grid; place-items: center; }

/*
 * 卡片**贴合照片本身的比例**，不是一个固定 4:5 的框。
 *
 * 这一条是被真实数据打回来的：固定 4:5 竖框遇到横构图的照片（截图、合影），
 * object-fit: contain 会在上下各留一大条黑边 —— 用户看到的是一张"空的卡片、
 * 图缩在中间"，而票根那种竖长条才会刚好填满。
 * 实测用户的 12 张全是横的动画截图，卡片一半是空的，观感直接崩掉。
 *
 * 现在的做法：卡片宽高都是 fit-content，由里面的图撑开；
 * 上限只有两条 —— 舞台宽的 82%（"--deck-avail-w"）和可用高（"--deck-avail-h"），
 * 由 deck.js 的 sizeStack 算好。于是横图铺满宽度、竖图顶满高度，
 * 两种构图都不会留空边，而"占屏宽 82%"这条对横图真正成立。
 *
 * .deck__frame 用 display: contents 让图直接由卡片布局，
 * 少一层"框"就不会出现框比图大的老问题。
 */
.deck__card {
  position: absolute; left: 50%; top: 50%;
  /*
   * 宽高由 js 算出来的两个变量给（deck.js 的 sizeStack）：**整摞同一个盒子**，
   * 比例取正面那张照片。三张形状一致，向上错开 16/30px 出来的才是顶边那条带。
   * 之前写成 fit-content 各撑各的，实测出现"后面那张比正面还宽"而从两侧露出来。
   */
  width: var(--deck-card-w, 82%); height: var(--deck-card-h, 60%);
  margin: 0;
  display: block;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--card);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  transform: translate(-50%, -50%);
  /*
   * 缩放的原点是**顶边中点**，不是中心。
   *
   * 以中心缩放时，缩小后的卡片顶边会往下退（实测：前面那张顶边 107，
   * 后面 0.94 那张顶边 112）—— 于是它被前面那张**完全盖住**，一条边都露不出来，
   * "后面还压着两张"这件事就看不见了。
   * 改成以顶边为原点：缩放只影响左右和高度，顶边位置不动，再往上错 16/30px，
   * 露出的就是顶边那条带 —— 也就是"向上错开露出边缘"。
   */
  transform-origin: 50% 0;
  transition: transform var(--dur-normal) var(--ease-out);
  will-change: transform;
}
/*
 * z-index 必须显式给。
 * 三张都是绝对定位，不给的话谁压谁由 DOM 顺序决定 —— 也就是**最后那张盖在最上面**，
 * 和"最新的在最前"正好相反（实测截图上三张的说明条全露着，就是这么来的）。
 */
.deck__card[data-slot="0"] {
  z-index: 3;
  transform: translate(-50%, calc(-50% + var(--deck-drag, 0px))) rotate(var(--drag-tilt, 0deg));
}
.deck__card[data-slot="1"] { z-index: 2; transform: translate(-50%, calc(-50% - 16px)) scale(0.94); }
.deck__card[data-slot="2"] { z-index: 1; transform: translate(-50%, calc(-50% - 30px)) scale(0.88); }

/*
 * 一摞的**厚度**靠两件事，不是靠描边。
 *
 * 一、三格各自的投影深度不同：最前面那张最大最远，往后依次收。
 *     三张共用一个 --shadow-lg 的话，它们在视觉上是三个"贴在同一个平面上"的色块，
 *     读不出前后关系 —— 叠放堆栈最要紧的恰恰是这层前后关系。
 */
.deck__card[data-slot="0"] { box-shadow: var(--shadow-xl); }
.deck__card[data-slot="1"] { box-shadow: var(--shadow-lg); }
.deck__card[data-slot="2"] { box-shadow: var(--shadow-md); }

/*
 * 二、顶边内侧一道极淡的高光，模拟纸边受光。
 *     没有它，深色照片的上缘和深色底糊在一起，卡片像一张贴纸而不是一张照片。
 *     inset 画的所以不占布局，也不会随卡片缩放变粗。
 */
.deck__card::after {
  content: ''; position: absolute; inset: 0; z-index: 4;
  border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 1px 0 color-mix(in srgb, var(--deck-fg) 14%, transparent);
}

/*
 * 正面那张：鼠标压上来时，一层很宽很淡的光跟着指针走。
 *
 * 用 ::before（::after 已经被顶边高光占了）。光要"和谐"靠的是三件事：
 *   大小 —— 原来铺到 120%x90%（几乎盖满整张），用户判定"光晕太大了"。
 *          收到 62%x52%：一小片，但边缘仍然是渐隐的。
 *   淡   —— 白 15%：看得出有光，但不抢照片
 *   软   —— 70% 处完全透明，没有可辨认的边界
 * 小、亮、边界清楚的高光才是那种廉价的"扫光"。
 * 指针位置由 js/ui/motion.js 的 lightZone 写进 --lx/--ly —— 和标签、页码、标题
 * 读的是同一个指针位置，所以是同一盏灯。
 */
.deck__card[data-slot="0"]::before {
  content: ''; position: absolute; inset: 0; z-index: 2;
  border-radius: inherit; pointer-events: none;
  /* 位置来自**一个光源**（--lx/--ly），不是这张卡自己算的 */
  background: radial-gradient(62% 52% at var(--lx, 50%) var(--ly, 50%),
              color-mix(in srgb, #fff 15%, transparent), transparent 70%);
  opacity: 0;
  transition: opacity var(--deck-beat) var(--deck-ease);
}
.deck__card[data-slot="0"].is-lit::before { opacity: 1; }
/*
 * 光落在照片上时，照片也"抬起来一点"接光 —— 只加深投影，不动 transform
 * （transform 归格位所有）。
 * 只有光、没有投影变化的话，看起来像屏幕调亮了一格，而不像一张纸被照亮。
 */
.deck__card[data-slot="0"].is-lit { box-shadow: var(--shadow-xl), 0 22px 48px -18px rgb(0 0 0 / 0.55); }

/*
 * 后面那两张：从顶边往下渐暗。
 *
 * 原来用 filter: brightness(.82) 整张压暗，连露出来的那条边一起暗 ——
 * 结果就是三条不同亮度的色块，读不出"后面压着一张照片"。
 * 改成从顶边向下渐暗：露出的那条边是暗的，往下迅速回到原色，
 * 才像是被前面那张压住、只漏出一条边。
 * （顺带：filter 会新建合成层，渐变更便宜。）
 */
.deck__card[data-slot="1"]::before,
.deck__card[data-slot="2"]::before {
  content: ''; position: absolute; inset-inline: 0; top: 0; height: 3.5rem; z-index: 1;
  pointer-events: none;
  background: linear-gradient(180deg, color-mix(in srgb, var(--scrim-from) 66%, transparent), transparent);
  transition: opacity var(--dur-fast) var(--ease-out);
}
.deck__card[data-slot="1"], .deck__card[data-slot="2"] { cursor: pointer; }
@media (hover: hover) {
  /* 悬停把那条压边提亮一点：它在说"这张可以点" */
  .deck__card[data-slot="1"]:hover::before,
  .deck__card[data-slot="2"]:hover::before { opacity: 0.35; }
}
.deck__frame { display: contents; }
/*
 * 正面那张：contain —— **不裁切**。盒子的比例本来就是它自己的比例，所以正好铺满。
 * 后面两张：cover —— 裁切填满，这样露出来的那条边是真实照片内容；
 *           用 contain 的话那条边会是卡片底色，看着像"图裂了"。
 */
.deck__img { display: block; width: 100%; height: 100%; object-fit: contain; }
.deck__card[data-slot="1"] .deck__img,
.deck__card[data-slot="2"] .deck__img { object-fit: cover; }

/* 图未就绪：给占位块而不是空白 —— 空白会让"这一摞有几张"读不出来 */
.deck__skeleton {
  /* 图未就绪时铺满整格 —— 盒子的尺寸已经由 sizeStack 定好了，不需要它再撑 */
  width: 100%; height: 100%;
  background: var(--skeleton);
  background-size: 200% 100%;
  animation: fa-deck-shimmer 1.4s linear infinite;
}
@keyframes fa-deck-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }

/* 坏图：这一张读不到，但翻页照旧（一张坏图不许挡住其余所有） */
.deck__broken {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-2); height: 100%; padding: var(--space-6); text-align: center;
}
.deck__broken-text { font-size: var(--text-sm); color: var(--foreground); }
.deck__broken-hint { font-size: var(--text-2xs); color: var(--muted-foreground); max-width: 22rem; }

.deck__flag {
  z-index: 3;
  position: absolute; left: var(--space-3); top: var(--space-3);
  padding: 2px var(--space-2); border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--scrim-from) 70%, transparent);
  color: #fff; font-size: var(--text-2xs); letter-spacing: 0.06em;
}

/*
 * 照片自己的说明：压在下缘暗角上。
 * 直接对齐 .poster__overlay / 原灯箱那套写法（linear-gradient 到 --scrim-from + 白字），
 * --scrim-from 三套主题各有各的值，硬编码黑色在 midnight 下就是错的。
 */
.deck__scrim {
  /* 压在"后面那张的渐暗"之上（那是 z-index 1），否则说明会被压暗 */
  z-index: 2;
  position: absolute; inset-inline: 0; bottom: 0;
  padding: var(--space-10) var(--space-4) var(--space-4);
  background: linear-gradient(180deg, transparent, var(--scrim-from));
  color: #fff; pointer-events: none;
}
.deck__caption {
  margin: 0;
  font-size: var(--text-base); font-weight: 600; line-height: var(--leading-snug);
  /* 长说明最多两行，不要把照片盖完 */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}

/*
 * 拖拽。
 *
 * touch-action: none 给**所有卡片**，不给"正面那张"一个类：
 * 拖拽是委托在 stack 上处理的（见 deck.js），正面那张随时会换人，
 * 靠一个跟着换的类来控制 touch-action 会漏掉刚补位上来的那张 ——
 * 那一漏的表现就是"翻过一页之后拖不动"。
 * 卡片区域本来也不该滚（灯箱自己锁了滚动，可滚的是下面的抽屉），所以整体关掉没有副作用。
 */
.deck__card { touch-action: none; }
.deck__card[data-slot="0"] { cursor: grab; }
.deck__card--dragging { transition: none; cursor: grabbing; }

/* 飞出去：翻页时最前面那张的离场 */
.deck__card--fly {
  z-index: 9; pointer-events: none;
  /* 一个质量：飞出的时长就是全页那个节拍，和页码、进度线同时起停 */
  animation: fa-deck-fly var(--deck-beat) var(--deck-ease) both;
}
@keyframes fa-deck-fly {
  from {
    /* 起手就是格位 0 的样子（放大到 1），保证不跳帧 */
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
  }
  to {
    /*
     * 抬起：飞出时**朝观者靠近**一点（放大到 1.04），所以它不但往上走，还变大。
     * 只有位移没有缩放的话，看起来是"一张纸被推走"；有了这一下，
     * 才像"最上面那张被抽起来、离你更近了"。
     * 这是立体感里最直接的一笔，而且不碰任何基础几何。
     */
    transform: translate(-50%, calc(-50% - 118%)) rotate(calc(var(--fly-dir, 1) * -6deg)) scale(1.04);
    opacity: 0;
  }
}

/*
 * ---- 视差：一摞的纵深 ----
 *
 * 翻页时**正面先动，后面两张依次跟上** —— 一摞纸被抽走最上面那张时，
 * 下面的不会同时弹起来，它们慢一拍。
 *
 * 用 transition-delay 而不是各写各的 duration：**时长还是同一个时钟**，
 * 只是起跑点错开。这不是"又一个独立的动效"，是同一条时间轴上派生的错拍 ——
 * 各写各的时长才是"不同步"，错拍是物理。
 *
 * 40 / 80ms：够看出层次，又不至于让人等第二张。
 */
.deck__card[data-slot="1"] { transition-delay: 40ms; }
.deck__card[data-slot="2"] { transition-delay: 80ms; }

/* 翻到头／到尾：顶一下 */
.deck__card--draggable.deck__bump--up { animation: fa-deck-bump-up var(--dur-normal) var(--ease-out); }
.deck__card--draggable.deck__bump--down { animation: fa-deck-bump-down var(--dur-normal) var(--ease-out); }
@keyframes fa-deck-bump-up { 50% { transform: translate(-50%, calc(-50% - 10px)); } }
@keyframes fa-deck-bump-down { 50% { transform: translate(-50%, calc(-50% + 10px)); } }

/* ---- 底部控制行：一条进度线 + 两个能点的翻页按钮 ---- */
/*
 * 定位层：两个箭头在它左右两端、竖直和照片中心齐平。
 * 它是 position: relative 的参照物，箭头 absolute 挂在上面 ——
 * 这样"箭头对齐照片"和"轨道贴底"互不影响。
 */
/*
 * 计数器现在是个按钮（点开缩略图跳转条），所以要把 <p> 那套观感按住，
 * 只留"能按"这一件事。它原来就是右对齐的一行读数，位置和字号一个字不动。
 */
.deck__count {
  appearance: none; border: 0; background: none; padding: 0;
  font: inherit; color: inherit; cursor: pointer;
  border-radius: var(--radius-sm);
  transition: color var(--deck-beat) var(--deck-ease);
}
@media (hover: hover) { .deck__count:hover { color: var(--primary); } }
.deck__count:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }

/* ---- 页码的形（folio，不是里程表）----
 *
 * 原来写成 "01 / 05"：补零 + 斜杠 + 统一字号，三个都是仪表盘的语法，
 * 所以看着"很规范、像老古董"。刊物页码不是这么排的：
 *   当前号**大而紧**，一根发丝线，总数**小而退后**。
 * 信息一个字没少，读起来的腔调换了。
 */
.deck__count { display: inline-flex; align-items: center; gap: var(--space-2); }
.deck__count-now {
  font-size: var(--text-lg); font-weight: 600; letter-spacing: -0.04em;
  line-height: 1; color: var(--deck-fg);
  /*
   * 宽度必须**真的**稳得住。
   *
   * 原来写 min-width: 1.15em（约 20.7px），而等宽数字两位约 21px —— 差 0.3px，
   * 也就是**根本没挡住**：9 → 10 时盒子变宽，而这一组是 justify-content: center，
   * 于是整行跟着左右挪，看着就是"数字前后移位"。
   * 改成 2ch（等宽数字下 1ch = 一个数字宽），1 位和 2 位占同一个盒子，
   * 再配 text-align: right，个位数始终停在同一个位置，只有十位往左长。
   */
  font-variant-numeric: tabular-nums;
  min-width: 2ch; text-align: right;
}
.deck__count-sep {
  width: 1px; height: 0.95em;
  background: color-mix(in srgb, var(--deck-fg) 30%, transparent);
}
.deck__count-all {
  font-size: var(--text-2xs); font-weight: 500; letter-spacing: 0.02em;
  color: var(--deck-fg-dim);
  font-variant-numeric: tabular-nums;
}
/*
 * 手机档：给计数器撑到 44px 的最小触控高度。
 *
 * 它原来是个 <p> 读数（24px 高无所谓），现在是个按钮 —— 手指点不中就不是装饰问题。
 * 用 min-height + inline-flex 居中，**不用透明的伪元素偷偷扩热区**：
 * 那样功能上够了，但真实盒子还是 24px，量出来仍然是 24px，等于把问题藏起来。
 * 顶部这一行左边本来就是两行字，撑到 44 不会把头部顶高。
 */
@media (hover: none), (pointer: coarse), (max-width: 640px) {
  .deck__count {
    display: inline-flex; align-items: center; justify-content: flex-end;
    min-height: 44px; padding-inline: var(--space-2); margin-inline-end: calc(var(--space-2) * -1);
  }
}

/*
 * 缩略图跳转条：浮在照片底部，**不插进布局**。
 * 插进去的话打开它会把照片挤矮 —— 你想挑一张，画面却先变小了。
 */
.deck__jump {
  position: absolute; inset-inline: 0; bottom: 0; z-index: 6;
  display: flex; gap: var(--space-2); align-items: center;
  padding: var(--space-3);
  overflow-x: auto; overscroll-behavior-x: contain;
  border-radius: var(--radius-lg);
  /* 压在照片上，所以必须垫一层底，否则深色照片上的缩略图会糊在一起 */
  background: color-mix(in srgb, var(--deck-bg, var(--background)) 82%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  box-shadow: var(--shadow-lg);
}
.deck__jump-item {
  flex: 0 0 auto; width: 48px; height: 48px;
  padding: 0; border: 0; cursor: pointer;
  border-radius: var(--radius-sm); overflow: hidden;
  background: var(--muted);
  opacity: 0.55;
  transition: opacity var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.deck__jump-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
@media (hover: hover) { .deck__jump-item:hover { opacity: 1; } }
.deck__jump-item:active { transform: scale(0.94); }
.deck__jump-item.is-current { opacity: 1; box-shadow: inset 0 0 0 2px var(--primary); }
.deck__jump-skel { display: block; width: 100%; height: 100%; background: var(--muted); }

.deck__viewport {
  position: relative;
  flex: 1 1 auto; min-height: 0; width: 100%;
  display: flex;
  justify-content: center;
  /*
   * align-items 必须是 stretch，**不能是 center**。
   *
   * 写成 center 时，堆栈成了"行向 flex 里按内容居中"的孩子 —— 而堆栈里的卡片
   * 全是绝对定位、不贡献高度，于是堆栈量出来高 0；sizeStack 拿到的可用高也就是 0，
   * 卡片高度算成 0，图片高度 0 —— 表现就是"点进去图直接没了"。
   * 箭头本来就已经用 absolute + translateY(-50%) 自己居中了，不靠这个属性。
   */
  align-items: stretch;
}
/* 底部只留一条读数轨道 */
.deck__controls {
  flex: 0 0 auto;
  /*
   * width: 100% 是必须的：父级 .deck__stage 是 align-items: center（照片要居中），
   * 控制行会被压成"内容宽"，于是 flex:1 的轨道算出 0 宽 —— 实测就是一条看不见的线。
   */
  width: 100%;
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-3);
  padding-top: var(--space-4);
}
.deck__controls[hidden] { display: none; }
/*
 * 进度线：全宽的一条细线，画的是"这一摞走到哪了"。
 * 它是**真的有限进度**（确实有 N 张），不是滚动条暗示 —— 所以用填充而不是滑块。
 */
.deck__rail {
  /*
   * 轨道不再铺满整幅宽。
   *
   * 原来它是 flex:1，于是从面板最左一直到最右一整条 —— 而照片只占中间 82%。
   * 一条比它所描述的东西还长的进度线，读起来和照片没关系。
   * 现在有上限（20rem）、居中，和旁边的计数器同属一组。
   */
  position: relative; flex: 0 1 20rem; min-width: 4rem; height: 3px; border-radius: var(--radius-full);
  /*
   * 轨道必须看得见，否则进度条读出来是"一小截浮线"而不是"走到哪了"。
   * 原来用 --deck-line（前景的 14%）—— 实测深色和浅色两档都几乎看不见轨道，
   * 只剩 20% 的填充色孤零零挂在左边（截图里就是这样）。
   * 提到 22%，并把线从 2px 加到 3px：它是"分量"的读数，不是一条分隔线。
   */
  background: color-mix(in srgb, var(--deck-fg) 22%, transparent);
  overflow: hidden;
}
.deck__rail-fill {
  position: absolute; inset: 0;
  transform-origin: left center;
  transform: scaleX(var(--deck-progress, 0));
  background: var(--primary);
  transition: transform var(--dur-normal) var(--ease-out);
}

/*
 * 翻页按钮 —— 组件八个状态的主战场。
 *
 * 拖拽和键盘都留着，但按钮是"明面上那条路"：能 Tab 到、有文字、看得见。
 * 下面是每个状态的实际写法（不是声称）：
 *   default        常态底色 + 常量线宽
 *   hover          只在真有指针的设备上（触屏上 hover 会粘住）
 *   focus-visible  outline 环形，**不做淡入**（键盘用户需要它立刻出现）
 *   active         按下时下沉 1px
 *   disabled       原生 disabled + 透明度 + cursor，三个通道
 *   loading/error/success 由卡片那边表达（骨架 / 读不到 / 图已就位），
 *                 按钮本身不需要这三种 —— 见文件末尾的组件印章
 */
.deck__nav {
  /* 贴在照片两侧、竖直居中 —— 手和眼都在照片上，不用往页面底部跑 */
  position: absolute; top: 50%; transform: translateY(-50%);
  z-index: 5;   /* 压在卡片之上：它露在照片外侧，但照片很宽时会挨到边 */
  /*
   * 小幽灵按钮：只有箭头，没有底色也没有描边。
   *
   * 32px 是**鼠标**的尺寸，不需要 44px 的触控下限 —— 因为触屏上这两个按钮
   * 根本不渲染（见下面那条媒体查询）。线宽恒为 0，所以没有任何状态会挤动邻居。
   */
  flex: 0 0 auto;
  width: 2rem; height: 2rem;
  display: grid; place-items: center;
  border: 0; border-radius: var(--radius-full);
  background: transparent;
  color: var(--deck-fg-dim);
  cursor: pointer;
  /* 常态就把 outline 占好位，聚焦时不会因为多出描边而挪动布局 */
  outline: 2px solid transparent;
  outline-offset: 2px;
  transition: background-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out);
}
/*
 * 触屏设备上不渲染这两个按钮。
 *
 * 手机上滑动就是翻页，按钮多余；摆在那儿只会占掉一条、还把这一页压得更挤。
 * 判据是**指针类型**不是屏幕宽度：电脑上把窗口拉窄到 780px 仍然是鼠标，
 * 按钮该在（用户就是这么用的）；平板/手机即使屏幕很宽也不该在。
 */
@media (hover: none), (pointer: coarse), (max-width: 640px) {
  .deck__nav { display: none; }
}
@media (hover: hover) {
  .deck__nav:hover:not(:disabled) { background: var(--deck-chip); color: var(--deck-fg); }
}
/*
 * 箭头贴住**照片的边缘**，不是贴在视口两端。
 *
 * 原来写 left:0 / right:0，于是它们飘在照片两侧的空白里（照片占 82% 宽，
 * 每条空档有 9%），离照片很远、和照片也没有关系 —— 截图里就是两个孤零零的符号。
 * 现在按照片的实际宽度算：照片左缘再往左 2.6rem，且不许超出视口。
 * --deck-card-w 由 sizeStack() 挂到 .deck__viewport 上（箭头是 stack 的兄弟，
 * 拿不到 stack 上的同名变量）。
 */
.deck__nav--prev { left: max(var(--space-1), calc(50% - var(--deck-card-w, 82%) / 2 - 2.6rem)); }
.deck__nav--next { right: max(var(--space-1), calc(50% - var(--deck-card-w, 82%) / 2 - 2.6rem)); }
.deck__nav:focus-visible { outline-color: var(--ring); }
/* 位移和缩放要一起写：只写 scale 会把上面那条 translateY(-50%) 覆盖掉，按钮当场跳位 */
.deck__nav:active:not(:disabled) { transform: translateY(-50%) scale(0.9); }
.deck__nav:disabled {
  opacity: 0.32;
  cursor: not-allowed;
}
/* 预览页要靠类名把状态钉住（真伪类需要一个活的指针），一个状态多一个选择器 */
.deck__nav.is-hover:not(:disabled) { background: var(--deck-line); }
.deck__nav.is-focus { outline-color: var(--ring); }
.deck__nav.is-active:not(:disabled) { transform: translateY(1px); }
.deck__nav[disabled].is-disabled { opacity: 0.5; cursor: not-allowed; }

.deck__empty { display: grid; place-items: center; height: 100%; color: var(--deck-fg-dim); font-size: var(--text-sm); }

/* 减少动效：令牌已经把时长压到 1ms，飞出去的位移不需要另写终点（终点就是"走了"） */
@media (prefers-reduced-motion: reduce) {
  .deck__card--fly, .deck__skeleton { animation-duration: 1ms; }
  .deck__skeleton { animation: none; }
}

/* ---------------------------------------------------------------- 邀请 */
.invite-card { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-4); }
/*
 * 要抄写的码：恢复码 / 邀请码 / 它们对应的输入框。
 *
 * 字体走 --font-code（全站唯一的等宽例外，理由见 tokens.css）。
 * 另外三件事都是为了"抄不错"：
 *   · font-feature-settings: 'zero' 1 —— 打开带斜杠的零，这是把 0 和 O 彻底分开的那一刀
 *   · tabular-nums —— 数字等宽，一组码上下对齐，一行行核对时眼睛不会串行
 *   · 字距拉开 —— 分组之间本来就有连字符，再加字距，4 位一组看得清
 * 这三条只在这一处用，不扩散到别的地方。
 */
.code-value,
.invite-code {
  font-family: var(--font-code);
  font-feature-settings: 'zero' 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.14em;
}
/* 输入框里也走等宽 —— 抄写时是把屏幕上的和手里的对着看，两边得同一种字 */
.input--code {
  font-family: var(--font-code);
  font-feature-settings: 'zero' 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.invite-code {
  font-size: var(--text-xl); font-weight: 600; letter-spacing: 0.2em;
  padding: var(--space-4) var(--space-5); border-radius: var(--radius-lg);
  border: 1px dashed var(--border-strong); background: var(--background);
  color: var(--foreground); user-select: all;
}

/* ---------------------------------------------------------------- 账号门 */
.gate {
  min-height: 100dvh; display: grid; place-items: center;
  padding: var(--space-6);
  background: var(--background);
}
.gate__card {
  width: 100%; max-width: 24rem;
  display: flex; flex-direction: column; gap: var(--space-5);
  padding: var(--space-8) var(--space-6);
  border: 1px solid var(--border); border-radius: var(--radius-xl);
  background: var(--card);
  box-shadow: var(--shadow-sm);
}
.gate__brand {
  display: flex; align-items: center; gap: var(--space-2);
  font-family: var(--font-display); font-size: var(--text-base); font-weight: 600;
}
.gate__avatar {
  width: 3rem; height: 3rem; border-radius: var(--radius-lg);
  display: grid; place-items: center;
  background: var(--primary-soft); color: var(--primary);
  font-family: var(--font-display); font-size: var(--text-xl); font-weight: 600;
}
.gate__title { font-family: var(--font-display); font-size: var(--text-xl); font-weight: 600; }
.gate__desc { font-size: var(--text-sm); color: var(--muted-foreground); }
.gate__form { display: flex; flex-direction: column; gap: var(--space-4); }
.gate .entity-card[aria-current="true"] { background: var(--primary-soft); }

/* 首屏的二选一：注册账号 / 只用本机 */
.gate__choices { display: flex; flex-direction: column; gap: var(--space-2); }
.gate__choice {
  display: flex; align-items: center; gap: var(--space-3);
  width: 100%; text-align: left;
  padding: var(--space-3) var(--space-3);
  border: 1px solid var(--border); border-radius: var(--radius-lg);
  background: var(--background-elevated);
  transition: border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .gate__choice:hover { border-color: var(--primary-line); background: var(--primary-soft); }
}
.gate__choice-icon {
  width: 2rem; height: 2rem; flex-shrink: 0;
  display: grid; place-items: center;
  border-radius: var(--radius-md);
  background: var(--muted); color: var(--muted-foreground);
}
@media (hover: hover) {
  .gate__choice:hover .gate__choice-icon { background: var(--background); color: var(--primary); }
}
.gate__choice-title { display: block; font-size: var(--text-sm); font-weight: 600; }
.gate__choice-desc { display: block; font-size: var(--text-xs); color: var(--muted-foreground); margin-top: 1px; }

/* 恢复码：要让用户能一眼看清、方便抄下来 */
.gate__code {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px dashed var(--border-strong); border-radius: var(--radius-lg);
  background: var(--background);
}
.gate__code code {
  flex: 1 1 auto;
  font-family: var(--font-mono); font-size: var(--text-lg); font-weight: 600;
  letter-spacing: 0.12em; user-select: all;
}

.gate__link {
  align-self: flex-start;
  font-size: var(--text-sm); color: var(--muted-foreground);
  text-decoration: underline; text-underline-offset: 3px;
  /* 触屏上要够高才点得中（实测只有 24 —— 全场最小的可点元素） */
  display: inline-flex; align-items: center;
  padding-block: var(--space-1);
}
@media (hover: hover) {
  .gate__link:hover { color: var(--primary); }
}
.gate__error { font-size: var(--text-sm); color: var(--destructive); }

/* 同步状态点：绿=正常，黄=进行中，红=出错 */
.sync-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--faint-foreground); }
.sync-dot--ok { background: var(--success); }
.sync-dot--busy { background: var(--warning); animation: fa-pulse 1.2s ease-in-out infinite; }
.sync-dot--error { background: var(--destructive); }
@keyframes fa-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

/* ---------------------------------------------------------------- 打印 */
@media print {
  .app-rail, .app-header, .app-tabbar, .toast-region, .btn, .graph-toolbar { display: none !important; }
  .app-main { margin: 0; padding: 0; }
  .yearbook { border: none; }
  .yearbook__cover { background: none; }
}

/*
 * 窄屏（手机）的工具栏排版。
 *
 * 为什么必须压缩：实测 390px 下，"界面本身"（顶栏 + 页头 + 工具栏 + 筛选）
 * 吃掉了 443px —— 整整 52% 的屏幕，海报墙从 y=499 才开始。
 * 电脑上同样的东西只占 158px（17%）。差别全在换行：工具栏 3 行、筛选 2 行。
 *
 * 用 grid 而不是 flex-wrap：flex 换行管不住"谁和谁一行" ——
 * 试过给搜索框 flex-basis: 100%，结果第一个筛选器又挤回第一行，
 * 三个筛选器只剩 78px 一个，连"全部片种"都显示不全。
 * grid 把位置写死，才不会随内容宽度漂。
 *
 * 顺序、元素、位置一个不动 —— 手机上仍是电脑那一套，只是不再一行行地堆。
 * 这一段必须留在文件最末尾：里面很多选择器和上面同特异性，谁赢只看谁在后面。
 */
@media (max-width: 768px) {
  .archive-toolbar { gap: var(--space-2); }

  .archive-toolbar__row {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-2);
  }

  /* 第一行：搜索框占两格，视图切换占一格 */
  .archive-toolbar__row .grow { grid-area: 1 / 1 / 2 / 3; min-width: 0; }
  .archive-toolbar__row .row.gap-1 { grid-area: 1 / 3 / 2 / 4; justify-content: flex-end; }

  /* 第二行：三个筛选器各占一格（自动落位，不用写死第几个） */
  .archive-toolbar__row .toolbar-select { min-width: 0; width: 100%; }

  /*
   * 状态筛选：一行，横向滚。
   * 五个芯片 + "多选"在 390px 上放不下，换行之后会变成两行、
   * 而且"多选"孤零零占一整行。改成芯片滚、"多选"钉在右边。
   */
  .filters--tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .filters--tabs::-webkit-scrollbar { display: none; }
  .filters--tabs .chip { flex: 0 0 auto; }
  .filters--tabs .chip + .chip { margin-left: var(--space-4); }
  .filters--tabs .grow { flex: 0 0 var(--space-2); }
  .filters--tabs > .btn { flex: 0 0 auto; }
}

/* ---------------------------------------------------------------- 主题列表 */
/*
 * 主题是一个扁平列表：浅色 / 深色 / 午夜黑 / 以及 skins 里带图案的主题。
 * 带图案的没有线条图标可用，就显示它的两个代表色点。
 */
.segmented--themes { flex-wrap: wrap; }

.theme-swatch {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  vertical-align: -2px;
}
.theme-swatch i {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: var(--radius-full);
  display: block;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
}


/* 搜索结果里展开的"季列表"：往右缩一档 + 左边一条细线，表明它属于上面那部剧 */
.result-seasons {
  margin: 0 0 var(--space-3) var(--space-6);
  padding-left: var(--space-4);
  border-left: 2px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
